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(),