diff --git a/src/domain/analysisSnapshot.test.ts b/src/domain/analysisSnapshot.test.ts index 00f34684f5..85580248e5 100644 --- a/src/domain/analysisSnapshot.test.ts +++ b/src/domain/analysisSnapshot.test.ts @@ -113,6 +113,37 @@ describe("analysis snapshot contract", () => { expect(parsed.evidence_bundle.records[0]?.subject?.format).toBe("dos-mz"); }); + it("rejects a snapshot subject path containing NUL", () => { + const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE); + const entry = { + query_id: `query_${"0".repeat(64)}`, + operation: "analyze_function", + parameters: {}, + execution: { + result: null, + raw_result: null, + provider: binding.provider, + limitations: [], + locations: [], + subject: { + path: "a\u0000b", + sha256: "a".repeat(64), + format: "elf" as const, + architecture: null, + }, + }, + }; + expect( + analysisSnapshotSchema.safeParse({ + target: snapshotTarget(ANALYSIS_SNAPSHOT_TARGET), + binding, + entries: [entry], + workflow_entries: [], + evidence_bundle: createEvidenceBundle([]), + }).success, + ).toBe(false); + }); + it("accepts snapshots with more than ten thousand analysis entries", () => { const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE); const entry = { diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index a60b705247..e839b1e97d 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -7,6 +7,7 @@ import { compareUnicodeCodePoints, compositeKey, } from "./unicodeCodePointOrder.js"; +import { localPathStringSchema } from "./localPath.js"; import { analysisProfileSchema, @@ -65,7 +66,7 @@ const formatSchema = z.enum([ ]); const kindSchema = z.enum(["executable", "database", "archive", "artifact"]); const subjectSchema = z.object({ - path: z.string().min(1), + path: localPathStringSchema, sha256: digestSchema, format: z.enum([ ...formatSchema.options, diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts index 9a129d7b69..fc2f199216 100644 --- a/src/domain/evidence.test.ts +++ b/src/domain/evidence.test.ts @@ -291,6 +291,22 @@ describe("analysis evidence identity", () => { }); }); +it("rejects a subject local path containing NUL while admitting an empty path", () => { + const evidence = createEvidence(TARGET, PROVIDER, { + operation: "health", + parameters: {}, + result: true, + }); + expect(evidence.subject).not.toBeNull(); + const withPath = (path: string) => ({ + ...evidence, + subject: { ...evidence.subject, local_path: path }, + }); + expect(evidenceSchema.safeParse(withPath("/tmp/fixture")).success).toBe(true); + expect(evidenceSchema.safeParse(withPath("")).success).toBe(true); + expect(evidenceSchema.safeParse(withPath("a\u0000b")).success).toBe(false); +}); + it("binds an explicit unknown-subject reason into the semantic Evidence identity", () => { const reason = "Caller excluded the observed digest."; const observation = { operation: "health", parameters: {}, result: true }; diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 8aa6d4c654..4e6fbe372c 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { digestCanonicalValue } from "./canonicalDigest.js"; import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js"; +import { nulFreeStringSchema } from "./localPath.js"; import { analysisProfileSchema, @@ -65,7 +66,7 @@ const subjectSchema = z.object({ "entitlements", ]), architecture: z.enum(BINARY_ARCHITECTURES).nullable(), - local_path: z.string(), + local_path: nulFreeStringSchema, }); /** Source location attached to an evidence observation. */ /** Source-location schema shared by evidence-bearing persistence formats. */ diff --git a/src/domain/localPath.ts b/src/domain/localPath.ts index d3d2001896..789803a210 100644 --- a/src/domain/localPath.ts +++ b/src/domain/localPath.ts @@ -2,7 +2,9 @@ import { isAbsolute, relative, sep } from "node:path"; import { z } from "zod"; /** - * Preserve selected filesystem names while rejecting OS-invalid NUL bytes. + * Reject OS-invalid NUL bytes in a local filesystem name. This is not a path + * shape check: an empty or relative value is admitted here and narrowed by the + * callers that need a nonempty or absolute path. * * The pattern spells NUL as `\x00` rather than `\u0000`. Both accept exactly the * same strings. The hex form also avoids the short `\0` spelling rejected by @@ -10,11 +12,13 @@ import { z } from "zod"; * one advertised pattern cannot compile. See `offsetDateTimeSchema` for the * same hex-escape convention. */ -export const localPathStringSchema = z +export const nulFreeStringSchema = z .string() - .min(1) .regex(/^[^\x00]*$/u, "Local filesystem paths cannot contain NUL"); +/** A selected local filesystem path: nonempty and free of NUL bytes. */ +export const localPathStringSchema = nulFreeStringSchema.min(1); + /** Test lexical containment on the host platform; callers must resolve symlinks first. */ export const isPathWithinRoot = (root: string, path: string): boolean => { const value = relative(root, path);