feat(artifacts): name the integrity check for target digests, Apple bundle readers and extraction (#11125)

This commit is contained in:
morluto authored and GitHub committed 2026-11-15 21:05:00 +08:00
1 parent 682a57a987
commit 6363881faa
13 files changed
+82 -34

No files matched your search

@@ -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,
+8 -3
View File
@@ -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 },
);
+5 -3
View File
@@ -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({
+5 -3
View File
@@ -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,
@@ -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;
}
@@ -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;
+16 -4
View File
@@ -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 <T>(
target: BinaryTarget,
@@ -13,10 +28,7 @@ export const withArtifactTargetIdentity = async <T>(
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();
+4 -2
View File
@@ -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 {
+8 -2
View File
@@ -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 });
+14 -7
View File
@@ -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 = <T>(
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 = <T>(
throw new ArtifactReaderFailure(
"integrity",
"Mach-O source changed during slice read.",
{ integrityCheck: "read-coverage" },
);
return bytes;
},
@@ -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);
@@ -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" },
},
});
});
});
@@ -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();
});