mirror of
https://github.com/morluto/rea
synced 2026-10-27 06:33:32 -04:00
35d6d5bb0a
* fix(artifacts): admit ZIP archives after unclaimed leading bytes ZIP recognition required the archive at byte 0, so a ZIP behind a script or self-extractor stub was refused as an unsupported binary format although standard readers accept it. Target resolution and root inventory now locate a trailing single-disk ZIP from its end record, central directory and first local header, whether member offsets include the leading bytes or not. Leading bytes any executable header family claims keep their executable route. Fixes #5732 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(artifacts): share the root ZIP family across recognition paths The trailing-ZIP fallback named a stub-prefixed root `.app` archive `zip`, while root inventory and the leading-signature route name it `app-pack`. The root mapping now lives once in `zipRootPackageFormat` and both target resolution routes and root inventory use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
913 lines
33 KiB
TypeScript
913 lines
33 KiB
TypeScript
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
|
|
|
import { fragmentInventoryEvidence } from "../../fixtures/artifactEvidence.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
import { inventoryArtifact } from "../../fixtures/artifactInventory.js";
|
|
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
|
|
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js";
|
|
import {
|
|
artifactComparisonResultSchema,
|
|
compareArtifacts,
|
|
} from "../../../src/domain/artifactComparison.js";
|
|
import { parseArtifactInventoryEvidence } from "../../../src/domain/artifactInventoryEvidence.js";
|
|
import { createEvidence, type Evidence } from "../../../src/domain/evidence.js";
|
|
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
|
|
|
|
const PROVIDER = {
|
|
id: "rea-artifact-graph",
|
|
name: "REA artifact graph",
|
|
version: "1",
|
|
} as const;
|
|
|
|
const observe = async (path: string) => {
|
|
const inventory = await inventoryArtifact(path);
|
|
return createEvidence(
|
|
{
|
|
path,
|
|
sha256: inventory.manifest.root_sha256,
|
|
format: inventory.manifest.root_format,
|
|
},
|
|
PROVIDER,
|
|
{
|
|
operation: "inventory_artifact",
|
|
parameters: {},
|
|
result: jsonValueSchema.parse(inventory),
|
|
confidence: "observed",
|
|
authority: "shipped-artifact",
|
|
},
|
|
);
|
|
};
|
|
|
|
const inspectPages = (
|
|
pages: readonly Evidence[],
|
|
substepsCompleted = pages.length,
|
|
) => {
|
|
const first = pages[0];
|
|
if (first === undefined) throw new Error("Missing inspection page");
|
|
const inventory = artifactInventoryResultSchema.parse(
|
|
first.normalized_result,
|
|
);
|
|
return createEvidence(
|
|
{
|
|
path: "artifact-inspection-example",
|
|
sha256: inventory.manifest.root_sha256,
|
|
format: inventory.manifest.root_format,
|
|
},
|
|
PROVIDER,
|
|
{
|
|
operation: "inspect_artifact",
|
|
parameters: {},
|
|
result: jsonValueSchema.parse({
|
|
inspection_id: `ai_${"f".repeat(64)}`,
|
|
subject: {
|
|
manifest_id: inventory.manifest.manifest_id,
|
|
root_artifact_id: inventory.manifest.root_artifact_id,
|
|
root_sha256: inventory.manifest.root_sha256,
|
|
root_format: inventory.manifest.root_format,
|
|
},
|
|
substeps: pages.map((evidence, index) => ({
|
|
substep_id: `ais_${(index + 1).toString(16).padStart(64, "0")}`,
|
|
operation: "inventory_artifact",
|
|
status: "completed",
|
|
evidence_id: evidence.evidence_id,
|
|
evidence,
|
|
})),
|
|
observations: [],
|
|
hypotheses: [],
|
|
contradictions: [],
|
|
unexplored_branches: [],
|
|
next_probes: [],
|
|
coverage: {
|
|
status: "complete-within-substeps",
|
|
substeps_completed: substepsCompleted,
|
|
observations_retained: 0,
|
|
observations_omitted: 0,
|
|
hypotheses_retained: 0,
|
|
hypotheses_omitted: 0,
|
|
unexplored_branches_retained: 0,
|
|
unexplored_branches_omitted: 0,
|
|
next_probes_retained: 0,
|
|
next_probes_omitted: 0,
|
|
},
|
|
evidence_links: pages.map(({ evidence_id }) => evidence_id),
|
|
limitations: [],
|
|
}),
|
|
confidence: "observed",
|
|
authority: "shipped-artifact",
|
|
},
|
|
);
|
|
};
|
|
|
|
const changedArtifactComparison = async () => {
|
|
const parent = await createTestTempDirectory("rea-artifact-compare-");
|
|
const leftPath = join(parent, "left.app");
|
|
const rightPath = join(parent, "right.app");
|
|
await Promise.all([mkdir(leftPath), mkdir(rightPath)]);
|
|
await Promise.all([
|
|
writeFile(join(leftPath, "main.js"), "old();"),
|
|
writeFile(join(leftPath, "same.txt"), "same"),
|
|
writeFile(join(rightPath, "main.js"), "newer();"),
|
|
writeFile(join(rightPath, "same.txt"), "same"),
|
|
writeFile(join(rightPath, "added.txt"), "added"),
|
|
]);
|
|
const left = await observe(leftPath);
|
|
const right = await observe(rightPath);
|
|
return {
|
|
left,
|
|
right,
|
|
result: compareArtifacts(left, right),
|
|
};
|
|
};
|
|
|
|
describe("leaf artifact comparison", () => {
|
|
it("rejects inventory Evidence without required producer coverage", async () => {
|
|
const root = await createTestTempDirectory("rea-artifact-plist-compare-");
|
|
const leftPath = join(root, "left.plist");
|
|
const xml =
|
|
'<?xml version="1.0"?><plist version="1.0"><dict><key>label</key><string>hello</string></dict></plist>';
|
|
await writeFile(leftPath, xml);
|
|
const left = await observe(leftPath);
|
|
const parsed = artifactInventoryResultSchema.parse(left.normalized_result);
|
|
const { coverage: _coverage, ...withoutCoverage } = parsed;
|
|
const malformed = createEvidence(
|
|
{
|
|
path: left.subject!.local_path,
|
|
sha256: left.subject!.digest.sha256,
|
|
format: left.subject!.format,
|
|
},
|
|
PROVIDER,
|
|
{
|
|
operation: "inventory_artifact",
|
|
parameters: {},
|
|
result: jsonValueSchema.parse(withoutCoverage),
|
|
confidence: "observed",
|
|
authority: "shipped-artifact",
|
|
},
|
|
);
|
|
expect(() => parseArtifactInventoryEvidence(malformed)).toThrow();
|
|
});
|
|
|
|
it("keeps content and executable changes visible for complete plist roots", async () => {
|
|
const root = await createTestTempDirectory("rea-artifact-plist-deltas-");
|
|
const leftPath = join(root, "left.plist");
|
|
const rightPath = join(root, "right.plist");
|
|
const plist = (label: string) =>
|
|
`<?xml version="1.0"?><plist version="1.0"><dict><key>label</key><string>${label}</string></dict></plist>`;
|
|
await Promise.all([
|
|
writeFile(leftPath, plist("hello"), { mode: 0o644 }),
|
|
writeFile(rightPath, plist("world"), { mode: 0o644 }),
|
|
]);
|
|
const [left, changed] = await Promise.all([
|
|
observe(leftPath),
|
|
observe(rightPath),
|
|
]);
|
|
expect(compareArtifacts(left, changed)).toMatchObject({
|
|
status: "changed",
|
|
summary: { changed: 1, unknown: 0 },
|
|
changes: [
|
|
expect.objectContaining({
|
|
logical_path: ".",
|
|
classification: "changed",
|
|
dimensions: expect.arrayContaining(["content"]),
|
|
}),
|
|
],
|
|
});
|
|
|
|
if (process.platform !== "win32") {
|
|
await writeFile(rightPath, plist("hello"), { mode: 0o644 });
|
|
await chmod(rightPath, 0o755);
|
|
const executable = await observe(rightPath);
|
|
expect(compareArtifacts(left, executable)).toMatchObject({
|
|
status: "changed",
|
|
summary: { changed: 1, unknown: 0 },
|
|
changes: [
|
|
expect.objectContaining({
|
|
logical_path: ".",
|
|
classification: "changed",
|
|
dimensions: expect.arrayContaining(["executable"]),
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("artifact inspection inventory substeps", () => {
|
|
it("assembles every paged inventory substep and retains its outer Evidence once", async () => {
|
|
const root = await createTestTempDirectory("rea-inspect-pages-");
|
|
const path = join(root, "fixture.app");
|
|
await mkdir(path);
|
|
await Promise.all([
|
|
writeFile(join(path, "first.js"), "first();"),
|
|
writeFile(join(path, "second.js"), "second();"),
|
|
]);
|
|
const source = await observe(path);
|
|
const pages = fragmentInventoryEvidence(source, 2);
|
|
const inspection = inspectPages(pages);
|
|
const parsed = parseArtifactInventoryEvidence(inspection);
|
|
|
|
expect(parsed.evidence).toEqual([inspection]);
|
|
expect(parsed.inventory.complete).toBe(true);
|
|
expect(parsed.inventory.nodes).toHaveLength(
|
|
artifactInventoryResultSchema.parse(source.normalized_result).nodes
|
|
.length,
|
|
);
|
|
});
|
|
|
|
it("rejects inspection substeps with different inventory manifests", async () => {
|
|
const root = await createTestTempDirectory("rea-inspect-mixed-pages-");
|
|
const firstPath = join(root, "first.plist");
|
|
const secondPath = join(root, "second.plist");
|
|
await Promise.all([
|
|
writeFile(
|
|
firstPath,
|
|
'<?xml version="1.0"?><plist version="1.0"><dict><key>a</key><string>1</string></dict></plist>',
|
|
),
|
|
writeFile(
|
|
secondPath,
|
|
'<?xml version="1.0"?><plist version="1.0"><dict><key>b</key><string>2</string></dict></plist>',
|
|
),
|
|
]);
|
|
|
|
const inspection = inspectPages([
|
|
await observe(firstPath),
|
|
await observe(secondPath),
|
|
]);
|
|
expect(() => parseArtifactInventoryEvidence(inspection)).toThrow(
|
|
"Artifact inventory pages do not share one manifest",
|
|
);
|
|
});
|
|
|
|
it("rejects inspection coverage counts that disagree with retained substeps", async () => {
|
|
const root = await createTestTempDirectory("rea-inspect-count-mismatch-");
|
|
const path = join(root, "fixture.app");
|
|
await mkdir(path);
|
|
await Promise.all([
|
|
writeFile(join(path, "first.js"), "first();"),
|
|
writeFile(join(path, "second.js"), "second();"),
|
|
]);
|
|
const pages = fragmentInventoryEvidence(await observe(path), 2);
|
|
const inspection = inspectPages(pages, 1);
|
|
|
|
expect(() => parseArtifactInventoryEvidence(inspection)).toThrow(
|
|
"Artifact inspection substep count does not match its coverage declaration",
|
|
);
|
|
});
|
|
|
|
it("authenticates later inventory substeps instead of silently ignoring them", async () => {
|
|
const root = await createTestTempDirectory("rea-inspect-invalid-page-");
|
|
const path = join(root, "fixture.app");
|
|
await mkdir(path);
|
|
await writeFile(join(path, "main.js"), "main();");
|
|
const source = await observe(path);
|
|
const [first, later] = fragmentInventoryEvidence(source, 2);
|
|
if (first === undefined || later === undefined)
|
|
throw new Error("Missing inventory page");
|
|
const graph = artifactInventoryResultSchema.parse(later.normalized_result);
|
|
const tamperedLater: Evidence = {
|
|
...later,
|
|
normalized_result: jsonValueSchema.parse({
|
|
...graph,
|
|
limitations: ["Changed after this Evidence identifier was computed."],
|
|
}),
|
|
};
|
|
const inspection = inspectPages([first, tamperedLater]);
|
|
|
|
expect(() => parseArtifactInventoryEvidence(inspection)).toThrow(
|
|
"Evidence semantic identifier does not match its record",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("artifact inventory coverage", () => {
|
|
it("uses current unknown coverage without deriving it from limitation prose", async () => {
|
|
const root = await createTestTempDirectory("rea-artifact-coverage-");
|
|
const path = join(root, "complete.plist");
|
|
await writeFile(
|
|
path,
|
|
'<?xml version="1.0"?><plist version="1.0"><dict/></plist>',
|
|
);
|
|
const complete = await observe(path);
|
|
const graph = artifactInventoryResultSchema.parse(
|
|
complete.normalized_result,
|
|
);
|
|
const unknownCoverage = createEvidence(
|
|
{
|
|
path: complete.subject!.local_path,
|
|
sha256: complete.subject!.digest.sha256,
|
|
format: complete.subject!.format,
|
|
},
|
|
PROVIDER,
|
|
{
|
|
operation: "inventory_artifact",
|
|
parameters: {},
|
|
result: jsonValueSchema.parse({
|
|
...graph,
|
|
coverage: { scope: "root", status: "unknown" },
|
|
limitations: ["No extra child entries were observed."],
|
|
}),
|
|
confidence: "derived",
|
|
authority: "shipped-artifact",
|
|
},
|
|
);
|
|
expect(
|
|
parseArtifactInventoryEvidence(unknownCoverage).inventory.complete,
|
|
).toBe(true);
|
|
expect(compareArtifacts(unknownCoverage, unknownCoverage)).toMatchObject({
|
|
status: "unknown",
|
|
summary: { unchanged: 0, unknown: 1 },
|
|
});
|
|
});
|
|
|
|
it("rejects inventory fragments that disagree on producer coverage", async () => {
|
|
const parent = await createTestTempDirectory("rea-artifact-page-coverage-");
|
|
const path = join(parent, "fixture.app");
|
|
await mkdir(path);
|
|
await writeFile(join(path, "main.js"), "main();");
|
|
const source = await observe(path);
|
|
const fragments = fragmentInventoryEvidence(source, 2);
|
|
const firstFragment = fragments[0];
|
|
const fragment = fragments[1];
|
|
if (firstFragment === undefined || fragment === undefined)
|
|
throw new Error("Missing test fragment");
|
|
const fragmentGraph = artifactInventoryResultSchema.parse(
|
|
fragment.normalized_result,
|
|
);
|
|
const mixed = createEvidence(
|
|
{
|
|
path: fragment.subject!.local_path,
|
|
sha256: fragment.subject!.digest.sha256,
|
|
format: fragment.subject!.format,
|
|
},
|
|
PROVIDER,
|
|
{
|
|
operation: "inventory_artifact",
|
|
parameters: fragment.parameters,
|
|
result: jsonValueSchema.parse({
|
|
...fragmentGraph,
|
|
coverage: { ...fragmentGraph.coverage, status: "unknown" },
|
|
}),
|
|
confidence: "derived",
|
|
authority: "shipped-artifact",
|
|
},
|
|
);
|
|
expect(() =>
|
|
parseArtifactInventoryEvidence([firstFragment, mixed]),
|
|
).toThrow("Artifact inventory pages do not share one coverage declaration");
|
|
});
|
|
});
|
|
|
|
describe("artifact comparison", () => {
|
|
it("keeps the canonical public example valid and fully comparable", () => {
|
|
const left = parseArtifactInventoryEvidence(
|
|
ARTIFACT_COMPARISON_EXAMPLE.left,
|
|
);
|
|
const right = parseArtifactInventoryEvidence(
|
|
ARTIFACT_COMPARISON_EXAMPLE.right,
|
|
);
|
|
|
|
expect(left.inventory).toMatchObject({
|
|
complete: true,
|
|
coverage: { scope: "root", status: "complete" },
|
|
});
|
|
expect(right.inventory).toMatchObject({
|
|
complete: true,
|
|
coverage: { scope: "root", status: "complete" },
|
|
});
|
|
expect(
|
|
artifactComparisonResultSchema.parse(
|
|
compareArtifacts(
|
|
ARTIFACT_COMPARISON_EXAMPLE.left,
|
|
ARTIFACT_COMPARISON_EXAMPLE.right,
|
|
),
|
|
),
|
|
).toMatchObject({
|
|
status: "changed",
|
|
summary: { changed: 1, unknown: 0 },
|
|
changes: [
|
|
expect.objectContaining({
|
|
logical_path: ".",
|
|
classification: "changed",
|
|
dimensions: expect.arrayContaining(["content"]),
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
|
|
it("classifies deterministic path changes and cites both inventories", async () => {
|
|
const firstObservation = await changedArtifactComparison();
|
|
const secondObservation = await changedArtifactComparison();
|
|
const first = firstObservation.result;
|
|
const second = secondObservation.result;
|
|
expect(first).toEqual(second);
|
|
expect(artifactComparisonResultSchema.parse(first)).toMatchObject({
|
|
status: "changed",
|
|
summary: { added: 1, changed: 2, unknown: 0 },
|
|
changes: expect.arrayContaining([
|
|
expect.objectContaining({ logical_path: "added.txt" }),
|
|
]),
|
|
});
|
|
expect(first.changes[0]?.evidence_links).toEqual([
|
|
firstObservation.left.evidence_id,
|
|
firstObservation.right.evidence_id,
|
|
]);
|
|
expect(first.changes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
logical_path: "added.txt",
|
|
classification: "added",
|
|
}),
|
|
expect.objectContaining({
|
|
logical_path: "main.js",
|
|
classification: "changed",
|
|
dimensions: expect.arrayContaining(["content", "size"]),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("reports incomplete inventory as truncated, never unchanged", async () => {
|
|
const root = await createTestTempDirectory("rea-artifact-truncated-");
|
|
await writeFile(join(root, "one.txt"), "one");
|
|
const complete = await observe(root);
|
|
const completeInventory = artifactInventoryResultSchema.parse(
|
|
complete.normalized_result,
|
|
);
|
|
const incomplete = createEvidence(
|
|
{
|
|
path: root,
|
|
sha256: complete.subject?.digest.sha256 ?? "0".repeat(64),
|
|
format: "directory",
|
|
},
|
|
PROVIDER,
|
|
{
|
|
operation: "inventory_artifact",
|
|
parameters: {},
|
|
result: jsonValueSchema.parse({
|
|
...completeInventory,
|
|
nodes: [],
|
|
}),
|
|
confidence: "observed",
|
|
authority: "shipped-artifact",
|
|
limitations: ["Inventory node observations are unavailable."],
|
|
},
|
|
);
|
|
const comparison = compareArtifacts(incomplete, incomplete);
|
|
expect(comparison).toMatchObject({
|
|
status: "truncated",
|
|
summary: { unchanged: 0, unknown: 2 },
|
|
});
|
|
expect(comparison.limitations).toContain(
|
|
"Left artifact inventory is incomplete.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("artifact comparison completeness", () => {
|
|
it("does not infer child changes from root-only coverage", async () => {
|
|
const root = await createTestTempDirectory("rea-artifact-root-coverage-");
|
|
const leftPath = join(root, "left.app");
|
|
const rightPath = join(root, "right.app");
|
|
await Promise.all([mkdir(leftPath), mkdir(rightPath)]);
|
|
await Promise.all([
|
|
writeFile(join(leftPath, "left-only.txt"), "left"),
|
|
writeFile(join(rightPath, "right-only.txt"), "right"),
|
|
]);
|
|
const [left, right] = await Promise.all([
|
|
observe(leftPath),
|
|
observe(rightPath),
|
|
]);
|
|
const rootOnly = (evidence: Awaited<ReturnType<typeof observe>>) => {
|
|
const inventory = artifactInventoryResultSchema.parse(
|
|
evidence.normalized_result,
|
|
);
|
|
return createEvidence(
|
|
{
|
|
path: evidence.subject?.local_path ?? "",
|
|
sha256: inventory.manifest.root_sha256,
|
|
format: inventory.manifest.root_format,
|
|
},
|
|
PROVIDER,
|
|
{
|
|
operation: "inventory_artifact",
|
|
parameters: {},
|
|
result: jsonValueSchema.parse({
|
|
...inventory,
|
|
coverage: { scope: "root", status: "complete" },
|
|
}),
|
|
confidence: "derived",
|
|
authority: "shipped-artifact",
|
|
},
|
|
);
|
|
};
|
|
const compared = compareArtifacts(rootOnly(left), rootOnly(right));
|
|
expect(compared.status).toBe("unknown");
|
|
expect(compared.summary).toMatchObject({ added: 0, removed: 0 });
|
|
expect(
|
|
compared.changes
|
|
.filter(({ logical_path }) => logical_path !== ".")
|
|
.map(({ logical_path, classification }) => ({
|
|
logical_path,
|
|
classification,
|
|
})),
|
|
).toEqual([
|
|
{ logical_path: "left-only.txt", classification: "unknown" },
|
|
{ logical_path: "right-only.txt", classification: "unknown" },
|
|
]);
|
|
expect(compared.limitations).toContain(
|
|
"Container entry coverage is unavailable for child paths.",
|
|
);
|
|
});
|
|
|
|
it("compares every graph member for inventories larger than 500 entries", async () => {
|
|
const root = await createTestTempDirectory("rea-artifact-pages-");
|
|
const entryCount = 501;
|
|
await Promise.all(
|
|
Array.from({ length: entryCount }, async (_, index) =>
|
|
writeFile(
|
|
join(root, `file-${String(index).padStart(3, "0")}.txt`),
|
|
String(index),
|
|
),
|
|
),
|
|
);
|
|
const complete = await observe(root);
|
|
const graph = artifactInventoryResultSchema.parse(
|
|
complete.normalized_result,
|
|
);
|
|
// The real risk above 500 entries is silent truncation, so assert the
|
|
// manifest counts reconcile with the arrays actually returned and that
|
|
// every written file is present. Deriving from `entryCount` keeps this
|
|
// valid at any size instead of freezing a page boundary.
|
|
expect(graph.manifest.node_count).toBe(graph.nodes.length);
|
|
expect(graph.manifest.occurrence_count).toBe(graph.occurrences.length);
|
|
expect(graph.manifest.edge_count).toBe(graph.edges.length);
|
|
const written = new Set(
|
|
Array.from(
|
|
{ length: entryCount },
|
|
(_, index) => `file-${String(index).padStart(3, "0")}.txt`,
|
|
),
|
|
);
|
|
// Occurrences carry the inventory paths, so completeness is checked there.
|
|
const paths = new Set(
|
|
graph.occurrences.map((occurrence) => occurrence.logical_path),
|
|
);
|
|
expect([...written].filter((path) => !paths.has(path))).toEqual([]);
|
|
expect(
|
|
graph.occurrences.filter(
|
|
(occurrence) => occurrence.entry_kind === "file",
|
|
),
|
|
).toHaveLength(entryCount);
|
|
expect(compareArtifacts(complete, complete)).toMatchObject({
|
|
status: "unchanged",
|
|
summary: { unchanged: 502, unknown: 0 },
|
|
changes: [],
|
|
});
|
|
}, 15_000);
|
|
});
|
|
|
|
describe("artifact source-map relationship coverage", () => {
|
|
it("preserves unknown source-map relationships for duplicate ZIP paths", async () => {
|
|
const root = await createTestTempDirectory("rea-duplicate-source-map-");
|
|
const path = join(root, "duplicate-source-map.zip");
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("main.js", new TextReader("first();"), { level: 0 });
|
|
await writer.add("your.js", new TextReader("second();"), { level: 0 });
|
|
await writer.add("main.js.map", new TextReader("{}"), { level: 0 });
|
|
const bytes = Buffer.from(await writer.close());
|
|
for (const [signature, headerSize] of [
|
|
[Buffer.from([0x50, 0x4b, 0x03, 0x04]), 30],
|
|
[Buffer.from([0x50, 0x4b, 0x01, 0x02]), 46],
|
|
] as const) {
|
|
const first = bytes.indexOf(signature);
|
|
const second = bytes.indexOf(signature, first + 4);
|
|
if (first < 0 || second <= first)
|
|
throw new Error("Expected both source ZIP member headers");
|
|
expect(
|
|
bytes.subarray(second + headerSize, second + headerSize + 7).toString(),
|
|
).toBe("your.js");
|
|
bytes.write("main.js", second + headerSize);
|
|
}
|
|
await writeFile(path, bytes);
|
|
|
|
const evidence = await observe(path);
|
|
const parsed = parseArtifactInventoryEvidence(evidence);
|
|
const { inventory } = parsed;
|
|
const map = inventory.occurrences.find(
|
|
({ logical_path }) => logical_path === "main.js.map",
|
|
);
|
|
if (map === undefined) throw new Error("Expected source-map occurrence");
|
|
const mapEdge = inventory.edges.find(
|
|
({ occurrence_id }) => occurrence_id === map.occurrence_id,
|
|
);
|
|
|
|
expect(inventory.coverage.status).toBe("unknown");
|
|
expect(map.limitations).toContain(
|
|
'Source-map target is unknown because multiple occurrences share "main.js".',
|
|
);
|
|
expect(mapEdge).toMatchObject({ relation: "contains" });
|
|
expect(mapEdge?.relation).not.toBe("maps-source");
|
|
expect(inventory.integrityContradictions).toEqual([]);
|
|
expect(
|
|
inventory.limitations.some((limitation) =>
|
|
limitation.toLowerCase().includes("integrity"),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("duplicate archive occurrence ownership", () => {
|
|
it("keeps directory ancestry unknown when ZIP directory paths repeat", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-artifact-duplicate-directory-",
|
|
);
|
|
const path = join(root, "duplicate-directories.zip");
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("x/", new TextReader(""));
|
|
await writer.add("y/", new TextReader(""));
|
|
await writer.add("x/file.txt", new TextReader("member"));
|
|
const bytes = Buffer.from(await writer.close());
|
|
for (const [signature, headerSize] of [
|
|
[Buffer.from([0x50, 0x4b, 0x03, 0x04]), 30],
|
|
[Buffer.from([0x50, 0x4b, 0x01, 0x02]), 46],
|
|
] as const) {
|
|
const first = bytes.indexOf(signature);
|
|
const second = bytes.indexOf(signature, first + 4);
|
|
if (first < 0 || second <= first)
|
|
throw new Error("Expected both ZIP directory headers");
|
|
expect(
|
|
bytes.subarray(second + headerSize, second + headerSize + 2).toString(),
|
|
).toBe("y/");
|
|
bytes.write("x/", second + headerSize);
|
|
}
|
|
await writeFile(path, bytes);
|
|
|
|
const evidence = await observe(path);
|
|
const inventory = parseArtifactInventoryEvidence(evidence).inventory;
|
|
const directory = inventory.occurrences.filter(
|
|
({ logical_path }) => logical_path === "x",
|
|
);
|
|
const child = inventory.occurrences.find(
|
|
({ logical_path }) => logical_path === "x/file.txt",
|
|
);
|
|
expect(directory).toHaveLength(2);
|
|
expect(directory.every(({ artifact_id }) => artifact_id === null)).toBe(
|
|
true,
|
|
);
|
|
expect(child?.parent_occurrence_id).toBeNull();
|
|
expect(child?.limitations).toContain(
|
|
'The parent occurrence for "x/file.txt" is unknown because multiple observed directory or container occurrences share "x".',
|
|
);
|
|
expect(inventory.coverage.status).toBe("unknown");
|
|
});
|
|
|
|
it("retains duplicate path occurrences across pages and leaves pairing unknown", async () => {
|
|
const root = await createTestTempDirectory("rea-artifact-duplicate-pages-");
|
|
const archives = [join(root, "left.zip"), join(root, "right.zip")] as const;
|
|
for (const [index, path] of archives.entries()) {
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("x.txt", new TextReader("first"));
|
|
await writer.add(
|
|
"y.txt",
|
|
new TextReader(index === 0 ? "second-left" : "second-right"),
|
|
);
|
|
await writer.add("neighbor.txt", new TextReader("neighbor"));
|
|
const bytes = Buffer.from(await writer.close());
|
|
// Produce a duplicate path the same way as real malformed/ambiguous ZIP
|
|
// inputs: retain each member record and rewrite only the equal-length name.
|
|
for (const [signature, headerSize] of [
|
|
[Buffer.from([0x50, 0x4b, 0x03, 0x04]), 30],
|
|
[Buffer.from([0x50, 0x4b, 0x01, 0x02]), 46],
|
|
] as const) {
|
|
const first = bytes.indexOf(signature);
|
|
const second = bytes.indexOf(signature, first + 4);
|
|
if (first < 0 || second <= first)
|
|
throw new Error("Expected both ZIP member headers");
|
|
expect(
|
|
bytes
|
|
.subarray(second + headerSize, second + headerSize + 5)
|
|
.toString(),
|
|
).toBe("y.txt");
|
|
bytes.write("x.txt", second + headerSize);
|
|
}
|
|
await writeFile(path, bytes);
|
|
}
|
|
|
|
const [left, right] = await Promise.all([
|
|
observe(archives[0]),
|
|
observe(archives[1]),
|
|
]);
|
|
const parsed = parseArtifactInventoryEvidence(left);
|
|
const repeated = parsed.inventory.occurrences.filter(
|
|
({ logical_path }) => logical_path === "x.txt",
|
|
);
|
|
expect(repeated).toHaveLength(2);
|
|
expect(
|
|
new Set(repeated.map(({ occurrence_id }) => occurrence_id)).size,
|
|
).toBe(2);
|
|
|
|
const leftPages = fragmentInventoryEvidence(left, 2);
|
|
const parsedPages = parseArtifactInventoryEvidence(leftPages);
|
|
expect(
|
|
parsedPages.inventory.occurrences.filter(
|
|
({ logical_path }) => logical_path === "x.txt",
|
|
),
|
|
).toHaveLength(2);
|
|
|
|
const comparison = compareArtifacts(
|
|
leftPages,
|
|
fragmentInventoryEvidence(right, 2),
|
|
);
|
|
expect(comparison).toMatchObject({
|
|
status: "unknown",
|
|
summary: { unknown: expect.any(Number) },
|
|
changes: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
logical_path: "x.txt",
|
|
classification: "unknown",
|
|
dimensions: ["availability"],
|
|
left_occurrence_id: null,
|
|
right_occurrence_id: null,
|
|
left_artifact_id: null,
|
|
right_artifact_id: null,
|
|
}),
|
|
]),
|
|
});
|
|
expect(comparison.limitations).toContain(
|
|
"Duplicate logical paths cannot be paired across inventories; comparisons for those paths are unknown.",
|
|
);
|
|
// Occurrences on the unknown duplicate path are never counted unchanged.
|
|
// The archives differ, so the root changes and only neighbor.txt remains.
|
|
expect(comparison.summary).toMatchObject({ unchanged: 1, unknown: 1 });
|
|
expect(compareArtifacts(left, left).summary).toMatchObject({
|
|
unchanged: 2,
|
|
unknown: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("artifact comparison fragment integrity", () => {
|
|
it("assembles real inventory fragments and retains every citation", async () => {
|
|
const parent = await createTestTempDirectory("rea-artifact-fragments-");
|
|
const leftPath = join(parent, "left.app");
|
|
const rightPath = join(parent, "right.app");
|
|
await Promise.all([mkdir(leftPath), mkdir(rightPath)]);
|
|
// Cross the former 100-citation limit with distinct, real graph members.
|
|
const fragmentCount = 101;
|
|
await Promise.all(
|
|
Array.from({ length: fragmentCount }, async (_, index) => {
|
|
await Promise.all([
|
|
writeFile(join(leftPath, `file-${index}.js`), `left(${index});`),
|
|
writeFile(join(rightPath, `file-${index}.js`), `right(${index});`),
|
|
]);
|
|
}),
|
|
);
|
|
const left = await observe(leftPath);
|
|
const right = await observe(rightPath);
|
|
const leftFragments = fragmentInventoryEvidence(left, fragmentCount);
|
|
const rightFragments = fragmentInventoryEvidence(right, fragmentCount);
|
|
const citations = [...leftFragments, ...rightFragments]
|
|
.map(({ evidence_id }) => evidence_id)
|
|
.sort();
|
|
expect(new Set(citations).size).toBe(fragmentCount * 2);
|
|
const comparison = compareArtifacts(leftFragments, rightFragments);
|
|
const complete = compareArtifacts(left, right);
|
|
expect(
|
|
comparison.changes
|
|
.filter(({ logical_path }) => logical_path !== ".")
|
|
.map(({ logical_path, classification }) => ({
|
|
logical_path,
|
|
classification,
|
|
}))
|
|
.sort((left, right) =>
|
|
left.logical_path.localeCompare(right.logical_path),
|
|
),
|
|
).toEqual(
|
|
Array.from({ length: fragmentCount }, (_, index) => ({
|
|
logical_path: `file-${index}.js`,
|
|
classification: "changed",
|
|
})).sort((left, right) =>
|
|
left.logical_path.localeCompare(right.logical_path),
|
|
),
|
|
);
|
|
expect({
|
|
...comparison,
|
|
changes: comparison.changes.map((change) => ({
|
|
...change,
|
|
evidence_links: [...change.evidence_links].sort(),
|
|
})),
|
|
}).toEqual({
|
|
...complete,
|
|
changes: complete.changes.map((change) => ({
|
|
...change,
|
|
evidence_links: citations,
|
|
})),
|
|
});
|
|
const fragment = leftFragments[0];
|
|
if (fragment === undefined) throw new Error("Expected inventory fragments");
|
|
const conflictingScope = createEvidence(
|
|
{
|
|
path: leftPath,
|
|
sha256: left.subject?.digest.sha256 ?? "",
|
|
format: "directory",
|
|
},
|
|
PROVIDER,
|
|
{
|
|
operation: "inventory_artifact",
|
|
parameters: {},
|
|
result: jsonValueSchema.parse({
|
|
...artifactInventoryResultSchema.parse(fragment.normalized_result),
|
|
coverage: { scope: "root", status: "complete" },
|
|
}),
|
|
},
|
|
);
|
|
expect(() =>
|
|
compareArtifacts(
|
|
[conflictingScope, ...leftFragments.slice(1)],
|
|
rightFragments,
|
|
),
|
|
).toThrow(/coverage declaration/u);
|
|
}, 15_000);
|
|
|
|
it("rejects non-inventory and tampered Evidence", async () => {
|
|
const root = await createTestTempDirectory("rea-artifact-invalid-");
|
|
const evidence = await observe(root);
|
|
expect(() =>
|
|
compareArtifacts({ ...evidence, operation: "binary_overview" }, evidence),
|
|
).toThrow(/identifier/u);
|
|
const wrongOperation = createEvidence(undefined, PROVIDER, {
|
|
operation: "binary_overview",
|
|
parameters: {},
|
|
result: evidence.normalized_result,
|
|
});
|
|
expect(() => compareArtifacts(wrongOperation, evidence)).toThrow(
|
|
/inspect_artifact/u,
|
|
);
|
|
const mismatchedSubject = createEvidence(
|
|
{ path: root, sha256: "f".repeat(64), format: "directory" },
|
|
PROVIDER,
|
|
{
|
|
operation: "inventory_artifact",
|
|
parameters: {},
|
|
result: evidence.normalized_result,
|
|
},
|
|
);
|
|
expect(() => compareArtifacts(mismatchedSubject, evidence)).toThrow(
|
|
/root digest/u,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ZIP non-entry coverage", () => {
|
|
it("retains pre-directory APK block changes under current ZIP coverage", async () => {
|
|
const root = await createTestTempDirectory("rea-apk-block-compare-");
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("classes.dex", new TextReader("unchanged entry"));
|
|
const zip = Buffer.from(await writer.close());
|
|
const eocd = zip.length - 22;
|
|
expect(zip.readUInt32LE(eocd)).toBe(0x06054b50);
|
|
const directory = zip.readUInt32LE(eocd + 16);
|
|
// APK Signing Block framing with one opaque padding pair, not a verified signature.
|
|
// https://source.android.com/docs/security/features/apksigning/v2#apk-signing-block
|
|
const block = Buffer.alloc(48);
|
|
block.writeBigUInt64LE(40n, 0);
|
|
block.writeBigUInt64LE(8n, 8);
|
|
block.writeUInt32LE(0x42726577, 16);
|
|
block.writeBigUInt64LE(40n, 24);
|
|
block.write("APK Sig Block 42", 32, "ascii");
|
|
const paths = [join(root, "before.apk"), join(root, "after.apk")];
|
|
for (const [index, path] of paths.entries()) {
|
|
block.writeUInt32LE(index, 20);
|
|
const apk = Buffer.concat([
|
|
zip.subarray(0, directory),
|
|
block,
|
|
zip.subarray(directory),
|
|
]);
|
|
apk.writeUInt32LE(directory + block.length, eocd + block.length + 16);
|
|
await writeFile(path, apk);
|
|
}
|
|
const left = await observe(paths[0]!);
|
|
const right = await observe(paths[1]!);
|
|
const inventory = artifactInventoryResultSchema.parse(
|
|
left.normalized_result,
|
|
);
|
|
expect(inventory.limitations).toContain(
|
|
"ZIP inventory lists central-directory entries only. Non-entry bytes, including leading bytes before the first entry such as a self-extractor stub, an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences.",
|
|
);
|
|
expect(
|
|
inventory.occurrences.map(({ logical_path }) => logical_path).sort(),
|
|
).toEqual([".", "classes.dex"]);
|
|
expect(compareArtifacts(left, right)).toMatchObject({
|
|
status: "changed",
|
|
summary: { changed: 1, unchanged: 1, unknown: 0 },
|
|
changes: [
|
|
expect.objectContaining({ logical_path: ".", dimensions: ["content"] }),
|
|
],
|
|
});
|
|
});
|
|
});
|