diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts index cae0295898..98016f124e 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts @@ -337,6 +337,7 @@ const inspectAssetCatalogEntry = async (input: { throw new ArtifactReaderFailure( "integrity", `Asset catalog changed before inspection: ${entry.path}`, + { integrityCheck: "member-identity" }, ); let output: string; try { @@ -353,6 +354,7 @@ const inspectAssetCatalogEntry = async (input: { throw new ArtifactReaderFailure( "integrity", `Asset catalog changed during inspection: ${entry.path}`, + { integrityCheck: "member-identity" }, ); const capturedOutput: AnalysisCapturedOutput = { stdout: output, diff --git a/src/artifacts/apple/DylibFileAccess.ts b/src/artifacts/apple/DylibFileAccess.ts index 448b5476c1..00e650a860 100644 --- a/src/artifacts/apple/DylibFileAccess.ts +++ b/src/artifacts/apple/DylibFileAccess.ts @@ -18,6 +18,7 @@ import { mapArtifactFileFailure, withArtifactRegularFile, } from "../ArtifactFileOperation.js"; +import type { ArtifactIntegrityCheck } from "../../domain/artifactOperationError.js"; import type { ArtifactResourceScope } from "../ArtifactResourceScope.js"; import { readArtifactFileChunks } from "../readArtifactFileChunks.js"; import { hasMachoMagic } from "../../domain/apple/machoContainer.js"; @@ -80,7 +81,7 @@ export class DylibFileAccess { for await (const chunk of source) hash.update(chunk); const coverage = source.readCoverage(); if (coverage.kind !== "complete" || coverage.bytes !== initial.size) - throw changed(path); + throw changed(path, undefined, "read-coverage"); return hash.digest("hex"); }); } @@ -164,9 +165,13 @@ const deniedDisplayPath = (root: string, path: string): string => { return inside === "" || outside ? path : inside; }; -const changed = (path: string, cause?: unknown): ArtifactReaderFailure => +const changed = ( + path: string, + cause?: unknown, + integrityCheck: ArtifactIntegrityCheck = "member-identity", +): ArtifactReaderFailure => new ArtifactReaderFailure( "integrity", `${path} changed while its dyld load commands were traced`, - cause === undefined ? undefined : { cause }, + cause === undefined ? { integrityCheck } : { cause, integrityCheck }, ); diff --git a/src/artifacts/apple/DylibResolutionReader.ts b/src/artifacts/apple/DylibResolutionReader.ts index 98d11741a8..5732480ffe 100644 --- a/src/artifacts/apple/DylibResolutionReader.ts +++ b/src/artifacts/apple/DylibResolutionReader.ts @@ -23,6 +23,7 @@ import { } from "../ArtifactReader.js"; import { ArtifactTraversalObservation } from "../ArtifactTraversalObservation.js"; import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; +import { targetDigestChangedFailure } from "../withArtifactTargetIdentity.js"; import { consumeWithOrderedReads } from "../OrderedReads.js"; import { DylibFileAccess, @@ -209,9 +210,10 @@ export const traceDylibResolution = async (options: { digests.get(target) ?? (await files.sha256(join(root, target), undefined)); if (targetDigest !== options.targetSha256) - throw new ArtifactReaderFailure( - "integrity", - `Active target digest changed: expected ${options.targetSha256}, observed ${targetDigest}`, + throw targetDigestChangedFailure( + "target", + options.targetSha256, + targetDigest, ); assertArtifactReadActive(options.signal); return dylibResolutionResultSchema.parse({ diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index 7fbe7a14e6..4835e3a693 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -1,4 +1,5 @@ import { HOST_PLATFORM } from "../../platform/HostPlatform.js"; +import { targetDigestChangedFailure } from "../withArtifactTargetIdentity.js"; import { createHash } from "node:crypto"; import { decodeLimit, @@ -302,9 +303,10 @@ export const inspectStandaloneKeyedArchive = (input: { input.signal, ); if (snapshot.sha256 !== input.targetSha256) - throw new ArtifactReaderFailure( - "integrity", - `Active archive digest changed: expected ${input.targetSha256}, observed ${snapshot.sha256}`, + throw targetDigestChangedFailure( + "archive", + input.targetSha256, + snapshot.sha256, ); return decodeSelectedArchive(snapshot.bytes, { targetSha256: input.targetSha256, diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index e90fa4284a..9afbd65a54 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -187,7 +187,7 @@ export const materializeArtifactInventoryInScope = async ( throw new ArtifactReaderFailure( "integrity", `Inventoried source changed: ${sourcePath}`, - { cause }, + { cause, integrityCheck: "container-identity" }, ); throw cause; } @@ -288,6 +288,7 @@ export const materializeArtifactInventoryInScope = async ( throw new ArtifactReaderFailure( "integrity", `Selected occurrence has no inventory node: ${occurrence.occurrence_id}`, + { integrityCheck: "inventory-node" }, ); return { occurrence, node }; }); @@ -493,6 +494,7 @@ const writeSelectedEntries = async ({ throw new ArtifactReaderFailure( "integrity", `Regular artifact entry is missing from inventory: ${path}`, + { integrityCheck: "inventory-node" }, ); } queue.next += 1; @@ -526,6 +528,7 @@ const writeSelectedEntries = async ({ throw new ArtifactReaderFailure( "integrity", `Inventoried regular artifact entries were not materialized: ${[...expected.keys()].sort(compareUnicodeCodePoints).join(", ")}`, + { integrityCheck: "traversal-coverage" }, ); }; @@ -750,7 +753,7 @@ const createReader = async ( throw new ArtifactReaderFailure( "integrity", `Inventoried source changed: ${path}`, - { cause }, + { cause, integrityCheck: "container-identity" }, ); throw cause; } diff --git a/src/artifacts/inventory/hashStableRootArtifact.ts b/src/artifacts/inventory/hashStableRootArtifact.ts index 37f6ded9c6..1d11fc1ae6 100644 --- a/src/artifacts/inventory/hashStableRootArtifact.ts +++ b/src/artifacts/inventory/hashStableRootArtifact.ts @@ -166,7 +166,7 @@ export const hashStableRootArtifactHandle = async ( throw new ArtifactReaderFailure( "integrity", `Root artifact changed during inventory: ${path}`, - { cause: cause.cause ?? cause }, + { cause: cause.cause ?? cause, integrityCheck: "container-identity" }, ); throw artifactOperationCause(cause); } @@ -174,6 +174,7 @@ export const hashStableRootArtifactHandle = async ( throw new ArtifactReaderFailure( "integrity", `Root artifact changed during inventory: ${path}`, + { integrityCheck: "container-identity" }, ); abortIfNeeded(signal); return digest; diff --git a/src/artifacts/withArtifactTargetIdentity.ts b/src/artifacts/withArtifactTargetIdentity.ts index b6ad882d69..ffebd5984b 100644 --- a/src/artifacts/withArtifactTargetIdentity.ts +++ b/src/artifacts/withArtifactTargetIdentity.ts @@ -3,6 +3,21 @@ import { ArtifactReaderFailure } from "./ArtifactReader.js"; import type { ArtifactResourceScope } from "./ArtifactResourceScope.js"; import { hashStableRootArtifact } from "./inventory/hashStableRootArtifact.js"; +/** + * The `container-identity` failure for a selected target whose observed + * digest no longer matches the one the session admitted. + */ +export const targetDigestChangedFailure = ( + subject: string, + expected: string, + observed: string, +): ArtifactReaderFailure => + new ArtifactReaderFailure( + "integrity", + `Active ${subject} digest changed: expected ${expected}, observed ${observed}`, + { integrityCheck: "container-identity" }, + ); + /** Bind live resource reads to the target identity established by the session. */ export const withArtifactTargetIdentity = async ( target: BinaryTarget, @@ -13,10 +28,7 @@ export const withArtifactTargetIdentity = async ( const verify = async () => { const digest = await hashStableRootArtifact(target.path, resources, signal); if (digest.sha256 !== target.sha256) - throw new ArtifactReaderFailure( - "integrity", - `Active target digest changed: expected ${target.sha256}, observed ${digest.sha256}`, - ); + throw targetDigestChangedFailure("target", target.sha256, digest.sha256); }; await verify(); const result = await read(); diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index 093055114e..9f1e203c0f 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -32,7 +32,8 @@ export interface ArtifactAdmissionLimit { * or place an entry outside the artifact, entries that do not reassemble into * the source, an inventory node or verified state a later read relies on * that is missing, output bytes that do not read back as written, or an - * output tree used after commit or whose cleanup could not be verified. + * output tree used after commit or whose cleanup could not be verified, + * or a verification traversal that did not reproduce every inventoried entry. */ export type ArtifactIntegrityCheck = | "foreign-entry" @@ -49,7 +50,8 @@ export type ArtifactIntegrityCheck = | "reconstruction" | "inventory-node" | "output-readback" - | "output-lifecycle"; + | "output-lifecycle" + | "traversal-coverage"; /** Artifact inventory or extraction failed a typed safety boundary. */ export class ArtifactOperationError extends AnalysisError { diff --git a/src/native/NativeMachoSource.test.ts b/src/native/NativeMachoSource.test.ts index 85ea4b7ce4..8af92e5415 100644 --- a/src/native/NativeMachoSource.test.ts +++ b/src/native/NativeMachoSource.test.ts @@ -84,7 +84,10 @@ it("refuses wrong initial digest and a changed selected file even after a return undefined, run, ), - ).rejects.toMatchObject({ reason: "integrity" }); + ).rejects.toMatchObject({ + reason: "integrity", + integrityCheck: "container-identity", + }); expect(run).not.toHaveBeenCalled(); await expect( withNativeMachoSource(f, scope, undefined, async () => { @@ -111,7 +114,10 @@ it("refuses a path replacement during the producer callback", async () => { await writeFile(f.path, f.bytes); return "result"; }), - ).rejects.toMatchObject({ reason: "integrity" }); + ).rejects.toMatchObject({ + reason: "integrity", + integrityCheck: "container-identity", + }); } finally { await scope.close(); await rm(f.root, { recursive: true, force: true }); diff --git a/src/native/NativeMachoSource.ts b/src/native/NativeMachoSource.ts index 6be35bc95f..6c45acdaf7 100644 --- a/src/native/NativeMachoSource.ts +++ b/src/native/NativeMachoSource.ts @@ -8,6 +8,7 @@ import { import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { hashStableRootArtifactHandle } from "../artifacts/inventory/hashStableRootArtifact.js"; +import { targetDigestChangedFailure } from "../artifacts/withArtifactTargetIdentity.js"; /** A positional reader for one slice of the descriptor bound to the target. */ export interface NativeMachoSource { @@ -37,9 +38,13 @@ export class NativeMachoSourceVerificationFailure extends ArtifactReaderFailure : String(verificationCause), { cause: verificationCause, - ...(verificationCause instanceof ArtifactReaderFailure && - verificationCause.cancellation !== undefined - ? { cancellation: verificationCause.cancellation } + ...(verificationCause instanceof ArtifactReaderFailure + ? { + ...verificationCause.primaryFacts(), + ...(verificationCause.cancellation === undefined + ? {} + : { cancellation: verificationCause.cancellation }), + } : {}), }, verificationCause instanceof ArtifactReaderFailure @@ -82,14 +87,15 @@ export const withNativeMachoSource = ( throw new ArtifactReaderFailure( "integrity", `Active Mach-O target changed: ${target.path}`, - { cause }, + { cause, integrityCheck: "container-identity" }, ); throw cause; }); if (digest.sha256 !== target.sha256) - throw new ArtifactReaderFailure( - "integrity", - `Active Mach-O target digest changed: expected ${target.sha256}, observed ${digest.sha256}`, + throw targetDigestChangedFailure( + "Mach-O target", + target.sha256, + digest.sha256, ); }; await verify(); @@ -122,6 +128,7 @@ export const withNativeMachoSource = ( throw new ArtifactReaderFailure( "integrity", "Mach-O source changed during slice read.", + { integrityCheck: "read-coverage" }, ); return bytes; }, diff --git a/tests/boundary/filesystem/keyedArchiveStandaloneSelection.test.ts b/tests/boundary/filesystem/keyedArchiveStandaloneSelection.test.ts index 628fccfe8c..6810a548b7 100644 --- a/tests/boundary/filesystem/keyedArchiveStandaloneSelection.test.ts +++ b/tests/boundary/filesystem/keyedArchiveStandaloneSelection.test.ts @@ -129,7 +129,7 @@ it.skipIf(process.platform === "win32")( ok: false, error: { reason: "integrity", - detail: expect.stringContaining("Active archive digest changed"), + integrityCheck: "container-identity", }, }); await truncate(stored, 64 * 1024 * 1024 + 1); diff --git a/tests/boundary/mcp/dylibResolutionMcp.test.ts b/tests/boundary/mcp/dylibResolutionMcp.test.ts index 5ea20698d0..db6f9378dd 100644 --- a/tests/boundary/mcp/dylibResolutionMcp.test.ts +++ b/tests/boundary/mcp/dylibResolutionMcp.test.ts @@ -111,7 +111,12 @@ it("traces dylib resolution for an opened app bundle and rejects a changed targe arguments: {}, }); expect(changed.isError).toBe(true); - expect(JSON.stringify(parseMcpToolError(changed))).toContain("integrity"); + expect(parseMcpToolError(changed)).toMatchObject({ + error: { + category: "integrity_mismatch", + details: { reason: "integrity", integrity_check: "container-identity" }, + }, + }); }); }); diff --git a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts index 26fa178edc..8ea9b49efe 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts @@ -104,7 +104,8 @@ it.each(["file", "directory"] as const)( ); await expect(materialize()).rejects.toMatchObject({ reason: "integrity", - message: expect.stringMatching(/not materialized.*z\/missing\.txt/iu), + integrityCheck: "traversal-coverage", + message: expect.stringContaining("z/missing.txt"), }); await expect(access(output)).rejects.toThrow(); expect( @@ -129,9 +130,8 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( onTestFinished(() => chmod(child, 0o755)); await expect(materialize()).rejects.toMatchObject({ reason: "integrity", - message: expect.stringMatching( - /not materialized.*child\/selected\.txt/iu, - ), + integrityCheck: "traversal-coverage", + message: expect.stringContaining("child/selected.txt"), }); await expect(access(output)).rejects.toThrow(); await chmod(child, 0o755); @@ -151,7 +151,8 @@ it("still refuses a regular file added after inventory and rolls back", async () await writeFile(join(source, "z.txt"), "new\n"); await expect(materialize()).rejects.toMatchObject({ reason: "integrity", - message: "Regular artifact entry is missing from inventory: z.txt", + integrityCheck: "inventory-node", + message: expect.stringContaining("z.txt"), }); await expect(access(output)).rejects.toThrow(); });