From c6474cc8e9dc079ccc286ff2141fb24c8f666199 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:51:45 +0800 Subject: [PATCH] refactor(ghidra): drop the unreleased checkpoint v1 reader and a one-time pattern proof (#10560) * refactor(ghidra): drop the unreleased checkpoint v1 reader and a one-time pattern proof The Ghidra checkpoint manifest v1 shape and its upgrade to v2 arrived in the same commit (c07ab4b994, first released in 6.9.0), and every release writes v2, so no released build produced v1. A v1 manifest is now refused as malformed and left unchanged, like any other unknown manifest. portableSchemaPatterns.test.ts proved two advertised patterns equal the look-ahead regexes they replaced by enumerating 90,000 strings. The lasting invariant (no look-around in advertised patterns) is asserted catalog-wide by advertisedPatternsVMode.test.ts for the default and full input profiles. The current accept/refuse contract for dylib roots and the reserved process environment name moves to the owning schema tests as small tables. Refs #10515 * test(process): retain malformed environment name coverage --- src/domain/apple/dylibResolution.test.ts | 26 +++++++ src/domain/portableSchemaPatterns.test.ts | 79 ---------------------- src/domain/process/processScenario.test.ts | 23 +++++++ src/ghidra/GhidraCheckpoint.test.ts | 31 ++------- src/ghidra/GhidraCheckpoint.ts | 33 +++------ 5 files changed, 66 insertions(+), 126 deletions(-) delete mode 100644 src/domain/portableSchemaPatterns.test.ts diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts index a775e766a9..8d09a43f13 100644 --- a/src/domain/apple/dylibResolution.test.ts +++ b/src/domain/apple/dylibResolution.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + dylibResolutionInputSchema, machoImageSlices, traceDylibLoading, type DylibTreeEntry, @@ -1794,3 +1795,28 @@ it.each([ } }, ); + +describe("caller-selected dylib roots", () => { + const admits = (root: string) => + dylibResolutionInputSchema.safeParse({ roots: [root] }).success; + + it.each(["a", "a/b", ".a", "..a", "a.", "a/.b/..c", "Contents/MacOS/App"])( + "admits the normalized relative root %j", + (root) => expect(admits(root)).toBe(true), + ); + + it.each([ + "", + "/a", + "a/", + "a//b", + ".", + "..", + "./a", + "a/./b", + "a/../b", + "a/..", + "a\\b", + "a\u0000b", + ])("refuses the root %j", (root) => expect(admits(root)).toBe(false)); +}); diff --git a/src/domain/portableSchemaPatterns.test.ts b/src/domain/portableSchemaPatterns.test.ts deleted file mode 100644 index f6148d400c..0000000000 --- a/src/domain/portableSchemaPatterns.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { dylibResolutionInputSchema } from "./apple/dylibResolution.js"; -import { processScenarioSchema } from "./process/processScenario.js"; - -// The look-ahead spellings these schemas advertised before; RE2 and Rust -// validators reject look-around, so the schemas now use equivalent complements. -const lookaheadRootRelativePath = - /^(?!\/)(?!(?:.*\/)?\.{1,2}(?:\/|$))(?!.*\/\/)(?!.*\/$)[^\\\u0000]+$/u; -const lookaheadEnvironmentName = - /^(?!REA_PROCESS_RUN_ID(?![\s\S]))[^=\u0000]+$/u; - -const strings = (alphabet: readonly string[], maxLength: number): string[] => { - const all: string[] = []; - let level = [""]; - for (let length = 1; length <= maxLength; length += 1) { - level = level.flatMap((prefix) => - alphabet.map((character) => `${prefix}${character}`), - ); - all.push(...level); - } - return all; -}; - -describe("look-around-free advertised patterns", () => { - it("accepts exactly the previous normalized relative dylib roots", () => { - const candidates = strings(["a", ".", "/", "\\", "\u0000"], 7); - const mismatches = candidates.filter( - (root) => - dylibResolutionInputSchema.safeParse({ roots: [root] }).success !== - lookaheadRootRelativePath.test(root), - ); - expect(candidates.length).toBeGreaterThan(90_000); - expect(mismatches).toEqual([]); - }); - - it("accepts exactly the previous environment names around the reserved name", () => { - const reserved = "REA_PROCESS_RUN_ID"; - const edits = ["", "R", "X", "_", "=", "\u0000", "é"]; - const candidates = new Set(strings(["R", "E", "=", "x"], 5)); - for (let index = 0; index <= reserved.length; index += 1) - for (const edit of edits) { - const prefix = reserved.slice(0, index); - candidates.add(`${prefix}${edit}`); - candidates.add(`${prefix}${edit}${reserved.slice(index)}`); - candidates.add(`${prefix}${edit}${reserved.slice(index + 1)}`); - candidates.add(`${edit}${reserved.slice(index)}`); - } - const accepts = (name: string) => - processScenarioSchema.safeParse({ - executable: "/bin/true", - environment: { [name]: "value" }, - }).success; - const mismatches = [...candidates].filter( - (name) => accepts(name) !== lookaheadEnvironmentName.test(name), - ); - expect(accepts(reserved)).toBe(false); - expect(accepts(`${reserved}_2`)).toBe(true); - expect(accepts("REA_PROCESS_RUN")).toBe(true); - expect(mismatches).toEqual([]); - }); - - it("keeps the reserved-name diagnostic", () => { - const parsed = processScenarioSchema.safeParse({ - executable: "/bin/true", - environment: { REA_PROCESS_RUN_ID: "value" }, - }); - // Record keys report their own issues nested under the invalid key. - expect(parsed.error?.issues).toMatchObject([ - { - code: "invalid_key", - path: ["environment", "REA_PROCESS_RUN_ID"], - issues: [ - { message: "REA_PROCESS_RUN_ID is reserved by the process adapter" }, - ], - }, - ]); - }); -}); diff --git a/src/domain/process/processScenario.test.ts b/src/domain/process/processScenario.test.ts index e364fcd69c..f36bdeba4c 100644 --- a/src/domain/process/processScenario.test.ts +++ b/src/domain/process/processScenario.test.ts @@ -30,6 +30,29 @@ it("advertises the same unsupported PTY environment name as runtime admission", }), ).toBe(true); expect(validate({ REA_PROCESS_RUN_ID: "reserved" })).toBe(false); + for (const name of ["", "A=B", "A\0B"]) { + const environment = { [name]: "value" }; + expect(validate(environment), name).toBe(false); + expect( + processScenarioSchema.safeParse({ ...baseScenario, environment }).success, + name, + ).toBe(false); + } + // Only the exact reserved name is refused, in the schema and at runtime. + for (const name of [ + "REA_PROCESS_RUN_ID_2", + "REA_PROCESS_RUN", + "XREA_PROCESS_RUN_ID", + ]) { + expect(validate({ [name]: "value" }), name).toBe(true); + expect( + processScenarioSchema.safeParse({ + ...baseScenario, + environment: { [name]: "value" }, + }).success, + name, + ).toBe(true); + } }); it("defaults the finalization interval to zero without changing committed identity", () => { diff --git a/src/ghidra/GhidraCheckpoint.test.ts b/src/ghidra/GhidraCheckpoint.test.ts index f10c731f59..97f29425be 100644 --- a/src/ghidra/GhidraCheckpoint.test.ts +++ b/src/ghidra/GhidraCheckpoint.test.ts @@ -273,7 +273,7 @@ it.skipIf(process.platform === "win32")( ); it.skipIf(process.platform === "win32")( - "keeps initial identity stable and retains legacy data without guessing its origin", + "keeps initial identity stable and rejects an unknown manifest version without rewriting it", async () => { const f = await fixture(); await f.owner.acquire("initial"); @@ -290,29 +290,12 @@ it.skipIf(process.platform === "win32")( const record = JSON.parse(await readFile(path, "utf8")); expect(record.version).toBe(2); expect(record.source.kind).toBe("initial-analysis"); - const legacy = { - ...record, - version: 1, - source: { file: record.source.file, sha256: record.source.sha256 }, - }; - await writeFile(path, JSON.stringify(legacy)); - const selected = await resolveGhidraCheckpoint( - f.root, - target, - profile, - f.resources, - ); - expect(selected.state).toEqual({ - kind: "retained", - sha256: record.source.sha256, - }); - const resumed = new GhidraCheckpoint(selected, f.resources); - owners.push(resumed); - expect((await resumed.acquire("legacy"))?.sha256).toBe( - record.source.sha256, - ); - await resumed.close(); - expect(JSON.parse(await readFile(path, "utf8"))).toEqual(legacy); + const unknown = JSON.stringify({ ...record, version: 1 }); + await writeFile(path, unknown); + await expect( + resolveGhidraCheckpoint(f.root, target, profile, f.resources), + ).rejects.toThrow(/manifest is malformed/); + expect(await readFile(path, "utf8")).toBe(unknown); }, ); diff --git a/src/ghidra/GhidraCheckpoint.ts b/src/ghidra/GhidraCheckpoint.ts index 9b9d85d8ed..e526dc129e 100644 --- a/src/ghidra/GhidraCheckpoint.ts +++ b/src/ghidra/GhidraCheckpoint.ts @@ -35,35 +35,22 @@ import { } from "./GhidraFileArtifacts.js"; const digest = z.string().regex(/^[a-f0-9]{64}$/); -const sourceIdentitySchema = z +const sourceSchema = z .object({ file: z.string().regex(/^program-[a-f0-9-]{36}\.gzf$/), sha256: digest, + kind: z.enum(["initial-analysis", "retained"]), }) .strict(); -const sourceSchema = sourceIdentitySchema.extend({ - kind: z.enum(["initial-analysis", "retained"]), -}); type CheckpointSource = z.infer; -const manifestIdentitySchema = z.object({ - target_sha256: digest, - base_profile_digest: digest, -}); -const manifestSchema = z.union([ - manifestIdentitySchema - .extend({ version: z.literal(2), source: sourceSchema }) - .strict(), - // Earlier local checkpoints have no initial-generation proof. Retain their - // data, binding their identity to the verified content rather than guessing. - manifestIdentitySchema - .extend({ version: z.literal(1), source: sourceIdentitySchema }) - .strict() - .transform((value) => ({ - ...value, - version: 2 as const, - source: { ...value.source, kind: "retained" as const }, - })), -]); +const manifestSchema = z + .object({ + version: z.literal(2), + target_sha256: digest, + base_profile_digest: digest, + source: sourceSchema, + }) + .strict(); const stateSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("initial-analysis") }).strict(), z.object({ kind: z.literal("retained"), sha256: digest }).strict(),