diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 624bcd17d6..73b412237f 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -451,7 +451,7 @@ const prepareSnapshot = async (options: { const { path, snapshotPath } = options; if (snapshotPath === undefined || !(await fileExists(snapshotPath))) return ok({}); - const loaded = await readAnalysisSnapshot(snapshotPath); + const loaded = await readAnalysisSnapshot(snapshotPath, options.signal); if (!loaded.ok) return loaded; const target = await parseBinaryTarget(path, { signal: options.signal, diff --git a/src/application/JsonFiles.ts b/src/application/JsonFiles.ts index 1d3875570c..3bb79b39e5 100644 --- a/src/application/JsonFiles.ts +++ b/src/application/JsonFiles.ts @@ -16,7 +16,11 @@ import { AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../domain/result.js"; -import { readJsonInputFile } from "./JsonInputFile.js"; +import { + readJsonInputFile, + readJsonObjectFile, + type JsonObjectMemberReader, +} from "./JsonInputFile.js"; import { NonRegularFileReadError } from "../filesystem/RegularFile.js"; /** Request control and its owning operation for an interruptible atomic write. */ @@ -33,13 +37,47 @@ export const readJsonFile = async ( path: string, ): Promise< Result +> => + readJsonFileInput(path, (selected) => + readJsonInputFile(selected, "read_evidence_file"), + ); + +/** Read selected JSON members through the same regular-file error boundary. */ +export const readJsonObjectMembersFile = ( + path: string, + scans: readonly ReadonlyMap[], + signal?: AbortSignal, +): Promise< + Result< + void, + EvidenceFileError | AnalysisResourceConstraintError | AnalysisCancelledError + > +> => + readJsonFileInput( + path, + (selected) => + readJsonObjectFile(selected, "read_evidence_file", scans, signal), + signal, + ); + +const readJsonFileInput = async ( + path: string, + read: ( + selected: string, + ) => Promise< + Result< + Value, + | import("../domain/analysisErrorCore.js").AnalysisInputError + | AnalysisResourceConstraintError + > + >, + signal?: AbortSignal, +): Promise< + Result > => { const requestedPath = resolve(path); try { - const decoded = await readJsonInputFile( - requestedPath, - "read_evidence_file", - ); + const decoded = await read(requestedPath); if (!decoded.ok) { if (decoded.error instanceof AnalysisResourceConstraintError) return err(decoded.error); @@ -57,6 +95,7 @@ export const readJsonFile = async ( return ok(decoded.value); } catch (cause: unknown) { if (cause instanceof AnalysisResourceConstraintError) return err(cause); + if (signal?.aborted === true) throw cause; return err( new EvidenceFileError( "read", diff --git a/src/application/JsonInputFile.ts b/src/application/JsonInputFile.ts index a333f8d8f9..bceb482d2f 100644 --- a/src/application/JsonInputFile.ts +++ b/src/application/JsonInputFile.ts @@ -1,4 +1,5 @@ import { constants } from "node:buffer"; +import { createHash } from "node:crypto"; import type { FileHandle } from "node:fs/promises"; import { pipeline } from "node:stream/promises"; import { getHeapStatistics } from "node:v8"; @@ -7,6 +8,7 @@ import { asStream, none } from "stream-chain"; import fun from "stream-chain/fun.js"; import parser from "stream-json/parser.js"; import streamValues from "stream-json/streamers/stream-values.js"; +import streamArray from "stream-json/streamers/stream-array.js"; import { RegularFileChangedError } from "../filesystem/RegularFile.js"; import { withRegularFile } from "./RegularFileRead.js"; @@ -39,6 +41,196 @@ const nativeParseFits = (inputBytes: number): boolean => inputBytes * 72 + READ_CHUNK_BYTES <= getHeapStatistics().total_available_size; +/** One selected root member; repeated keys begin again with last-value semantics. */ +export interface JsonObjectMemberReader { + readonly mode: "value" | "array"; + readonly begin: () => void; + readonly accept: (value: unknown) => void | Promise; + readonly reject: (cause: unknown) => void; +} + +/** Scan selected members without retaining skipped subtrees or prior array items. */ +export const readJsonObjectFile = ( + path: string, + operation: string, + scans: readonly ReadonlyMap[], + signal?: AbortSignal, +): Promise> => + withRegularFile( + path, + async (handle, stats) => { + let previousDigest: string | undefined; + try { + const native = await readNativeJson( + handle, + stats.size, + path, + operation, + signal, + ); + if (native !== undefined) { + if (!native.ok) return native; + const value = native.value; + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) + ) + throw new TypeError("JSON document root must be an object"); + for (const members of scans) + for (const [key, member] of members) { + if (!Object.hasOwn(value, key)) continue; + signal?.throwIfAborted(); + member.begin(); + const selected: unknown = Reflect.get(value, key); + if (member.mode === "array" && !Array.isArray(selected)) { + member.reject( + new TypeError(`JSON member ${key} must be an array`), + ); + continue; + } + for (const item of member.mode === "array" && + Array.isArray(selected) + ? selected + : [selected]) { + signal?.throwIfAborted(); + try { + await member.accept(item); + } catch (cause: unknown) { + member.reject(cause); + } + } + } + return ok(undefined); + } + for (const members of scans) { + const hash = createHash("sha256"); + let rootObject = false; + let depth = 0; + let key: string | undefined; + let consume: + | ((token: parser.Token) => typeof none | Promise) + | undefined; + await pipeline( + decodedChunks(handle, stats.size, path, signal, (bytes) => { + hash.update(bytes); + }), + asStream( + fun( + parser({ + jsonStreaming: false, + streamValues: false, + packKeys: false, + packStrings: false, + packNumbers: false, + }), + packJsonScalars(operation, stats.size, path), + (token: parser.Token): typeof none | Promise => { + const opens = + token.name === "startObject" || token.name === "startArray"; + const closes = + token.name === "endObject" || token.name === "endArray"; + if (depth === 0) { + rootObject = token.name === "startObject"; + } else if ( + rootObject && + depth === 1 && + token.name === "keyValue" + ) { + key = token.value; + } else if (rootObject && depth === 1 && !closes) { + const member = + key === undefined ? undefined : members.get(key); + consume = undefined; + if (member !== undefined) { + member.begin(); + if ( + member.mode === "array" && + token.name !== "startArray" + ) { + member.reject( + new TypeError(`JSON member ${key} must be an array`), + ); + } else { + consume = memberConsumer( + member, + operation, + stats.size, + path, + ); + } + } + key = undefined; + } + const selected = consume; + if (opens) depth += 1; + if (closes) depth -= 1; + if (rootObject && depth === 1 && token.name !== "keyValue") + consume = undefined; + if (selected === undefined) return none; + return selected(token); + }, + ), + ), + signal === undefined ? {} : { signal }, + ); + if (!rootObject) + throw new TypeError("JSON document root must be an object"); + const digest = hash.digest("hex"); + if (previousDigest !== undefined && digest !== previousDigest) + throw new RegularFileChangedError(path); + previousDigest = digest; + } + return ok(undefined); + } catch (cause: unknown) { + const failure = jsonReadFailure(cause, operation, stats.size, path); + if (failure !== undefined) return err(failure); + throw cause; + } + }, + { signal }, + ); + +const memberConsumer = ( + member: JsonObjectMemberReader, + operation: string, + size: number, + path: string, +) => { + let admit = admitJsonAssembly(operation, size, path); + const streamer: parser.TokenConsumer<{ readonly value: unknown }> = + member.mode === "array" ? streamArray() : streamValues(); + const consume = fun( + (token: parser.Token) => admit(token), + streamer, + (row: unknown): typeof none | Promise => { + if (typeof row !== "object" || row === null || !("value" in row)) + throw new Error("Unexpected JSON member parser result"); + // The preceding array item has left the assembler; retain only what the + // consumer owns and use live headroom when admitting the next item. + admit = admitJsonAssembly(operation, size, path); + try { + const accepted = member.accept(row.value); + if (accepted instanceof Promise) + return accepted.then( + () => none, + (cause: unknown) => { + member.reject(cause); + return none; + }, + ); + } catch (cause: unknown) { + member.reject(cause); + } + return none; + }, + ); + return (token: parser.Token): typeof none | Promise => { + const consumed = consume(token); + return consumed instanceof Promise ? consumed.then(() => none) : none; + }; +}; + /** Parse strict UTF-8 JSON without requiring a whole-document string. */ export const readJsonInputFile = ( path: string, @@ -51,22 +243,14 @@ export const readJsonInputFile = ( let found = false; let value: unknown; try { - const prefix = nativeParseFits(stats.size) - ? await readPrefix(handle, stats.size + 1, signal) - : undefined; - if (prefix !== undefined) { - if (!prefix.complete || prefix.bytes.length !== stats.size) - throw new RegularFileChangedError(path); - const parsed = parseUtf8Json( - prefix.bytes, - operation, - path, - "json-file-input", - ); - return parsed.ok - ? ok(parsed.value) - : err(invalidJson(operation, parsed.error, parsed.cause)); - } + const native = await readNativeJson( + handle, + stats.size, + path, + operation, + signal, + ); + if (native !== undefined) return native; await pipeline( decodedChunks(handle, stats.size, path, signal), // Fuse synchronous token stages so only file chunks cross the async @@ -105,48 +289,55 @@ export const readJsonInputFile = ( ? ok(value) : err(invalidJson(operation, "JSON document is empty")); } catch (cause: unknown) { - if (cause instanceof AnalysisResourceConstraintError) return err(cause); - if ( - cause instanceof TypeError && - "code" in cause && - cause.code === "ERR_ENCODING_INVALID_ENCODED_DATA" - ) - return err( - invalidJson(operation, "JSON input is not valid UTF-8", cause), - ); - if ( - cause instanceof SyntaxError || - (cause instanceof Error && cause.message.startsWith("Parser ")) - ) - return err(invalidJson(operation, cause.message, cause)); - if ( - cause instanceof Error && - ((cause instanceof RangeError && - cause.message === "Invalid string length") || - ("code" in cause && cause.code === "ERR_STRING_TOO_LONG")) - ) - return err( - new AnalysisResourceConstraintError( - operation, - "memory", - "An individual JSON string or number token exceeds the runtime string limit", - { - input_path: path, - input_file_bytes: stats.size, - max_string_code_units: constants.MAX_STRING_LENGTH, - }, - { - cause, - remediationAction: JSON_INPUT_RESOURCE_REMEDIATION, - }, - ), - ); + const failure = jsonReadFailure(cause, operation, stats.size, path); + if (failure !== undefined) return err(failure); throw cause; } }, { signal }, ); +const jsonReadFailure = ( + cause: unknown, + operation: string, + size: number, + path: string, +): JsonInputFileFailure | undefined => { + if (cause instanceof AnalysisResourceConstraintError) return cause; + if ( + cause instanceof TypeError && + "code" in cause && + cause.code === "ERR_ENCODING_INVALID_ENCODED_DATA" + ) + return invalidJson(operation, "JSON input is not valid UTF-8", cause); + if ( + cause instanceof SyntaxError || + (cause instanceof Error && cause.message.startsWith("Parser ")) + ) + return invalidJson(operation, cause.message, cause); + if ( + cause instanceof Error && + ((cause instanceof RangeError && + cause.message === "Invalid string length") || + ("code" in cause && cause.code === "ERR_STRING_TOO_LONG")) + ) + return new AnalysisResourceConstraintError( + operation, + "memory", + "An individual JSON string or number token exceeds the runtime string limit", + { + input_path: path, + input_file_bytes: size, + max_string_code_units: constants.MAX_STRING_LENGTH, + }, + { + cause, + remediationAction: JSON_INPUT_RESOURCE_REMEDIATION, + }, + ); + return undefined; +}; + const packJsonScalars = ( operation: string, inputFileBytes: number, @@ -304,6 +495,7 @@ async function* decodedChunks( admittedBytes: number, path: string, signal: AbortSignal | undefined, + observe?: (bytes: Uint8Array) => void, ): AsyncGenerator { const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); let leading = true; @@ -322,15 +514,15 @@ async function* decodedChunks( bytes, 0, Math.min(bytes.length, admittedBytes - observedBytes + 1), - null, + observedBytes, ); signal?.throwIfAborted(); if (read.bytesRead === 0) break; observedBytes += read.bytesRead; if (observedBytes > admittedBytes) throw new RegularFileChangedError(path); - yield checked( - decoder.decode(bytes.subarray(0, read.bytesRead), { stream: true }), - ); + const chunk = bytes.subarray(0, read.bytesRead); + observe?.(chunk); + yield checked(decoder.decode(chunk, { stream: true })); } if (observedBytes !== admittedBytes) throw new RegularFileChangedError(path); yield checked(decoder.decode()); @@ -362,6 +554,28 @@ const readPrefix = async ( return { bytes, complete: false }; }; +const readNativeJson = async ( + handle: FileHandle, + size: number, + path: string, + operation: string, + signal?: AbortSignal, +): Promise | undefined> => { + if (!nativeParseFits(size)) return undefined; + const prefix = await readPrefix(handle, size + 1, signal); + if (!prefix.complete || prefix.bytes.length !== size) + throw new RegularFileChangedError(path); + const parsed = parseUtf8Json( + prefix.bytes, + operation, + path, + "json-file-input", + ); + return parsed.ok + ? ok(parsed.value) + : err(invalidJson(operation, parsed.error, parsed.cause)); +}; + const invalidJson = ( operation: string, message: string, diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 295b21636f..c6f74b23a2 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -18,6 +18,7 @@ import { snapshotMatchesTarget, snapshotTarget, parseAnalysisSnapshot, + immutableAnalysisSnapshot, type AnalysisSnapshot, type AnalysisSnapshotEntry, type AnalysisSnapshotWorkflowEntry, @@ -107,6 +108,7 @@ export class AnalysisSnapshotCache { /** Merge already-validated entries and return the new-entry count. */ stage(snapshot: AnalysisSnapshot): number { + snapshot = immutableAnalysisSnapshot(snapshot); if ( (this.#target !== undefined && JSON.stringify(this.#target) !== JSON.stringify(snapshot.target)) || @@ -123,12 +125,12 @@ export class AnalysisSnapshotCache { for (const entry of snapshot.entries) { if (!this.#entries.has(entry.query_id) && !this.#hasCapacity()) continue; if (!this.#entries.has(entry.query_id)) imported += 1; - this.#entries.set(entry.query_id, structuredClone(entry)); + this.#entries.set(entry.query_id, entry); } for (const entry of snapshot.workflow_entries) { if (!this.#workflowEntries.has(entry.query_id) && !this.#hasCapacity()) continue; - this.#workflowEntries.set(entry.query_id, structuredClone(entry)); + this.#workflowEntries.set(entry.query_id, entry); } return imported; } @@ -186,7 +188,7 @@ export class AnalysisSnapshotCache { ): Result { let validated: AnalysisSnapshot; try { - validated = parseAnalysisSnapshot(snapshot); + validated = immutableAnalysisSnapshot(snapshot); } catch (cause: unknown) { return err( new EvidenceIntegrityError("Analysis snapshot validation failed", { diff --git a/src/application/binary/AnalysisSnapshotFiles.ts b/src/application/binary/AnalysisSnapshotFiles.ts index 87d910b5d2..064aae7056 100644 --- a/src/application/binary/AnalysisSnapshotFiles.ts +++ b/src/application/binary/AnalysisSnapshotFiles.ts @@ -1,5 +1,9 @@ +import { setImmediate } from "node:timers/promises"; + import { - parseAnalysisSnapshot, + prepareSnapshotEntry, + parsePreparedAnalysisSnapshot, + type PreparedSnapshotEntry, serializeAnalysisSnapshot, type AnalysisSnapshot, } from "../../domain/analysisSnapshot.js"; @@ -9,27 +13,106 @@ import { } from "../../domain/evidenceErrors.js"; import { err, ok, type Result } from "../../domain/result.js"; import type { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; -import { readJsonFile, writeTextFile } from "../JsonFiles.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { readJsonObjectMembersFile, writeTextFile } from "../JsonFiles.js"; +import type { JsonObjectMemberReader } from "../JsonInputFile.js"; type SnapshotFailure = EvidenceFileError | EvidenceIntegrityError; /** Read and validate an analysis snapshot from the caller's path. */ export const readAnalysisSnapshot = async ( path: string, + signal?: AbortSignal, ): Promise< - Result + Result< + AnalysisSnapshot, + SnapshotFailure | AnalysisResourceConstraintError | AnalysisCancelledError + > > => { - const loaded = await readJsonFile(path); - if (!loaded.ok) return loaded; + const entries = snapshotEntryReader("provider"); + const workflows = snapshotEntryReader("workflow"); + const header: Record = {}; + const metadata = new Map( + ["target", "binding", "evidence_bundle"].map((name) => [ + name, + { + mode: "value", + begin: () => { + delete header[name]; + }, + accept: (value) => { + header[name] = value; + }, + reject: (cause) => { + throw cause; + }, + }, + ]), + ); try { - return ok(parseAnalysisSnapshot(loaded.value)); + const loaded = await readJsonObjectMembersFile( + path, + [ + new Map([ + ["entries", entries.reader], + ["workflow_entries", workflows.reader], + ]), + metadata, + ], + signal, + ); + if (!loaded.ok) return loaded; + signal?.throwIfAborted(); + const snapshot = parsePreparedAnalysisSnapshot( + header, + entries.finish(), + workflows.finish(), + ); + // Deliver queued cancellation before handing a complete cache to the session. + await setImmediate(); + signal?.throwIfAborted(); + return ok(snapshot); } catch (cause: unknown) { + if (signal?.aborted === true) + return err(new AnalysisCancelledError("read_evidence_file", { cause })); return err( new EvidenceIntegrityError(snapshotValidationMessage(cause), { cause }), ); } }; +const snapshotEntryReader = (kind: "provider" | "workflow") => { + let seen = false; + let entries: PreparedSnapshotEntry[] = []; + let failure: { readonly cause: unknown } | undefined; + const reader: JsonObjectMemberReader = { + mode: "array", + begin: () => { + seen = true; + entries = []; + failure = undefined; + }, + accept: (value) => { + if (failure === undefined) + entries.push(prepareSnapshotEntry(value, kind)); + }, + reject: (cause) => { + failure ??= { cause }; + }, + }; + return { + reader, + finish: (): PreparedSnapshotEntry[] => { + if (!seen) + throw new TypeError( + `Analysis snapshot ${kind === "provider" ? "entries" : "workflow_entries"} is missing`, + ); + if (failure !== undefined) throw failure.cause; + return entries; + }, + }; +}; + /** Atomically write a deterministic analysis snapshot to the caller's path. */ export const writeAnalysisSnapshot = async ( snapshot: AnalysisSnapshot, diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index bb8043516f..98960ba55c 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -3,7 +3,10 @@ import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; import { z } from "zod"; -import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; +import { + compareUnicodeCodePoints, + compositeKey, +} from "./unicodeCodePointOrder.js"; import { analysisProfileSchema, @@ -19,6 +22,7 @@ import { evidenceBundleForTarget, evidenceBundleSchema, parseEvidenceBundle, + parseImmutableEvidenceBundle, } from "./evidenceBundle.js"; import { evidenceLocationSchema, @@ -33,6 +37,8 @@ import { } from "./jsonValue.js"; import { digestSchema } from "./../domain/digests.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; +import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js"; +import { freezeJsonSnapshot } from "./immutableJson.js"; const architectureSchema = z.enum(BINARY_ARCHITECTURES); const formatSchema = z.enum([ @@ -136,6 +142,146 @@ export type AnalysisSnapshotWorkflowEntry = z.infer; export type AnalysisSnapshotTarget = z.infer; export type AnalysisSnapshotBinding = z.infer; +// These handles only represent entries parsed in this realm. File readers can +// release their payloads while retaining an authenticated comparison operand. +const preparedEntryBrand = Symbol("prepared-snapshot-entry"); +export interface PreparedSnapshotEntry { + readonly [preparedEntryBrand]: true; +} +type EntryMetadata = Omit & { + readonly execution: Omit< + AnalysisSnapshotEntry["execution"], + "result" | "raw_result" + >; +}; +type WorkflowMetadata = Omit & { + readonly execution: Omit< + AnalysisSnapshotWorkflowEntry["execution"], + "result" | "raw_result" + >; +}; +type PreparedEntry = + | { + readonly kind: "provider"; + readonly entry: EntryMetadata; + readonly digest: string; + } + | { + readonly kind: "workflow"; + readonly entry: WorkflowMetadata; + readonly digest: string; + }; +const preparedEntries = new WeakMap(); + +/** Capture a cache entry without retaining a second copy of its full payload. */ +export const prepareSnapshotEntry = ( + input: unknown, + kind: "provider" | "workflow", +): PreparedSnapshotEntry => { + const handle: PreparedSnapshotEntry = Object.freeze({ + [preparedEntryBrand]: true as const, + }); + if (kind === "provider") { + const entry = entrySchema.parse(input); + const { result, raw_result, ...execution } = entry.execution; + preparedEntries.set(handle, { + kind, + entry: { ...entry, execution }, + digest: snapshotContentDigest(result, raw_result, execution), + }); + } else { + const entry = workflowEntrySchema.parse(input); + const { result, raw_result, ...execution } = entry.execution; + preparedEntries.set(handle, { + kind, + entry: { ...entry, execution }, + digest: snapshotContentDigest(result, raw_result, execution), + }); + } + return handle; +}; + +const snapshotFileHeaderSchema = z.object({ + target: targetSchema, + binding: bindingSchema, + evidence_bundle: z.unknown(), +}); + +/** Reconstruct file entries only from fully authenticated matching Evidence. */ +export const parsePreparedAnalysisSnapshot = ( + input: unknown, + entries: readonly PreparedSnapshotEntry[], + workflowEntries: readonly PreparedSnapshotEntry[], +): AnalysisSnapshot => { + const header = snapshotFileHeaderSchema.parse(input); + const bundle = parseImmutableEvidenceBundle(header.evidence_bundle); + const providerRows = entries.map((handle) => { + const row = preparedEntries.get(handle); + if (row?.kind !== "provider") + throw new TypeError( + "Analysis snapshot entry was not parsed in this realm", + ); + return row; + }); + const workflowRows = workflowEntries.map((handle) => { + const row = preparedEntries.get(handle); + if (row?.kind !== "workflow") + throw new TypeError( + "Analysis snapshot workflow entry was not parsed in this realm", + ); + return row; + }); + const restored = sealSnapshot( + restoreSnapshot(header, bundle, providerRows, workflowRows), + ); + for (const handle of [...entries, ...workflowEntries]) + preparedEntries.delete(handle); + return restored; +}; + +const contentDigest = (value: JsonValue): string => { + const steps = canonicalJsonDigestSteps(value); + let step = steps.next(); + while (!step.done) step = steps.next(); + return step.value; +}; + +const snapshotContentDigest = ( + result: JsonValue, + rawResult: JsonValue | null, + execution: Pick< + AnalysisSnapshotEntry["execution"], + "limitations" | "locations" | "subject" + >, +): string => { + const metadata = jsonObjectSchema.parse({ + limitations: execution.limitations, + locations: execution.locations, + subject: + execution.subject === null + ? null + : { + local_path: execution.subject.path, + sha256: execution.subject.sha256, + format: execution.subject.format, + architecture: execution.subject.architecture, + }, + }); + return contentDigest({ + ...metadata, + normalized_result: result, + raw_result: rawResult, + }); +}; + +const evidenceContentDigest = (evidence: Evidence): string => { + return contentDigest({ + ...jsonObjectSchema.parse(evidenceContentMetadata(evidence)), + normalized_result: evidence.normalized_result, + raw_result: evidence.raw_result, + }); +}; + interface SnapshotExecution { readonly result: JsonValue; readonly rawResult: JsonValue | null; @@ -305,22 +451,10 @@ const evidenceMatchesEntry = ( snapshot: Pick, ): boolean => isCorrespondingEvidence(evidence, snapshot) && - canonicalJson({ - query: evidenceQueryKey(evidence), - normalized_result: evidence.normalized_result, - raw_result: evidence.raw_result, - limitations: evidence.limitations, - locations: evidence.locations, - subject: - evidence.subject === null - ? null - : { - local_path: evidence.subject.local_path, - sha256: evidence.subject.digest.sha256, - format: evidence.subject.format, - architecture: evidence.subject.architecture, - }, - }) === entryEvidenceKey(entry, snapshot.binding); + compositeKey([ + evidenceQueryKey(evidence), + evidenceContentDigest(evidence), + ]) === entryEvidenceKey(entry, snapshot.binding); /** Compute the stable lookup key for one provider/profile-specific query. */ export const analysisQueryId = ( @@ -373,94 +507,189 @@ export const createAnalysisSnapshotEntry = (input: { }; }; +const validatedSnapshots = new WeakMap(); + /** Parse a provider/profile-exact snapshot and verify its entries and evidence. */ export const parseAnalysisSnapshot = (input: unknown): AnalysisSnapshot => { + if (typeof input === "object" && input !== null) { + const owned = validatedSnapshots.get(input); + if (owned !== undefined) return owned; + } const parsed = analysisSnapshotSchema.parse(input); - parseEvidenceBundle(parsed.evidence_bundle); - const scopedEvidence = evidenceBundleForTarget( - parsed.evidence_bundle, - parsed.target.sha256, + restoreSnapshot( + parsed, + parseEvidenceBundle(parsed.evidence_bundle), + parsed.entries.map((entry) => ({ + kind: "provider", + entry, + digest: snapshotContentDigest( + entry.execution.result, + entry.execution.raw_result, + entry.execution, + ), + })), + parsed.workflow_entries.map((entry) => ({ + kind: "workflow", + entry, + digest: snapshotContentDigest( + entry.execution.result, + entry.execution.raw_result, + entry.execution, + ), + })), ); - // The validated canonical bundle is filtered, never extended. Equal counts - // establish that no record or unknown was removed without serializing results. + return parsed; +}; + +/** Own an authenticated immutable cache while preserving mutable caller exports. */ +export const immutableAnalysisSnapshot = (input: unknown): AnalysisSnapshot => + sealSnapshot(parseAnalysisSnapshot(input)); + +const sealSnapshot = (snapshot: AnalysisSnapshot): AnalysisSnapshot => { + for (const entry of [...snapshot.entries, ...snapshot.workflow_entries]) { + freezeJsonSnapshot(entry.execution.result); + freezeJsonSnapshot(entry.execution.raw_result); + } + freezeJsonSnapshot(snapshot); + validatedSnapshots.set(snapshot, snapshot); + return snapshot; +}; + +const restoreSnapshot = ( + header: Pick, + bundle: AnalysisSnapshot["evidence_bundle"], + providerRows: readonly Extract[], + workflowRows: readonly Extract[], +): AnalysisSnapshot => { + const scopedEvidence = evidenceBundleForTarget(bundle, header.target.sha256); if ( - scopedEvidence.records.length !== parsed.evidence_bundle.records.length || - scopedEvidence.unknowns.length !== parsed.evidence_bundle.unknowns.length + scopedEvidence.records.length !== bundle.records.length || + scopedEvidence.unknowns.length !== bundle.unknowns.length ) throw new TypeError( "Analysis snapshot evidence contains records for another target", ); - if (!hasCanonicalQueryOrder(parsed.entries)) + if (!hasCanonicalQueryOrder(providerRows.map(({ entry }) => entry))) throw new TypeError("Analysis snapshot entries are not canonical"); - if (!hasCanonicalQueryOrder(parsed.workflow_entries)) + if (!hasCanonicalQueryOrder(workflowRows.map(({ entry }) => entry))) throw new TypeError("Analysis snapshot workflow entries are not canonical"); const ids = new Set(); - const index = createEvidenceIndex(parsed); - const workflowIndex = createWorkflowEvidenceIndex(parsed); - for (const entry of parsed.entries) { + const corresponding = new Set(); + const matching = new Map(); + for (const evidence of bundle.records) { if ( - entry.execution.provider.id !== parsed.binding.provider.id || - entry.execution.provider.name !== parsed.binding.provider.name || - entry.execution.provider.version !== parsed.binding.provider.version + evidence.predicate_type !== "rea.analysis" || + evidence.authority !== "shipped-artifact" || + evidence.subject?.digest.sha256 !== header.target.sha256 || + evidence.analysis_profile === null || + (evidence.confidence !== "observed" && evidence.confidence !== "derived") ) - throw new TypeError("Analysis snapshot entry provider does not match"); + continue; + if ( + evidence.confidence === "observed" && + !isCorrespondingEvidence(evidence, header) + ) + continue; + const query = compositeKey([ + evidence.confidence, + evidenceQueryKey(evidence), + ]); + corresponding.add(query); + matching.set( + compositeKey([query, evidenceContentDigest(evidence)]), + evidence, + ); + } + const find = ( + query: string, + digest: string, + label: string, + operation: string, + ): Evidence => { + const evidence = matching.get(compositeKey([query, digest])); + if (evidence !== undefined) return evidence; + throw new TypeError( + corresponding.has(query) + ? `Analysis snapshot ${label} ${operation} differs from its Evidence record` + : `Analysis snapshot ${label} ${operation} has no profile-bound Evidence record`, + ); + }; + const checkId = ( + entry: EntryMetadata | WorkflowMetadata, + workflow: boolean, + ): void => { const expected = analysisQueryId( - parsed.target, - parsed.binding, + header.target, + header.binding, entry.operation, entry.parameters, ); if (entry.query_id !== expected) - throw new TypeError("Analysis snapshot query identifier does not match"); + throw new TypeError( + workflow + ? "Analysis snapshot workflow query identifier does not match" + : "Analysis snapshot query identifier does not match", + ); if (ids.has(entry.query_id)) throw new TypeError("Analysis snapshot contains duplicate queries"); ids.add(entry.query_id); - if (!entryHasMatchingEvidence(entry, parsed, index)) { - if (entryHasCorrespondingEvidence(entry, parsed, index)) - throw new TypeError( - `Analysis snapshot entry ${entry.operation} differs from its Evidence record`, - ); - throw new TypeError( - `Analysis snapshot entry ${entry.operation} has no profile-bound Evidence record`, - ); - } - } - for (const entry of parsed.workflow_entries) { + }; + const entries = providerRows.map(({ entry, digest }) => { if ( - entry.execution.provider.id !== - entry.execution.analysis_profile.provider.id || - entry.execution.provider.name !== - entry.execution.analysis_profile.provider.name || - entry.execution.provider.version !== - entry.execution.analysis_profile.provider.version + entry.execution.provider.id !== header.binding.provider.id || + entry.execution.provider.name !== header.binding.provider.name || + entry.execution.provider.version !== header.binding.provider.version + ) + throw new TypeError("Analysis snapshot entry provider does not match"); + checkId(entry, false); + const evidence = find( + compositeKey(["observed", entryQueryKey(entry, header.binding)]), + digest, + "entry", + entry.operation, + ); + return { + ...entry, + execution: { + ...entry.execution, + result: evidence.normalized_result, + raw_result: evidence.raw_result, + }, + }; + }); + const workflow_entries = workflowRows.map(({ entry, digest }) => { + const provider = entry.execution.analysis_profile.provider; + if ( + entry.execution.provider.id !== provider.id || + entry.execution.provider.name !== provider.name || + entry.execution.provider.version !== provider.version ) throw new TypeError( "Analysis snapshot workflow entry provider does not match its profile", ); - const expected = analysisQueryId( - parsed.target, - parsed.binding, + checkId(entry, true); + const evidence = find( + compositeKey(["derived", workflowEntryQueryKey(entry)]), + digest, + "workflow entry", entry.operation, - entry.parameters, ); - if (entry.query_id !== expected) - throw new TypeError( - "Analysis snapshot workflow query identifier does not match", - ); - if (ids.has(entry.query_id)) - throw new TypeError("Analysis snapshot contains duplicate queries"); - ids.add(entry.query_id); - if (!workflowEntryHasMatchingEvidence(entry, workflowIndex)) { - if (workflowEntryHasCorrespondingEvidence(entry, workflowIndex)) - throw new TypeError( - `Analysis snapshot workflow entry ${entry.operation} differs from its Evidence record`, - ); - throw new TypeError( - `Analysis snapshot workflow entry ${entry.operation} has no profile-bound Evidence record`, - ); - } - } - return parsed; + return { + ...entry, + execution: { + ...entry.execution, + result: evidence.normalized_result, + raw_result: evidence.raw_result, + }, + }; + }); + return { + target: header.target, + binding: header.binding, + entries, + workflow_entries, + evidence_bundle: bundle, + }; }; const hasCanonicalQueryOrder = ( @@ -478,56 +707,6 @@ const hasCanonicalQueryOrder = ( return true; }; -/** Check that a cached provider execution is represented by bundled Evidence. */ -interface EvidenceIndex { - readonly matching: ReadonlySet; - readonly corresponding: ReadonlySet; -} - -const createEvidenceIndex = ( - snapshot: Pick, -): EvidenceIndex => { - const matching = new Set(); - const corresponding = new Set(); - for (const evidence of snapshot.evidence_bundle.records) { - if (!isCorrespondingEvidence(evidence, snapshot)) continue; - const query = evidenceQueryKey(evidence); - corresponding.add(query); - matching.add( - canonicalJson({ - query, - normalized_result: evidence.normalized_result, - raw_result: evidence.raw_result, - limitations: evidence.limitations, - locations: evidence.locations, - subject: - evidence.subject === null - ? null - : { - local_path: evidence.subject.local_path, - sha256: evidence.subject.digest.sha256, - format: evidence.subject.format, - architecture: evidence.subject.architecture, - }, - }), - ); - } - const result = { matching, corresponding }; - return result; -}; - -const entryHasMatchingEvidence = ( - entry: AnalysisSnapshotEntry, - snapshot: Pick, - index: EvidenceIndex, -): boolean => index.matching.has(entryEvidenceKey(entry, snapshot.binding)); - -const entryHasCorrespondingEvidence = ( - entry: AnalysisSnapshotEntry, - snapshot: Pick, - index: EvidenceIndex, -): boolean => index.corresponding.has(entryQueryKey(entry, snapshot.binding)); - const isCorrespondingEvidence = ( evidence: Evidence, snapshot: Pick, @@ -543,7 +722,7 @@ const isCorrespondingEvidence = ( ); const evidenceQueryKey = (evidence: Evidence): string => - canonicalJson({ + contentDigest({ operation: evidence.operation, parameters: evidence.parameters, provider: evidence.provider, @@ -551,10 +730,12 @@ const evidenceQueryKey = (evidence: Evidence): string => }); const entryQueryKey = ( - entry: AnalysisSnapshotEntry, + entry: Pick & { + readonly execution: Pick; + }, binding: AnalysisSnapshotBinding, ): string => - canonicalJson({ + contentDigest({ operation: entry.operation, parameters: entry.parameters, provider: entry.execution.provider, @@ -565,25 +746,24 @@ const entryEvidenceKey = ( entry: AnalysisSnapshotEntry, binding: AnalysisSnapshotBinding, ): string => - canonicalJson({ - query: entryQueryKey(entry, binding), - normalized_result: entry.execution.result, - raw_result: entry.execution.raw_result, - limitations: entry.execution.limitations, - locations: entry.execution.locations, - subject: - entry.execution.subject === null - ? null - : { - local_path: entry.execution.subject.path, - sha256: entry.execution.subject.sha256, - format: entry.execution.subject.format, - architecture: entry.execution.subject.architecture, - }, - }); + compositeKey([ + entryQueryKey(entry, binding), + snapshotContentDigest( + entry.execution.result, + entry.execution.raw_result, + entry.execution, + ), + ]); -const workflowEntryQueryKey = (entry: AnalysisSnapshotWorkflowEntry): string => - canonicalJson({ +const workflowEntryQueryKey = ( + entry: Pick & { + readonly execution: Pick< + AnalysisSnapshotWorkflowEntry["execution"], + "provider" | "analysis_profile" + >; + }, +): string => + contentDigest({ operation: entry.operation, parameters: entry.parameters, provider: entry.execution.provider, @@ -593,22 +773,14 @@ const workflowEntryQueryKey = (entry: AnalysisSnapshotWorkflowEntry): string => const workflowEntryEvidenceKey = ( entry: AnalysisSnapshotWorkflowEntry, ): string => - canonicalJson({ - query: workflowEntryQueryKey(entry), - normalized_result: entry.execution.result, - raw_result: entry.execution.raw_result, - limitations: entry.execution.limitations, - locations: entry.execution.locations, - subject: - entry.execution.subject === null - ? null - : { - local_path: entry.execution.subject.path, - sha256: entry.execution.subject.sha256, - format: entry.execution.subject.format, - architecture: entry.execution.subject.architecture, - }, - }); + compositeKey([ + workflowEntryQueryKey(entry), + snapshotContentDigest( + entry.execution.result, + entry.execution.raw_result, + entry.execution, + ), + ]); const isWorkflowEvidence = ( evidence: Evidence, @@ -628,49 +800,18 @@ const isWorkflowEvidence = ( evidence.provider.name === entry.execution.provider.name && evidence.provider.version === entry.execution.provider.version; -const createWorkflowEvidenceIndex = ( - snapshot: Pick, -): EvidenceIndex => { - const matching = new Set(); - const corresponding = new Set(); - for (const evidence of snapshot.evidence_bundle.records) { - if ( - evidence.predicate_type !== "rea.analysis" || - evidence.confidence !== "derived" || - evidence.authority !== "shipped-artifact" || - evidence.subject?.digest.sha256 !== snapshot.target.sha256 || - evidence.analysis_profile === null - ) - continue; - corresponding.add(evidenceQueryKey(evidence)); - matching.add(canonicalJson(evidenceContentProjection(evidence))); - } - return { matching, corresponding }; -}; - -const workflowEntryHasMatchingEvidence = ( - entry: AnalysisSnapshotWorkflowEntry, - index: EvidenceIndex, -): boolean => index.matching.has(workflowEntryEvidenceKey(entry)); - -const workflowEntryHasCorrespondingEvidence = ( - entry: AnalysisSnapshotWorkflowEntry, - index: EvidenceIndex, -): boolean => index.corresponding.has(workflowEntryQueryKey(entry)); - const evidenceMatchesWorkflowEntry = ( evidence: Evidence, entry: AnalysisSnapshotWorkflowEntry, snapshot: Pick, ): boolean => isWorkflowEvidence(evidence, snapshot, entry) && - canonicalJson(evidenceContentProjection(evidence)) === - workflowEntryEvidenceKey(entry); + compositeKey([ + evidenceQueryKey(evidence), + evidenceContentDigest(evidence), + ]) === workflowEntryEvidenceKey(entry); -const evidenceContentProjection = (evidence: Evidence) => ({ - query: evidenceQueryKey(evidence), - normalized_result: evidence.normalized_result, - raw_result: evidence.raw_result, +const evidenceContentMetadata = (evidence: Evidence) => ({ limitations: evidence.limitations, locations: evidence.locations, subject: diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index 4deceb0c47..5e94620c71 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -234,16 +234,32 @@ export const describeValidationFailure = (cause: unknown): string => { /** Parse records, verify semantic IDs, and reject inconsistent manifests. */ export const parseEvidenceBundle = (input: unknown): EvidenceBundle => { + return parseBundle(input, false); +}; + +/** Authenticate and seal file-owned payloads once for downstream zero-copy reuse. */ +export const parseImmutableEvidenceBundle = ( + input: unknown, +): EvidenceBundle => { + return parseBundle(input, true); +}; + +const parseBundle = (input: unknown, seal: boolean): EvidenceBundle => { if (typeof input === "object" && input !== null) { const owned = immutableBundles.get(input); if (owned !== undefined) return owned; } - return authenticateParsedEvidenceBundle(evidenceBundleSchema.parse(input)); + return authenticateBundle(evidenceBundleSchema.parse(input), seal); }; /** Authenticate evidence and bundle invariants after the bundle shape is parsed. */ export const authenticateParsedEvidenceBundle = ( parsed: EvidenceBundle, +): EvidenceBundle => authenticateBundle(parsed, false); + +const authenticateBundle = ( + parsed: EvidenceBundle, + seal: boolean, ): EvidenceBundle => { const recordIds = parsed.records.map(({ evidence_id: id }) => id); if (new Set(recordIds).size !== recordIds.length) @@ -252,7 +268,12 @@ export const authenticateParsedEvidenceBundle = ( const canonical = createEvidenceBundle( parsed.records.map((record, index) => { try { - return parseEvidence(record); + if (!seal) return parseEvidence(record); + // Schema parsing gave this boundary exclusive ownership. Register only + // validated payloads before authenticating the complete record envelope. + record.normalized_result = freezeJsonSnapshot(record.normalized_result); + record.raw_result = freezeJsonSnapshot(record.raw_result); + return immutableEvidence(record); } catch (cause: unknown) { if (cause instanceof z.ZodError) throw new z.ZodError( @@ -268,6 +289,10 @@ export const authenticateParsedEvidenceBundle = ( ); if (JSON.stringify(parsed) !== JSON.stringify(canonical)) throw new TypeError("Evidence bundle manifests are not canonical"); + if (seal) { + freezeJsonSnapshot(canonical); + immutableBundles.set(canonical, canonical); + } return canonical; }; diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index b716d96f54..7b7e5ebb53 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -191,7 +191,10 @@ const registerOpenLifecycleTool = ({ async (input, context) => { let snapshot: AnalysisSnapshot | undefined; if (input.snapshot_path !== undefined) { - const loaded = await readAnalysisSnapshot(input.snapshot_path); + const loaded = await readAnalysisSnapshot( + input.snapshot_path, + context.mcpReq.signal, + ); if (!loaded.ok) return server.delivery.toCallToolResult(loaded, openContract); snapshot = loaded.value; diff --git a/tests/boundary/filesystem/analysisSnapshot.test.ts b/tests/boundary/filesystem/analysisSnapshot.test.ts index 90f365a510..84cd14e035 100644 --- a/tests/boundary/filesystem/analysisSnapshot.test.ts +++ b/tests/boundary/filesystem/analysisSnapshot.test.ts @@ -13,6 +13,8 @@ import { import { createAnalysisSnapshotEntry, parseAnalysisSnapshot, + parsePreparedAnalysisSnapshot, + prepareSnapshotEntry, snapshotBinding, snapshotTarget, type AnalysisSnapshot, @@ -24,21 +26,23 @@ import { } from "../../../src/domain/analysisSnapshot.fixture.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { readJsonObjectFile } from "../../../src/application/JsonInputFile.js"; +import { RegularFileChangedError } from "../../../src/filesystem/RegularFile.js"; -describe("analysis snapshots: persistence", () => { - it("writes canonical private v2 JSON and rejects changed query identity", async () => { - const directory = await createTestTempDirectory("rea-snapshot-"); - const path = join(directory, "analysis.json"); - const target = snapshotTarget(TARGET); - const binding = snapshotBinding(PROFILE); - const entry = createAnalysisSnapshotEntry({ - target, - binding, - operation: "address_name", - parameters: { address: "0x1000" }, - execution: createAnalysisExecution("main", PROVIDER, { +const snapshotFixture = (): AnalysisSnapshot => { + const target = snapshotTarget(TARGET); + const binding = snapshotBinding(PROFILE); + const entry = createAnalysisSnapshotEntry({ + target, + binding, + operation: "address_name", + parameters: { address: "0x1000" }, + execution: createAnalysisExecution( + { name: "main", "😀": { "\uE000": "unicode" } }, + PROVIDER, + { analysisProfile: PROFILE, - rawResult: "main", + rawResult: { name: "main", "😀": { "\uE000": "unicode" } }, subject: { path: TARGET.path, sha256: TARGET.sha256, @@ -47,22 +51,31 @@ describe("analysis snapshots: persistence", () => { ? {} : { architecture: TARGET.architecture }), }, - }), - }); - const evidence = createEvidence(TARGET, PROVIDER, { - operation: "address_name", - parameters: { address: "0x1000" }, - result: "main", - rawResult: "main", - analysisProfile: PROFILE, - }); - const snapshot: AnalysisSnapshot = { - target, - binding, - entries: [entry], - workflow_entries: [], - evidence_bundle: createEvidenceBundle([evidence]), - }; + }, + ), + }); + const evidence = createEvidence(TARGET, PROVIDER, { + operation: "address_name", + parameters: { address: "0x1000" }, + result: { name: "main", "😀": { "\uE000": "unicode" } }, + rawResult: { name: "main", "😀": { "\uE000": "unicode" } }, + analysisProfile: PROFILE, + }); + const snapshot: AnalysisSnapshot = { + target, + binding, + entries: [entry], + workflow_entries: [], + evidence_bundle: createEvidenceBundle([evidence]), + }; + return snapshot; +}; + +describe("analysis snapshots: persistence", () => { + it("writes canonical private v2 JSON and rejects changed query identity", async () => { + const directory = await createTestTempDirectory("rea-snapshot-"); + const path = join(directory, "analysis.json"); + const snapshot = snapshotFixture(); const written = await writeAnalysisSnapshot(snapshot, path, false); expect(written.ok).toBe(true); expect((await readFile(path, "utf8")).endsWith("\n")).toBe(true); @@ -94,3 +107,178 @@ describe("analysis snapshots: persistence", () => { expect(() => parseAnalysisSnapshot(altered)).toThrow(/identifier/u); }); }); + +describe("analysis snapshot member decoding", () => { + it("restores reordered legacy members with last-key semantics and authenticated shared payloads", async () => { + const directory = await createTestTempDirectory("rea-snapshot-members-"); + const path = join(directory, "snapshot.json"); + const snapshot = snapshotFixture(); + const entry = snapshot.entries[0]; + if (entry === undefined) throw new Error("Missing fixture entry"); + const parts = [ + `"evidence_bundle":${JSON.stringify(snapshot.evidence_bundle)}`, + '"entries":[{"bad":true}]', + '"workflow_entries":null', + `"binding":${JSON.stringify(snapshot.binding)}`, + `"entries":${JSON.stringify(snapshot.entries)}`, + `"target":${JSON.stringify(snapshot.target)}`, + '"workflow_entries":[]', + '"ignored":{"entries":["nested"]}', + ]; + await writeFile(path, `${" ".repeat(9 * 1024 * 1024)}{${parts.join(",")}}`); + const loaded = await readAnalysisSnapshot(path); + if (!loaded.ok) throw loaded.error; + expect(loaded.value).toEqual(snapshot); + const restored = loaded.value.entries[0]; + const evidence = loaded.value.evidence_bundle.records[0]; + if (restored === undefined || evidence === undefined) + throw new Error("Missing restored content"); + expect(restored.execution.result).toBe(evidence.normalized_result); + expect(restored.execution.raw_result).toBe(evidence.raw_result); + expect(Object.isFrozen(restored.execution)).toBe(true); + expect(parseAnalysisSnapshot(loaded.value)).toBe(loaded.value); + + await writeFile( + path, + `${" ".repeat(9 * 1024 * 1024)}{${parts.join(",")},"entries":null}`, + ); + const rejected = await readAnalysisSnapshot(path); + expect(rejected).toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); + }); + + it.each(["result", "raw_result", "limitations", "provider", "query_id"])( + "rejects cache %s changes even when bundled Evidence is intact", + async (field) => { + const directory = await createTestTempDirectory("rea-snapshot-tamper-"); + const path = join(directory, "snapshot.json"); + const snapshot = snapshotFixture(); + const entry = snapshot.entries[0]; + if (entry === undefined) throw new Error("Missing fixture entry"); + if (field === "query_id") entry.query_id = `query_${"f".repeat(64)}`; + else + Reflect.set( + entry.execution, + field, + field === "limitations" + ? ["changed"] + : field === "provider" + ? { ...PROVIDER, version: "changed" } + : "changed", + ); + await writeFile(path, JSON.stringify(snapshot)); + const loaded = await readAnalysisSnapshot(path); + expect(loaded).toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); + if (!loaded.ok) + expect(loaded.error.message).toMatch( + field === "query_id" + ? /identifier/u + : field === "provider" + ? /provider/u + : /differs from/u, + ); + }, + ); + + it("validates skipped JSON syntax and UTF-8 and does not turn missing arrays into empty caches", async () => { + const directory = await createTestTempDirectory("rea-snapshot-invalid-"); + const path = join(directory, "snapshot.json"); + const snapshot = snapshotFixture(); + const encoded = JSON.stringify(snapshot); + for (const bytes of [ + Buffer.from(`${encoded.slice(0, -1)},"ignored":[1,]}`), + Buffer.concat([ + Buffer.from(`${encoded.slice(0, -1)},"ignored":"`), + Buffer.from([0xff]), + Buffer.from('"}'), + ]), + Buffer.from(`\uFEFF${encoded}`), + ]) { + await writeFile(path, bytes); + expect(await readAnalysisSnapshot(path)).toMatchObject({ + ok: false, + error: { _tag: "EvidenceFileError", reason: "invalid-json" }, + }); + } + const { workflow_entries: _workflows, ...missing } = snapshot; + await writeFile(path, JSON.stringify(missing)); + expect(await readAnalysisSnapshot(path)).toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); + }); + + it("returns cancellation and permits the next complete read", async () => { + const directory = await createTestTempDirectory("rea-snapshot-cancel-"); + const path = join(directory, "snapshot.json"); + const snapshot = snapshotFixture(); + await writeFile( + path, + `${" ".repeat(9 * 1024 * 1024)}${JSON.stringify(snapshot)}`, + ); + const controller = new AbortController(); + const pending = readAnalysisSnapshot(path, controller.signal); + const timer = setTimeout(() => controller.abort(), 1); + try { + expect(await pending).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + } finally { + clearTimeout(timer); + } + const loaded = await readAnalysisSnapshot(path); + if (!loaded.ok) throw loaded.error; + expect(loaded.value).toEqual(snapshot); + }); + + it("rejects changed bytes between scans on the same descriptor", async () => { + const directory = await createTestTempDirectory("rea-snapshot-changed-"); + const path = join(directory, "input.json"); + await writeFile(path, `${" ".repeat(9 * 1024 * 1024)}{"value":1}`); + const reader = new Map([ + [ + "value", + { + mode: "value" as const, + begin: () => undefined, + accept: async () => { + await writeFile(path, `${" ".repeat(9 * 1024 * 1024)}{"value":2}`); + }, + reject: (cause: unknown) => { + throw cause; + }, + }, + ], + ]); + await expect( + readJsonObjectFile(path, "test-input", [reader, new Map()]), + ).rejects.toBeInstanceOf(RegularFileChangedError); + expect((await readFile(path, "utf8")).trim()).toBe('{"value":2}'); + }); +}); + +it("rejects copied preparation handles and prevents mutable caller values from changing authenticated cache content", () => { + const snapshot = snapshotFixture(); + const entry = snapshot.entries[0]; + if (entry === undefined) throw new Error("Missing fixture entry"); + const prepared = prepareSnapshotEntry(entry, "provider"); + const forged = { ...prepared }; + expect(() => parsePreparedAnalysisSnapshot(snapshot, [forged], [])).toThrow( + /not parsed in this realm/u, + ); + const restored = parsePreparedAnalysisSnapshot(snapshot, [prepared], []); + Reflect.set(entry.execution, "result", "changed after preparation"); + expect(restored.entries[0]?.execution.result).toEqual({ + name: "main", + "😀": { "\uE000": "unicode" }, + }); + expect(() => parsePreparedAnalysisSnapshot(snapshot, [prepared], [])).toThrow( + /not parsed in this realm/u, + ); +});