mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
feat(artifacts): name the integrity check for target digests, Apple bundle readers and extraction (#11125)
This commit is contained in:
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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user