mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
test(hopper): repair Linux demo setup and native verification (#2638)
* fix(verification): align Linux Hopper setup lane with current contracts * fix(verification): retain orphan checks on clean Linux runners * fix(verification): detect Hopper through alternate launcher paths * fix(verification): report identified Hopper leaks before proc errors * fix(verification): distinguish Hopper string recognition from byte fidelity * fix(verification): report full literal decoding coverage explicitly --------- Co-authored-by: Luis Taffelli <86675342+LuisTaffelli@users.noreply.github.com>
This commit is contained in:
1 parent
18831491cf
commit
141d2095f5
11 files changed
+520
-60
No files matched your search
@@ -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
@@ -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,64 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* 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 { stdout } = await execFileAsync("ps", ["-ax", "-o", "command="]);
|
||||
if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(stdout))
|
||||
throw new Error("Clean Linux runner retained a Hopper demo process");
|
||||
const launchers = [];
|
||||
for (const path of launcherPaths) {
|
||||
try {
|
||||
launchers.push(await stat(path, { bigint: true }));
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
if (launchers.length === 0) return;
|
||||
// /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 executable;
|
||||
try {
|
||||
executable = await stat(`/proc/${entry.name}/exe`, { bigint: true });
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT" || error?.code === "ESRCH") continue;
|
||||
// Other users' executables 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 (
|
||||
launchers.some(
|
||||
({ dev, ino }) => dev === executable.dev && ino === executable.ino,
|
||||
)
|
||||
)
|
||||
throw new Error("Clean Linux runner retained a Hopper demo process");
|
||||
}
|
||||
if (inspectionError !== undefined) throw inspectionError;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
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;
|
||||
}>;
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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}`);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { chmod, copyFile, readFile, symlink, unlink } 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")
|
||||
.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);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user