diff --git a/docs/client-verification.md b/docs/client-verification.md index a02c67edf..42e059382 100644 --- a/docs/client-verification.md +++ b/docs/client-verification.md @@ -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 diff --git a/package.json b/package.json index 6fdb0d9de..cbb173b4f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/lib/clean-linux-hopper-runtime.d.mts b/scripts/lib/clean-linux-hopper-runtime.d.mts new file mode 100644 index 000000000..dd1010aa1 --- /dev/null +++ b/scripts/lib/clean-linux-hopper-runtime.d.mts @@ -0,0 +1,3 @@ +export function assertCleanLinuxHopperRuntime( + launcherPaths?: readonly string[], +): Promise; diff --git a/scripts/lib/clean-linux-hopper-runtime.mjs b/scripts/lib/clean-linux-hopper-runtime.mjs new file mode 100644 index 000000000..27a306fce --- /dev/null +++ b/scripts/lib/clean-linux-hopper-runtime.mjs @@ -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; +}; diff --git a/scripts/lib/real-hopper-search.d.mts b/scripts/lib/real-hopper-search.d.mts new file mode 100644 index 000000000..e64031652 --- /dev/null +++ b/scripts/lib/real-hopper-search.d.mts @@ -0,0 +1,30 @@ +type VerifierCall = ( + name: string, + arguments_?: Record, +) => Promise; + +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[] | 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"; diff --git a/scripts/lib/real-hopper-search.mjs b/scripts/lib/real-hopper-search.mjs index 0d796f3d2..3456bc6b2 100644 --- a/scripts/lib/real-hopper-search.mjs +++ b/scripts/lib/real-hopper-search.mjs @@ -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, }; } diff --git a/scripts/verify-linux-setup-e2e.mjs b/scripts/verify-linux-setup-e2e.mjs index 8b57fbacc..e08e8b160 100644 --- a/scripts/verify-linux-setup-e2e.mjs +++ b/scripts/verify-linux-setup-e2e.mjs @@ -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, diff --git a/scripts/verify-real-hopper-linux.mjs b/scripts/verify-real-hopper-linux.mjs index 854a58ce0..951b71078 100644 --- a/scripts/verify-real-hopper-linux.mjs +++ b/scripts/verify-real-hopper-linux.mjs @@ -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 }); } diff --git a/tests/conformance/c/fixture.c b/tests/conformance/c/fixture.c index 6708ac9dd..e1dff9a9c 100644 --- a/tests/conformance/c/fixture.c +++ b/tests/conformance/c/fixture.c @@ -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) { diff --git a/tests/conformance/providers/hopper/realHopperSearch.test.ts b/tests/conformance/providers/hopper/realHopperSearch.test.ts new file mode 100644 index 000000000..10aedb433 --- /dev/null +++ b/tests/conformance/providers/hopper/realHopperSearch.test.ts @@ -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 = {}, +) { + return async (name: string, arguments_: Record = {}) => { + 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}`); + }; +} diff --git a/tests/process-global/cleanLinuxHopperRuntime.test.ts b/tests/process-global/cleanLinuxHopperRuntime.test.ts new file mode 100644 index 000000000..d356f5abf --- /dev/null +++ b/tests/process-global/cleanLinuxHopperRuntime.test.ts @@ -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); + } + } + }, +);