Compare commits

...
Author SHA1 Message Date
N0zoM1z0 c0d44fe8f5 fix(hopper): distinguish runtime processes from argument text
Parse procfs argument boundaries rather than searching rendered ps command text. Keep executable identity checks and deferred permission failures, eliminate the extra ps process, and put shared process observations in the serial process-boundary lane.
2026-10-13 06:44:26 +00:00
N0zoM1z0 11c188523a test(hopper): verify Linux demo setup and source-bound string coverage
Integrate the verification corrections from #1942 on current main, retaining their original commit history. Reuse the unchanged document inventory and verified source bytes, retain recognition addresses, and reject incomplete or mismatched string evidence.
2026-10-13 06:21:07 +00:00
Luis Taffelli 794debe565 chore(verification): sync Hopper validation with latest main
Integrate main 653067d7, preserving the tested Hopper corrections.
The incoming changes are JavaScript/Apple implementation and regression
updates; Hopper provider, verifier and dependency bytes are unchanged.

Real Hopper runs remain bound to exact head 97b76aa2 and tested checkout
f379bc38. This final integration has no new native execution claim.
26 focused tests, full static check and docs:check passed on this tree.
2026-10-12 19:33:06 -03:00
Luis Taffelli f379bc3882 fix(verification): integrate Linux Hopper lane with current contracts
Integrate main ae8051302 while retaining the Linux setup, orphan detection,
launcher restoration and explicit string-recognition coverage corrections.
Move verification guidance to its current owner and use the test-scoped
temporary-directory helper.

The checkout matches all 3,175 exported source-file digests of the tested
candidate. Git stores the Windows captureGhidraLaunch.cmd fixture with LF;
its exported checkout uses CRLF per .gitattributes. It is outside Linux
Hopper execution. No native run is attributed to different runtime bytes.
Real Hopper conformance: 9e7de373-4686-4820-ae48-801e536589b7.
Uninterrupted Linux setup: 93584b53-944b-47cf-a6a4-b0d4ff8a4961.
The exact 97b76aa2 run remains separate: ae4de7ff-dd2a-4c73-88dc-6dbf1ad7c537.

21 focused tests, check, docs:check, four fixtures, discovery, package
verification and pack dry-run passed. Linux native decoded omissions,
macOS/other builds and installed npm artifacts with Hopper remain unverified.

Refs #339, #340
2026-10-12 19:31:20 -03:00
Luis Taffelli 97b76aa20a fix(verification): report full literal decoding coverage explicitly 2026-10-11 18:46:16 -03:00
Luis Taffelli 8ea0432ffc fix(verification): distinguish Hopper string recognition from byte fidelity 2026-10-11 18:31:29 -03:00
Luis Taffelli b9d9a474cf fix(verification): report identified Hopper leaks before proc errors 2026-10-11 18:28:41 -03:00
Luis Taffelli b88cdc248a fix(verification): detect Hopper through alternate launcher paths 2026-10-11 17:06:32 -03:00
Luis Taffelli c039dd81d5 fix(verification): retain orphan checks on clean Linux runners 2026-10-11 16:35:44 -03:00
Luis Taffelli 58d23e42b6 fix(verification): align Linux Hopper setup lane with current contracts 2026-10-11 16:06:09 -03:00
11 changed files with 587 additions and 60 deletions
+31 -2
View File
@@ -298,13 +298,42 @@ subsequent wire recovery and clean shutdown. Caller timeout does not interrupt
Hopper's synchronous native operation. Socket boundary tests deterministically
cover active and queued expiry, late replies and timer cleanup.
The Linux demo lane remains a separate `verify:hopper:linux` command. Its
lifecycle checks attempt competing CLI launches for the active target and a
The Linux demo lane remains a separate `verify:hopper:linux` command. Hopper
6.4.2's ELF loader did not recognize the original 4,119-byte literal or three
non-ASCII byte strings in the measured fixtures. The lane retains complete raw
byte checks for those literals through source-owned symbols. Recognition of the
original full-length literal is bound to its exact source address; its decoded
object coverage remains explicitly unverified on Linux even when recognized.
Absent non-ASCII recognition also reports decoded-object coverage as unverified.
Recognized non-ASCII and control-text objects must still match their source
bytes, encoding, filters and searches.
A separate 548-byte ASCII literal requires native fragmentation, shortened
provider display, complete decoded fragments and dossier provenance on Linux;
the macOS lane retains the original full-length recognition requirement. These
are observed fixture/build capabilities, not universal Hopper length limits.
Its lifecycle checks attempt competing CLI launches for the active target and a
different target, requiring rejection before either can change the owning
MCP session's documents or procedures. Lease boundary tests also preserve
unresponsive or malformed live endpoints while allowing confirmed stale
sockets to recover.
`verify:setup:linux` exercises installation on a clean supported Linux runner as
root. Use a disposable VM: this lane installs Hopper and its Linux runtime
dependencies through the system package manager. Set `REA_VERIFY_SERVER_COMMAND`
to the CLI entrypoint being verified. The command builds and checks source-owned
ELF fixtures before testing the read-only Hopper setup plan, approved
installation, scoped doctor, real CLI/MCP analysis, rejection of a modified
launcher, idempotent setup without requesting a reinstall, and owned child
cleanup. Before installation and after teardown, a clean-runner process scan
also rejects surviving Hopper demo helpers or Xvfb groups, including orphans
whose launcher exited. This scan observes processes without signaling them.
Hopper detection compares executable file identity through `/proc`, covering
alternate or configured launcher paths even after an invoked symlink is removed.
The dedicated runner must have no unrelated Hopper/Xvfb sessions. It requires
the repository's pinned development toolchain and an
existing host C compiler, `file`, `nm`, and `strings`. Use `verify:hopper:linux`
instead when Hopper is already installed.
`verify:hopper:fat` is a separate macOS lane requiring installed Hopper and the
existing Xcode clang/lipo toolchain. It compiles arm64/x86-64 thin executables and
one- and two-slice FAT32 containers, verifies exact/interior address mappings
+1 -1
View File
@@ -207,7 +207,7 @@
"fixtures:harmony": "node scripts/fixtures/harmony/fetch.mjs",
"verify:agent": "npm run build:cached && node scripts/verify-agent-experience.mjs",
"verify:hopper:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper-linux.mjs",
"verify:setup:linux": "npm run build:cached && node scripts/verify-linux-setup-e2e.mjs",
"verify:setup:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-linux-setup-e2e.mjs",
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
"build:ghidra-windows-fixture": "node scripts/create-ghidra-windows-fixture.mjs",
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
@@ -0,0 +1,3 @@
export function assertCleanLinuxHopperRuntime(
launcherPaths?: readonly string[],
): Promise<void>;
@@ -0,0 +1,70 @@
import { readFile, readdir, stat } from "node:fs/promises";
import { basename } from "node:path";
/**
* Observe leftover demo processes on a dedicated clean Linux runner, including
* groups reparented after their launcher exits. This is not ownership proof and
* never authorizes signaling; unrelated runtime processes also block this lane.
*/
export const assertCleanLinuxHopperRuntime = async (
launcherPaths = [
"/opt/hopper/bin/Hopper",
...(process.env.HOPPER_LAUNCHER_PATH === undefined
? []
: [process.env.HOPPER_LAUNCHER_PATH]),
],
) => {
const launchers = [];
for (const path of launcherPaths) {
try {
launchers.push(await stat(path, { bigint: true }));
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
// /proc exposes the executed file even when argv[0] names a removed symlink.
// Compare file identity, not a display path or a process name.
let inspectionError;
for (const entry of await readdir("/proc", { withFileTypes: true })) {
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue;
let retainedRuntime;
try {
// procfs preserves argument boundaries. A caller merely mentioning a
// runtime in its script text or other arguments is not that runtime.
const arguments_ = (
await readFile(`/proc/${entry.name}/cmdline`, "utf8")
).split("\0");
retainedRuntime =
arguments_[0] === "/opt/hopper/bin/Hopper" ||
basename(arguments_[0] ?? "") === "Xvfb" ||
(basename(arguments_[0] ?? "") === "python3" &&
basename(arguments_[1] ?? "") === "hopper-demo-x11.py");
if (!retainedRuntime && launchers.length > 0) {
const executable = await stat(`/proc/${entry.name}/exe`, {
bigint: true,
});
retainedRuntime = launchers.some(
({ dev, ino }) => dev === executable.dev && ino === executable.ino,
);
}
} catch (error) {
if (error?.code === "ENOENT" || error?.code === "ESRCH") continue;
// Other users' process facts may be unreadable. The setup lane launches
// providers as the caller; unreadable caller-owned processes fail closed.
if (error?.code === "EACCES") {
const owner = await stat(`/proc/${entry.name}`).catch((cause) => {
if (cause?.code === "ENOENT") return undefined;
throw cause;
});
if (owner === undefined || owner.uid !== process.getuid()) continue;
}
// Finish the inventory so an unrelated restricted process cannot mask a
// positively identified leak. Unknown caller processes still fail closed.
inspectionError ??= error;
continue;
}
if (retainedRuntime)
throw new Error("Clean Linux runner retained a Hopper demo process");
}
if (inspectionError !== undefined) throw inspectionError;
};
+31
View File
@@ -0,0 +1,31 @@
import type { z } from "zod";
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
type VerifierCall = (
name: string,
arguments_?: Record<string, unknown>,
) => Promise<unknown>;
interface StringExpectation {
readonly value: string;
readonly encoding: string;
readonly symbol?: string;
readonly recognitionOptional?: boolean;
}
/** Verify source bytes, observed decoding, address filters and literal searches. */
export function verifyHopperStringObjects(
call: VerifierCall,
expectations: readonly StringExpectation[],
inventory?: readonly z.output<typeof analysisStringSchema>[] | null,
): Promise<{
readonly sourceBytesVerified: true;
readonly recognition: readonly {
readonly value: string;
readonly address: string;
readonly state: "observed" | "not_observed";
readonly decodedObject: "verified" | "unverified";
}[];
readonly displayEscapesExcludedFromMatching: true;
}>;
+132 -28
View File
@@ -1,13 +1,16 @@
import assert from "node:assert/strict";
import { mcpTextValue } from "./mcp-verifier-results.mjs";
const fixtureSymbolPrefix = process.platform === "darwin" ? "_" : "";
/** Exercise literal/regex search, Unicode identities and cache invalidation in Hopper. */
export async function verifyHopperSearch(call, invalid, procedure) {
const address = procedure.address;
const original = await call("address_name", { address });
let stringCoverage;
try {
await verifySymbolNames(call, invalid, address);
await verifySearchModes(call, address);
stringCoverage = await verifySearchModes(call, address);
} finally {
await call("set_address_name", { address, name: original });
}
@@ -26,7 +29,7 @@ export async function verifyHopperSearch(call, invalid, procedure) {
symbolUtf16LimitValidatedBeforeMutation: true,
regexAndCaseModes: true,
annotationCacheInvalidation: true,
escapedUtf8AndByteStrings: true,
stringCoverage,
};
}
@@ -103,11 +106,26 @@ async function verifySearchModes(call, address) {
[{ address, value: "REA_Search_Upper" }],
);
const strings = await call("list_strings");
const long = "REA_LONG_LITERAL_" + "x".repeat(4096) + "needle";
const longItem = strings.find((item) =>
item.value.startsWith("REA_LONG_LITERAL_"),
const linux = process.platform === "linux";
const fullLiteral = "REA_LONG_LITERAL_" + "x".repeat(4096) + "needle";
// Recognition is a provider/loader capability, not an adapter guarantee.
// Always retain the original size in raw-byte coverage; Linux additionally
// requires an extent observed to produce native fragmented string objects.
const fullBytes = Buffer.from(fullLiteral + "\0");
const raw = await call("read_bytes", {
address: `${fixtureSymbolPrefix}rea_long_literal`,
length: fullBytes.length,
});
assert.equal(raw.complete, true);
assert.equal(raw.bytes_hex, fullBytes.toString("hex"));
const long = linux
? "REA_FRAGMENTED_LITERAL_" + "x".repeat(512) + "fragment_tail"
: fullLiteral;
const longItem = strings.find(
(item) => long.startsWith(item.value) && item.value.startsWith("REA_"),
);
assert.ok(longItem, "fixture omitted its real long literal");
if (!linux) assert.equal(longItem.address, raw.address);
assert.ok(long.startsWith(longItem.value));
assert.ok(longItem.provider_value.endsWith("…"));
assert.equal(longItem.string.termination, "missing");
@@ -158,7 +176,7 @@ async function verifySearchModes(call, address) {
}
assert.deepEqual(
await call("search_strings", {
pattern: "needle",
pattern: linux ? "fragment_tail" : "needle",
mode: "literal",
case_sensitive: true,
}),
@@ -176,36 +194,122 @@ async function verifySearchModes(call, address) {
}),
expected,
);
await verifyHopperStringObjects(call, [
{ value: "REA_UTF8_é_😀", encoding: "utf-8" },
{ value: 'REA_ESCAPED_"\\line\nend\t\r', encoding: "utf-8" },
{ value: "REA_LITERAL_BACKSLASH_\\n", encoding: "utf-8" },
{ value: "REA_LITERAL_…", encoding: "utf-8" },
{ value: "REA_LATIN1_ÿ", encoding: "latin-1" },
]);
const objects = await verifyHopperStringObjects(
call,
[
{
value: "REA_UTF8_é_😀",
encoding: "utf-8",
symbol: `${fixtureSymbolPrefix}rea_utf8_literal`,
recognitionOptional: linux,
},
{ value: 'REA_ESCAPED_"\\line\nend\t\r', encoding: "utf-8" },
{ value: "REA_LITERAL_BACKSLASH_\\n", encoding: "utf-8" },
{
value: "REA_LITERAL_…",
encoding: "utf-8",
symbol: `${fixtureSymbolPrefix}rea_ellipsis_literal`,
recognitionOptional: linux,
},
{
value: "REA_LATIN1_ÿ",
encoding: "latin-1",
symbol: `${fixtureSymbolPrefix}rea_latin1_literal`,
recognitionOptional: linux,
},
],
strings,
);
return {
fullLiteralSourceBytes: fullBytes.length,
fragmentedLiteralSourceBytes: Buffer.byteLength(long),
fullLiteralRecognition: {
address: raw.address,
state: strings.some((item) => item.address === raw.address)
? "observed"
: "not_observed",
// Linux validates the original through raw bytes; its required decoded
// fragment/dossier checks exercise the separate supported-size literal.
decodedObject: linux ? "unverified" : "verified",
},
...objects,
};
}
/** Compare native string identities and values with the source fixture's bytes. */
export async function verifyHopperStringObjects(call, expectations) {
const strings = await call("list_strings");
for (const { value, encoding } of expectations) {
const item = strings.find((candidate) => candidate.value === value);
assert.ok(
item,
`Hopper omitted the decoded string ${JSON.stringify(value)}`,
);
assert.equal(item.string.encoding, encoding);
assert.equal(item.string.encoding_status, "inferred");
assert.equal(item.string.termination, "present_or_not_required");
export async function verifyHopperStringObjects(
call,
expectations,
inventory = null,
) {
// Reuse only the caller's inventory for this unchanged document, never live
// state from an earlier session. Address-filter and search checks remain real.
const strings = inventory ?? (await call("list_strings"));
const recognition = [];
for (const {
value,
encoding,
symbol,
recognitionOptional = false,
} of expectations) {
const expectedBytes = Buffer.from(
value + "\0",
encoding.replaceAll("-", ""),
);
assert.equal(item.string.byte_length, expectedBytes.length);
const bytes = await call("read_bytes", {
let sourceBytes;
if (symbol !== undefined) {
const raw = await call("read_bytes", {
address: symbol,
length: expectedBytes.length,
});
sourceBytes = raw;
assert.equal(raw.complete, true);
assert.equal(raw.bytes_hex, expectedBytes.toString("hex"));
}
const item = strings.find((candidate) =>
sourceBytes === undefined
? candidate.value === value
: candidate.address === sourceBytes.address,
);
if (item === undefined && recognitionOptional) {
assert.ok(
symbol,
"optional recognition requires independent source-byte coverage",
);
recognition.push({
value,
address: sourceBytes.address,
state: "not_observed",
decodedObject: "unverified",
});
continue;
}
assert.ok(
item,
`Hopper omitted the decoded string ${JSON.stringify(value)}`,
);
assert.equal(
item.value,
value,
"native string decoding changed the source value",
);
recognition.push({
value,
address: item.address,
length: expectedBytes.length,
state: "observed",
decodedObject: "verified",
});
assert.equal(item.string.encoding, encoding);
assert.equal(item.string.encoding_status, "inferred");
assert.equal(item.string.termination, "present_or_not_required");
assert.equal(item.string.byte_length, expectedBytes.length);
const bytes =
sourceBytes ??
(await call("read_bytes", {
address: item.address,
length: expectedBytes.length,
}));
assert.equal(bytes.complete, true);
assert.equal(bytes.bytes_hex, expectedBytes.toString("hex"));
assert.deepEqual(await call("list_strings", { address: item.address }), [
item,
@@ -225,7 +329,7 @@ export async function verifyHopperStringObjects(call, expectations) {
);
return {
sourceBytesVerified: true,
inferredEncodingReported: true,
recognition,
displayEscapesExcludedFromMatching: true,
};
}
+30 -24
View File
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
import { promisify } from "node:util";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { assertCleanLinuxHopperRuntime } from "./lib/clean-linux-hopper-runtime.mjs";
const execFileAsync = promisify(execFile);
const verifierRun = createVerifierRun();
@@ -19,12 +20,12 @@ const command = process.env.REA_VERIFY_SERVER_COMMAND;
if (process.platform !== "linux" || process.getuid?.() !== 0)
throw new Error("Linux setup verification requires a root Linux runner");
if (!command) throw new Error("REA_VERIFY_SERVER_COMMAND is required");
await assertCleanLinuxHopperRuntime();
try {
await access("/opt/hopper/bin/Hopper");
throw new Error("Linux setup verification requires a clean runner");
} catch (error) {
if (error instanceof Error && error.message.includes("clean runner"))
throw error;
if (error?.code !== "ENOENT") throw error;
}
const runJson = async (args) => {
@@ -35,14 +36,25 @@ const runJson = async (args) => {
return JSON.parse(stdout);
};
const plan = await runJson(["setup", "--json"]);
if (plan.status !== "needs_confirmation")
throw new Error("setup did not produce a read-only confirmation plan");
const plan = await runJson([
"setup",
"--install-hopper",
"--dry-run",
"--json",
]);
if (
plan.status !== "planned" ||
!plan.plannedActions.some(({ kind }) => kind === "install_hopper") ||
plan.appliedActions.length !== 0
)
throw new Error("setup did not produce a read-only Hopper installation plan");
await access("/opt/hopper/bin/Hopper").then(
() => {
throw new Error("setup plan mutated the clean runner");
},
() => undefined,
(error) => {
if (error?.code !== "ENOENT") throw error;
},
);
const installed = await runJson([
@@ -58,7 +70,7 @@ if (
throw new Error(
`approved setup did not become ready: ${JSON.stringify(installed)}`,
);
const doctor = await runJson(["doctor", "--json"]);
const doctor = await runJson(["doctor", "--provider", "hopper", "--json"]);
if (doctor.healthy !== true)
throw new Error(`doctor is unhealthy: ${JSON.stringify(doctor)}`);
@@ -67,8 +79,6 @@ await execFileAsync(command, ["analyze", "/usr/bin/true"], {
timeout: 180_000,
maxBuffer: 10 * 1024 * 1024,
});
process.env.HOPPER_TARGET_PATH = "/usr/bin/true";
process.env.HOPPER_SECOND_TARGET_PATH = "/usr/bin/false";
process.env.REA_VERIFY_SERVER_ARGS_JSON = '["mcp"]';
await import("./verify-real-hopper-linux.mjs");
@@ -95,28 +105,24 @@ try {
await rm(modifiedDirectory, { recursive: true, force: true });
}
const repeated = await runJson([
"setup",
"--yes",
"--install-hopper",
"--json",
]);
const repeated = await runJson(["setup", "--yes", "--json"]);
if (
repeated.status !== "ready" ||
repeated.appliedActions.includes("installed_hopper")
repeated.plannedActions.length !== 0 ||
repeated.appliedActions.length !== 0
)
throw new Error("a second setup was not idempotent");
const { stdout: processes } = await execFileAsync("ps", [
"-ax",
"-o",
"command=",
]);
if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(processes))
throw new Error("Linux setup verification leaked a Hopper demo session");
await assertCleanLinuxHopperRuntime();
const completedVerifierRun = await completeVerifierRun(verifierRun);
if (
completedVerifierRun.process_lineage.status !== "verified" ||
completedVerifierRun.process_lineage.descendants.length !== 0
)
throw new Error("Linux setup verification did not prove owned child cleanup");
console.log(
JSON.stringify({
verifier_run: await completeVerifierRun(verifierRun),
verifier_run: completedVerifierRun,
status: "verified",
setup: true,
cli: true,
+4 -1
View File
@@ -11,7 +11,8 @@ createVerifierRun();
if (process.platform !== "linux")
throw new Error("Linux real-Hopper verification must run on Linux");
const launcher = process.env.HOPPER_LAUNCHER_PATH ?? "/opt/hopper/bin/Hopper";
const configuredLauncher = process.env.HOPPER_LAUNCHER_PATH;
const launcher = configuredLauncher ?? "/opt/hopper/bin/Hopper";
await access(launcher);
await access("/usr/bin/Xvfb");
await access("/usr/bin/xauth");
@@ -28,5 +29,7 @@ try {
process.env.HOPPER_LAUNCHER_PATH = alternateLauncher;
await import("./verify-real-hopper.mjs");
} finally {
if (configuredLauncher === undefined) delete process.env.HOPPER_LAUNCHER_PATH;
else process.env.HOPPER_LAUNCHER_PATH = configuredLauncher;
await rm(alternateDirectory, { recursive: true, force: true });
}
@@ -0,0 +1,173 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
chmod,
copyFile,
readFile,
symlink,
unlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { assertCleanLinuxHopperRuntime } from "../../../scripts/lib/clean-linux-hopper-runtime.mjs";
import { completeVerifierRun } from "../../../scripts/lib/verifier-run.mjs";
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execFileAsync = promisify(execFile);
it.runIf(process.platform === "linux")(
"does not treat runtime names in another process's arguments as a runtime",
async () => {
const observer = new URL(
"../../../scripts/lib/clean-linux-hopper-runtime.mjs",
import.meta.url,
).href;
const code = `
const { assertCleanLinuxHopperRuntime } = await import(${JSON.stringify(observer)});
await assertCleanLinuxHopperRuntime([]);
console.log('clean');
`;
const { stdout } = await execFileAsync(
process.execPath,
[
"--input-type=module",
"-e",
code,
"Xvfb",
"/opt/hopper/bin/Hopper",
"hopper-demo-x11.py",
],
{ timeout: 10_000 },
);
expect(stdout.trim()).toBe("clean");
},
);
it.runIf(process.platform === "linux")(
"distinguishes an ordinary Node script with the helper's filename",
async () => {
const directory = await createTestTempDirectory("rea-runtime-arguments-");
const script = join(directory, "hopper-demo-x11.py");
const observer = new URL(
"../../../scripts/lib/clean-linux-hopper-runtime.mjs",
import.meta.url,
).href;
await writeFile(
script,
`
import(${JSON.stringify(observer)}).then(async ({ assertCleanLinuxHopperRuntime }) => {
await assertCleanLinuxHopperRuntime([]);
console.log('clean');
});
`,
);
const { stdout } = await execFileAsync(process.execPath, [script], {
timeout: 10_000,
});
expect(stdout.trim()).toBe("clean");
},
);
it
.runIf(process.platform === "linux")
.each(["demo helper", "alternate launcher"])(
"rejects a reparented %s without signaling it",
async (mode) => {
const runId = randomUUID();
const childCode =
"process.title = 'Xvfb'; setInterval(() => {}, 1000); process.send('ready');";
const directory = await createTestTempDirectory("rea-clean-runtime-test-");
const launcher = join(directory, "Hopper");
const alternate = join(directory, "Hopper-alternate");
const alternateMode = mode === "alternate launcher";
if (alternateMode) {
await copyFile("/usr/bin/sleep", launcher);
await chmod(launcher, 0o700);
await symlink(launcher, alternate);
}
const command = alternateMode ? alternate : process.execPath;
const arguments_ = alternateMode ? ["30"] : ["-e", childCode];
const parentCode = `
const { spawn } = require('node:child_process');
const child = spawn(${JSON.stringify(command)}, ${JSON.stringify(arguments_)}, {
detached: true, stdio: ${JSON.stringify(alternateMode ? "ignore" : ["ignore", "ignore", "ignore", "ipc"])}
});
child.once('error', () => process.exit(1));
child.once(${JSON.stringify(alternateMode ? "spawn" : "message")}, () => {
process.stdout.write(JSON.stringify({ pid: child.pid, parentPid: process.pid }));
if (child.connected) child.disconnect();
child.unref();
});
`;
const parent = execFileAsync(process.execPath, ["-e", parentCode], {
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
timeout: 10_000,
});
const parentPid = parent.child.pid;
if (parentPid === undefined)
throw new Error("Fixture parent did not spawn");
let orphanPid: number | undefined;
try {
const { stdout } = await parent;
const { pid } = JSON.parse(stdout) as { pid: number };
orphanPid = pid;
const { stdout: coordinates } = await execFileAsync("ps", [
"-p",
String(pid),
"-o",
"ppid=,pgid=",
]);
const [currentParent, group] = coordinates
.trim()
.split(/\s+/u)
.map(Number);
expect(currentParent).not.toBe(parentPid);
expect(group).toBe(pid);
const lineage = await completeVerifierRun({
run_id: runId,
verifier_pid: process.pid,
parent_pid: process.ppid,
});
expect(lineage.process_lineage.status).toBe("verified");
if (lineage.process_lineage.status === "verified")
expect(
lineage.process_lineage.descendants.some((p) => p.pid === pid),
).toBe(false);
if (alternateMode) {
const commandLine = await readFile(
`/proc/${String(pid)}/cmdline`,
"utf8",
);
expect(commandLine.split("\0")[0]).toBe(alternate);
await unlink(alternate);
}
await expect(
assertCleanLinuxHopperRuntime(alternateMode ? [launcher] : []),
).rejects.toThrow("Clean Linux runner retained a Hopper demo process");
// Observation must not terminate the fixture, even though it resembles Xvfb.
const { stdout: alive } = await execFileAsync("ps", [
"-p",
String(pid),
"-o",
"stat=",
]);
expect(alive.trim()).not.toMatch(/^Z/u);
} finally {
if (orphanPid !== undefined) {
const cleanup = await cleanupOwnedProcessGroup({
runId,
leaderPid: orphanPid,
processGroupId: orphanPid,
});
expect(cleanup.cleaned, JSON.stringify(cleanup)).toBe(true);
}
}
},
);
+15 -4
View File
@@ -5,13 +5,24 @@ volatile int rea_c_global = 7;
#define REA_LONG_CHUNK "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
#define REA_LONG_256 REA_LONG_CHUNK REA_LONG_CHUNK REA_LONG_CHUNK REA_LONG_CHUNK
#define REA_LONG_1024 REA_LONG_256 REA_LONG_256 REA_LONG_256 REA_LONG_256
/* Keep the full-size literal even when a loader does not type it as a string. */
__attribute__((used)) const char rea_long_literal[] =
"REA_LONG_LITERAL_" REA_LONG_1024 REA_LONG_1024 REA_LONG_1024 REA_LONG_1024 "needle";
/* Linux Hopper 6.4.2 recognizes this extent and splits it into native objects. */
__attribute__((used)) const char rea_fragmented_literal[] =
"REA_FRAGMENTED_LITERAL_" REA_LONG_256 REA_LONG_256 "fragment_tail";
__attribute__((used)) const char rea_utf8_literal[] = "REA_UTF8_é_😀";
__attribute__((used)) const char rea_ellipsis_literal[] = "REA_LITERAL_…";
__attribute__((used)) const char rea_latin1_literal[] = "REA_LATIN1_\xff";
__attribute__((noinline, used)) void rea_long_literal_procedure(void) {
puts("REA_LONG_LITERAL_" REA_LONG_1024 REA_LONG_1024 REA_LONG_1024 REA_LONG_1024 "needle");
puts("REA_UTF8_é_😀");
puts(rea_long_literal);
puts(rea_fragmented_literal);
puts(rea_utf8_literal);
puts("REA_ESCAPED_\"\\line\nend\t\r");
puts("REA_LITERAL_BACKSLASH_\\n");
puts("REA_LITERAL_…");
puts("REA_LATIN1_\xff");
puts(rea_ellipsis_literal);
puts(rea_latin1_literal);
}
__attribute__((noinline, used)) int rea_leaf(int value) {
@@ -0,0 +1,97 @@
import { expect, it } from "vitest";
import { verifyHopperStringObjects } from "../../../../scripts/lib/real-hopper-search.mjs";
const value = "REA_UTF8_é_😀";
const address = "0x402020";
const bytes = Buffer.from(`${value}\0`);
const item = {
address,
value,
string: {
encoding: "utf-8",
encoding_status: "inferred" as const,
termination: "present_or_not_required" as const,
byte_length: bytes.length,
},
};
const expectation = {
value,
encoding: "utf-8",
symbol: "rea_utf8_literal",
recognitionOptional: true,
};
it("keeps source identity and unknown decoding when the same text exists elsewhere", async () => {
const report = await verifyHopperStringObjects(
fixtureCall(),
[expectation],
[{ ...item, address: "0x403000" }],
);
expect(report.recognition).toEqual([
{ value, address, state: "not_observed", decodedObject: "unverified" },
]);
});
it("reuses source bytes and inventory while checking real address filters and searches", async () => {
const calls: string[] = [];
const report = await verifyHopperStringObjects(
fixtureCall(calls),
[expectation],
[item],
);
expect(report.recognition).toEqual([
{ value, address, state: "observed", decodedObject: "verified" },
]);
expect(calls).toEqual([
"read_bytes:rea_utf8_literal",
`list_strings:${address}`,
`search_strings:${value}`,
"search_strings:\\xC3\\xA9",
]);
});
it.each([
["changed source bytes", { bytes_hex: "00" }, item],
["incomplete source read", { complete: false }, item],
["wrong decoded value", {}, { ...item, value: "wrong" }],
[
"wrong decoded extent",
{},
{ ...item, string: { ...item.string, byte_length: 1 } },
],
])(
"rejects %s even when native recognition is optional",
async (_label, override, decoded) => {
await expect(
verifyHopperStringObjects(
fixtureCall([], override),
[expectation],
[decoded],
),
).rejects.toThrow();
},
);
function fixtureCall(
calls: string[] = [],
override: Record<string, unknown> = {},
) {
return async (name: string, arguments_: Record<string, unknown> = {}) => {
calls.push(
`${name}:${String(arguments_.address ?? arguments_.pattern ?? "")}`,
);
if (name === "read_bytes")
return {
address,
bytes_hex: bytes.toString("hex"),
complete: true,
...override,
};
if (name === "list_strings" && arguments_.address === address)
return [item];
if (name === "search_strings")
return arguments_.pattern === value ? [item] : [];
throw new Error(`Unmodeled verifier call: ${name}`);
};
}