mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
966 lines
35 KiB
TypeScript
966 lines
35 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: 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: {
|
|
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: 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: {
|
|
...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: {
|
|
...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: {
|
|
...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: {
|
|
...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("reports when an unchanged map points to changed source content", async () => {
|
|
const root = await createTestTempDirectory("rea-map-target-change-");
|
|
const leftPath = join(root, "left");
|
|
const rightPath = join(root, "right");
|
|
await Promise.all([mkdir(leftPath), mkdir(rightPath)]);
|
|
await Promise.all([
|
|
writeFile(join(leftPath, "main.js"), "/* first source */\n"),
|
|
writeFile(join(rightPath, "main.js"), "/* second source */\n"),
|
|
writeFile(
|
|
join(leftPath, "main.js.map"),
|
|
'{"version":3,"sources":[],"names":[],"mappings":""}',
|
|
),
|
|
writeFile(
|
|
join(rightPath, "main.js.map"),
|
|
'{"version":3,"sources":[],"names":[],"mappings":""}',
|
|
),
|
|
]);
|
|
const [left, right] = await Promise.all([
|
|
observe(leftPath),
|
|
observe(rightPath),
|
|
]);
|
|
|
|
const comparison = compareArtifacts(left, right);
|
|
const leftInventory = parseArtifactInventoryEvidence(left).inventory;
|
|
const rightInventory = parseArtifactInventoryEvidence(right).inventory;
|
|
const leftMap = leftInventory.occurrences.find(
|
|
({ logical_path }) => logical_path === "main.js.map",
|
|
);
|
|
const rightMap = rightInventory.occurrences.find(
|
|
({ logical_path }) => logical_path === "main.js.map",
|
|
);
|
|
expect(leftMap?.artifact_id).toBe(rightMap?.artifact_id);
|
|
expect(comparison.changes).toContainEqual(
|
|
expect.objectContaining({
|
|
logical_path: "main.js.map",
|
|
classification: "changed",
|
|
dimensions: ["relations"],
|
|
left_artifact_id: leftMap?.artifact_id,
|
|
right_artifact_id: rightMap?.artifact_id,
|
|
}),
|
|
);
|
|
});
|
|
|
|
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: {
|
|
...artifactInventoryResultSchema.parse(fragment.normalized_result),
|
|
coverage: { scope: "root", status: "complete" },
|
|
},
|
|
},
|
|
);
|
|
expect(() =>
|
|
compareArtifacts(
|
|
[conflictingScope, ...leftFragments.slice(1)],
|
|
rightFragments,
|
|
),
|
|
).toThrowError(
|
|
expect.objectContaining({
|
|
_tag: "AnalysisInputError",
|
|
userMessage: expect.stringContaining("coverage declaration"),
|
|
issues: [expect.objectContaining({ path: ["left"] })],
|
|
}),
|
|
);
|
|
}, 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)).toThrowError(
|
|
expect.objectContaining({
|
|
_tag: "AnalysisInputError",
|
|
userMessage: expect.stringContaining("inspect_artifact"),
|
|
issues: [expect.objectContaining({ path: ["left"] })],
|
|
}),
|
|
);
|
|
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"] }),
|
|
],
|
|
});
|
|
});
|
|
});
|