Compare commits

..
13 changed files with 136 additions and 472 deletions

No files matched your search

+25
View File
@@ -6,6 +6,31 @@ import {
} from "../config/runtimeAnalysisSettings.js";
import { parseConfig } from "../config/parseConfig.js";
it.each([
["inspect_native_api", [64, 7]],
["trace_native_ui_action", []],
] as const)(
"reports the decompiler budgets consumed by %s",
(operation, values) => {
const store = new AnalysisSettings({
REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS: "7",
REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB: "64",
});
expect(
store
.inspect({
provider: "ghidra",
operation,
settings: [
"ghidra.decompile_max_payload_mib",
"ghidra.decompile_timeout_seconds",
],
})
.settings.map(({ value }) => value),
).toEqual(values);
},
);
it("updates atomically, restores launch values, and refuses stale or invalid writes", () => {
const store = new AnalysisSettings({ REA_ASAR_MAX_HEADER_BYTES: "20000000" });
const inspect = () => store.inspect({ settings: ["asar.max_header_bytes"] });
@@ -260,11 +260,11 @@ describe("source-map artifact relationships", () => {
);
sourceMap.artifact_id = contentId("3");
const result = createArtifactEdges(
contentId("4"),
[directory, ...targets, sourceMap],
"complete",
);
const result = createArtifactEdges(contentId("4"), [
directory,
...targets,
sourceMap,
]);
const edge = result.edges.find(
({ occurrence_id }) => occurrence_id === sourceMap.occurrence_id,
);
@@ -298,11 +298,11 @@ describe("source-map artifact relationships", () => {
);
directory.artifact_id = contentId("3");
const { edges, limitations } = createArtifactEdges(
contentId("4"),
[archive, script, directory],
"complete",
);
const { edges, limitations } = createArtifactEdges(contentId("4"), [
archive,
script,
directory,
]);
expect(directory).toMatchObject({
artifact_kind: "container",
@@ -28,11 +28,7 @@ import {
occurrenceIdForLocation,
logicalPathNfc,
} from "../../domain/artifactIdentity.js";
import type {
ArtifactEntry,
ArtifactReader,
ArtifactReaderTraversal,
} from "../ArtifactReader.js";
import type { ArtifactEntry, ArtifactReader } from "../ArtifactReader.js";
import type {
ArtifactCommand,
ArtifactEdge,
@@ -60,9 +56,6 @@ export interface MutableOccurrence {
occurrence_id: string;
artifact_id: string | null;
parent_occurrence_id: string | null;
relationship_coverage?: NonNullable<
ArtifactOccurrence["relationship_coverage"]
>;
logical_path: string;
entry_kind: ArtifactOccurrence["entry_kind"];
artifact_kind: ArtifactOccurrence["artifact_kind"];
@@ -351,7 +344,6 @@ export const rekeyOccurrences = (
export const createArtifactEdges = (
rootArtifactId: string,
occurrences: MutableOccurrence[],
traversalStatus: ArtifactReaderTraversal["status"],
producer?: ArtifactCommand,
): { readonly edges: ArtifactEdge[]; readonly limitations: string[] } => {
const byId = new Map(occurrences.map((item) => [item.occurrence_id, item]));
@@ -359,17 +351,12 @@ export const createArtifactEdges = (
const edges: ArtifactEdge[] = [];
const limitations: string[] = [];
for (const occurrence of occurrences) {
let relationshipComplete =
occurrence.logical_path === "." ||
(occurrence.parent_occurrence_id !== null &&
occurrence.artifact_id !== null);
// Use the occurrence's own classification: a directory named `x.map` is
// a container, not a source map of its sibling.
const isSourceMap = occurrence.artifact_kind === "source-map";
const sourcePath =
isSourceMap && occurrence.logical_path !== "."
? occurrence.logical_path.slice(0, -".map".length)
: undefined;
const sourcePath = isSourceMap
? occurrence.logical_path.slice(0, -".map".length)
: undefined;
const sourceCandidates =
sourcePath === undefined ? [] : (byPath.get(sourcePath) ?? []);
const mappedSource =
@@ -394,15 +381,10 @@ export const createArtifactEdges = (
}
: undefined;
if (unknownMapping !== undefined) {
relationshipComplete = false;
occurrence.limitations.push(unknownMapping.occurrence);
limitations.push(unknownMapping.coverage);
}
if (traversalStatus === "unknown") relationshipComplete = false;
}
occurrence.relationship_coverage = relationshipComplete
? "complete"
: "unknown";
if (
occurrence.parent_occurrence_id === null ||
occurrence.artifact_id === null
@@ -442,17 +424,15 @@ interface ArtifactParentIndex {
occurrences?: readonly MutableOccurrence[];
}
export interface ArtifactParentResolution {
readonly limitations: readonly string[];
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
}
/** Resolve strict observed ancestors without rebuilding every path prefix. */
export const resolveOccurrenceParents = (
occurrences: readonly MutableOccurrence[],
byPath: ReadonlyMap<string, readonly MutableOccurrence[]>,
expandedContainerIds: ReadonlySet<string>,
): ArtifactParentResolution => {
): {
readonly limitations: readonly string[];
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
} => {
const root: ArtifactParentIndex = { children: new Map() };
for (const matches of byPath.values()) {
const parents = matches.filter(
+7 -16
View File
@@ -34,7 +34,6 @@ import {
materializeDirectoryNodes,
rekeyOccurrences,
rootOccurrenceFor,
type ArtifactParentResolution,
type MutableOccurrence,
} from "./ArtifactGraphConstruction.js";
import {
@@ -161,7 +160,7 @@ const scanCanonical = async (
pendingContradictions,
caseCollisionLimitation,
inesRegionScan,
parentResolution,
unknownParentOccurrences,
traversal,
rootContradiction,
} = await scanReader(
@@ -187,7 +186,7 @@ const scanCanonical = async (
pendingContradictions,
caseCollisionLimitation,
inesRegionScan,
parentResolution,
unknownParentOccurrences,
traversal,
rootContradiction,
recognitionLimitations,
@@ -221,10 +220,7 @@ const scanCanonical = async (
pendingContradictions: [],
caseCollisionLimitation: undefined,
inesRegionScan: undefined,
parentResolution: {
unknownParentOccurrences: new Set(),
limitations: [],
},
unknownParentOccurrences: new Set(),
rootContradiction: undefined,
traversal: {
status: "unknown",
@@ -427,7 +423,7 @@ interface SnapshotBuildInput {
readonly pendingContradictions: PendingIntegrityContradiction[];
readonly caseCollisionLimitation: string | undefined;
readonly inesRegionScan: InesRegionScan | undefined;
readonly parentResolution: ArtifactParentResolution;
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
readonly traversal: ArtifactReaderTraversal;
readonly rootContradiction: ArtifactContainerIntegrityPair | undefined;
}
@@ -455,7 +451,7 @@ const buildInventorySnapshot = async (
for (const occurrence of input.occurrences)
if (
occurrence.parent_occurrence_id === null &&
!input.parentResolution.unknownParentOccurrences.has(occurrence)
!input.unknownParentOccurrences.has(occurrence)
)
occurrence.parent_occurrence_id = rootOccurrence.occurrence_id;
input.occurrences.unshift(rootOccurrence);
@@ -486,7 +482,6 @@ const buildInventorySnapshot = async (
const edgeResult = createArtifactEdges(
rootNode.artifact_id,
input.occurrences,
input.traversal.status,
reader?.provenance()[0],
);
const { edges } = edgeResult;
@@ -533,12 +528,8 @@ const buildInventorySnapshot = async (
);
}
const traversal = inventoryCoverage(rootFormat, reader, input.traversal);
const relationshipLimitations = [
...input.parentResolution.limitations,
...edgeResult.limitations,
];
const coverage =
relationshipLimitations.length === 0
edgeResult.limitations.length === 0
? traversal.coverage
: { ...traversal.coverage, status: "unknown" as const };
return {
@@ -553,7 +544,7 @@ const buildInventorySnapshot = async (
limitations: buildLimitations(
[
...traversal.limitations,
...relationshipLimitations,
...edgeResult.limitations,
...(input.recognitionLimitations ?? []),
],
integrityContradictions,
+8 -7
View File
@@ -37,7 +37,6 @@ import {
noteUnreadContainerSuffix,
indexDestinationOccurrencesByPath,
resolveOccurrenceParents,
type ArtifactParentResolution,
type MutableOccurrence,
} from "./ArtifactGraphConstruction.js";
import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
@@ -90,7 +89,7 @@ export const scanReader = async (
readonly pendingContradictions: PendingIntegrityContradiction[];
readonly caseCollisionLimitation: string | undefined;
readonly inesRegionScan: InesRegionScan | undefined;
readonly parentResolution: ArtifactParentResolution;
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
readonly traversal: ArtifactReaderTraversal;
/** A recorded disagreement between the root's own checksum and its bytes. */
readonly rootContradiction: ArtifactContainerIntegrityPair | undefined;
@@ -103,10 +102,7 @@ export const scanReader = async (
pendingContradictions,
caseCollisionLimitation: undefined,
inesRegionScan: undefined,
parentResolution: {
unknownParentOccurrences: new Set(),
limitations: [],
},
unknownParentOccurrences: new Set(),
traversal: { status: "complete", limitations: [] },
rootContradiction: undefined,
};
@@ -138,6 +134,11 @@ export const scanReader = async (
byPath,
context.expandedContainerIds,
);
const structuralLimitations = [...parentResolution.limitations];
context.traversal.onCoverage({
status: structuralLimitations.length === 0 ? "complete" : "unknown",
limitations: structuralLimitations,
});
return {
nodes,
occurrences,
@@ -151,7 +152,7 @@ export const scanReader = async (
bytes: context.reconstruction.bytes,
sha256: context.reconstruction.hash.digest("hex"),
},
parentResolution,
unknownParentOccurrences: parentResolution.unknownParentOccurrences,
traversal: context.traversal.snapshot(),
rootContradiction,
};
@@ -37,7 +37,6 @@ const inventory = (digit: string) => {
}),
artifact_id: artifactId,
parent_occurrence_id: null,
relationship_coverage: "complete",
logical_path: ".",
entry_kind: "file",
artifact_kind: "resource",
+12 -14
View File
@@ -57,6 +57,16 @@ const timer = {
unit: "ms",
appliesAt: "next_operation",
} as const;
/** Operations that invoke Ghidra's decompiler directly or through a workflow. */
const ghidraDecompileOperations = [
"procedure_pseudo_code",
"analyze_function",
"inspect_native_api",
"decompile_all",
"trace_native_values",
] as const;
/** Metadata aggregates existing owner definitions; no environment mutation or engine discovery. */
export const ANALYSIS_SETTINGS = {
"ghidra.decompile_max_payload_mib": {
@@ -67,14 +77,7 @@ export const ANALYSIS_SETTINGS = {
unit: "MiB",
appliesAt: "next_operation",
provider: "ghidra",
operations: [
"procedure_pseudo_code",
"analyze_function",
"inspect_native_api",
"decompile_all",
"trace_native_values",
"trace_native_ui_action",
],
operations: ghidraDecompileOperations,
},
"asar.max_listing_bytes": {
environment: "REA_ASAR_MAX_LISTING_BYTES",
@@ -212,12 +215,7 @@ export const ANALYSIS_SETTINGS = {
unit: "seconds",
appliesAt: "next_operation",
provider: "ghidra",
operations: [
"procedure_pseudo_code",
"analyze_function",
"decompile_all",
"trace_native_values",
],
operations: ghidraDecompileOperations,
},
"jadx.operation_timeout_ms": {
...timer,
@@ -16,12 +16,11 @@ import {
import type { AnalysisViewParent } from "./analysisView.js";
const carrier = ARTIFACT_COMPARISON_EXAMPLE.left;
const freshInventory = (version: "current" | "historical" = "current") => {
const freshInventory = () => {
const inventory = artifactInventoryResultSchema.parse(
carrier.normalized_result,
);
const root = inventory.occurrences[0]!;
if (version === "historical") delete root.relationship_coverage;
const rootNode = inventory.nodes[0]!;
root.artifact_kind = "container";
root.artifact_format = "zip";
@@ -260,19 +259,6 @@ it("does not fabricate a selected field absent from the owning report", () => {
).toThrow();
});
it("reads absent historical relationship coverage as unknown without changing the retained row", () => {
const inventory = freezeJsonSnapshot(freshInventory("historical"));
expect(
page(parent(inventory), {
kind: "inventory",
collection: "occurrences",
fields: ["relationship_coverage"],
limit: 1,
}).items,
).toMatchObject([{ relationship_coverage: "unknown" }]);
expect(inventory.occurrences[0]).not.toHaveProperty("relationship_coverage");
});
it("borrows authenticated immutable facts and validates once across retained queries", () => {
const inventory = freezeJsonSnapshot(freshInventory());
const source = parent(inventory);
@@ -3,7 +3,6 @@ import {
artifactInventoryCoverageSchema,
artifactNodeSchema,
artifactOccurrenceSchema,
artifactOccurrenceRelationshipCoverage,
artifactEdgeSchema,
integrityContradictionSchema,
} from "../artifactGraph.js";
@@ -575,12 +574,6 @@ const selectOccurrenceFields = (
? row
: {
...selectFields(row, fields),
...(fields.includes("relationship_coverage")
? {
relationship_coverage:
artifactOccurrenceRelationshipCoverage(row),
}
: {}),
occurrence_id: row.occurrence_id,
artifact_id: row.artifact_id,
parent_occurrence_id: row.parent_occurrence_id,
+15 -209
View File
@@ -11,32 +11,12 @@ import {
type ArtifactOccurrence,
} from "./artifactGraph.js";
import {
artifactEdgeId,
artifactGraphDigest,
artifactIdForContent,
artifactManifestId,
occurrenceIdForLocation,
} from "./artifactIdentity.js";
import { createEvidence } from "./evidence.js";
import { parseArtifactInventoryEvidence } from "./artifactInventoryEvidence.js";
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
const inventoryCarrier = (inventory: ArtifactInventoryResult) =>
createEvidence(
{
path: "packing-observation",
sha256: inventory.manifest.root_sha256,
format: inventory.manifest.root_format,
},
ARTIFACT_COMPARISON_EXAMPLE.left.provider,
{
operation: "inventory_artifact",
parameters: {},
result: inventory,
confidence: "observed",
authority: "shipped-artifact",
},
);
/** Recommit observation variants so comparisons exercise valid Evidence carriers. */
const commit = (inventory: ArtifactInventoryResult) => {
@@ -51,196 +31,23 @@ const commit = (inventory: ArtifactInventoryResult) => {
inventory.manifest.root_artifact_id,
graphSha256,
);
return inventoryCarrier(inventory);
return createEvidence(
{
path: "packing-observation",
sha256: inventory.manifest.root_sha256,
format: inventory.manifest.root_format,
},
ARTIFACT_COMPARISON_EXAMPLE.left.provider,
{
operation: "inventory_artifact",
parameters: {},
result: inventory,
confidence: "observed",
authority: "shipped-artifact",
},
);
};
const relationInventory = (
secondContent = "2",
firstRelation: ArtifactInventoryResult["edges"][number]["relation"] = "contains",
) => {
const inventory = artifactInventoryResultSchema.parse(
ARTIFACT_COMPARISON_EXAMPLE.left.normalized_result,
);
const root = inventory.occurrences[0]!;
const rootNode = inventory.nodes[0]!;
const rootId = inventory.manifest.root_artifact_id;
const members = [
{ path: "one.js", digit: "1", relation: firstRelation },
{ path: "two.js", digit: secondContent, relation: "contains" },
] as const;
for (const [ordinal, { path, digit, relation }] of members.entries()) {
const sha256 = digit.repeat(64);
const artifactId = artifactIdForContent(sha256);
const occurrence: ArtifactOccurrence = {
...root,
occurrence_id: occurrenceIdForLocation({
rootArtifactId: rootId,
logicalPath: path,
entryKind: "file",
}),
artifact_id: artifactId,
parent_occurrence_id: root.occurrence_id,
logical_path: path,
};
const edge = {
ordinal,
parent_artifact_id: rootId,
relation,
occurrence_id: occurrence.occurrence_id,
producer: null,
};
inventory.nodes.push({ ...rootNode, artifact_id: artifactId, sha256 });
inventory.occurrences.push(occurrence);
inventory.edges.push({
...edge,
edge_id: artifactEdgeId(edge, occurrence),
});
}
inventory.nodes.sort((left, right) =>
compareUnicodeCodePoints(left.artifact_id, right.artifact_id),
);
inventory.edges.sort((left, right) =>
compareUnicodeCodePoints(left.edge_id, right.edge_id),
);
inventory.coverage = { scope: "container-entries", status: "complete" };
inventory.manifest.node_count = inventory.nodes.length;
inventory.manifest.occurrence_count = inventory.occurrences.length;
inventory.manifest.edge_count = inventory.edges.length;
return { inventory, evidence: commit(inventory) };
};
it.each(["left", "right"] as const)(
"does not infer a relation change from an omitted edge on the %s",
(partialSide) => {
const { inventory, evidence } = relationInventory();
const omitted = inventory.occurrences.find(
({ logical_path }) => logical_path === "one.js",
)!;
const page = inventoryCarrier({
...inventory,
edges: inventory.edges.filter(
({ occurrence_id }) => occurrence_id !== omitted.occurrence_id,
),
});
const result =
partialSide === "left"
? compareArtifacts(page, evidence)
: compareArtifacts(evidence, page);
expect(result).toMatchObject({
status: "truncated",
summary: { changed: 0 },
changes: expect.arrayContaining([
expect.objectContaining({
logical_path: "one.js",
classification: "unknown",
dimensions: ["availability"],
}),
]),
});
expect(compareArtifacts(evidence, evidence)).toMatchObject({
status: "unchanged",
changes: [],
});
},
);
it("keeps a proven content change with incomplete relation observations", () => {
const left = relationInventory().evidence;
const { inventory } = relationInventory("3");
const result = compareArtifacts(
left,
inventoryCarrier({ ...inventory, edges: inventory.edges.slice(0, 1) }),
);
expect(result).toMatchObject({
status: "truncated",
changes: expect.arrayContaining([
expect.objectContaining({
logical_path: "two.js",
classification: "changed",
dimensions: ["content", "availability"],
}),
]),
});
});
it("does not infer a relation change when a retained edge's logical parent is omitted", () => {
const { inventory, evidence } = relationInventory();
const result = compareArtifacts(
evidence,
inventoryCarrier({
...inventory,
occurrences: inventory.occurrences.slice(1),
}),
);
expect(result).toMatchObject({
status: "truncated",
summary: { changed: 0 },
changes: expect.arrayContaining([
expect.objectContaining({
logical_path: "one.js",
classification: "unknown",
dimensions: ["availability"],
}),
]),
});
});
it("compares complete relations when an unrelated node is omitted", () => {
const left = relationInventory().evidence;
const { inventory } = relationInventory("2", "embeds");
const unrelated = inventory.occurrences.find(
({ logical_path }) => logical_path === "two.js",
)!;
const result = compareArtifacts(
left,
inventoryCarrier({
...inventory,
nodes: inventory.nodes.filter(
({ artifact_id }) => artifact_id !== unrelated.artifact_id,
),
}),
);
expect(result).toMatchObject({
status: "truncated",
changes: expect.arrayContaining([
expect.objectContaining({
logical_path: "one.js",
classification: "changed",
dimensions: ["relations"],
}),
]),
});
});
it("keeps a historical edge and its Evidence while reporting omitted coverage as unknown", () => {
const { inventory, evidence } = relationInventory();
const oldInventory = structuredClone(inventory);
const occurrence = oldInventory.occurrences.find(
({ logical_path }) => logical_path === "one.js",
)!;
delete occurrence.relationship_coverage;
const historical = commit(oldInventory);
const parsed = parseArtifactInventoryEvidence(historical);
expect(parsed.evidence[0]).toEqual(historical);
expect(parsed.inventory.manifest).toEqual(oldInventory.manifest);
expect(parsed.inventory.edges).toEqual(oldInventory.edges);
expect(
parsed.inventory.occurrences.find(
({ occurrence_id }) => occurrence_id === occurrence.occurrence_id,
),
).not.toHaveProperty("relationship_coverage");
expect(compareArtifacts(historical, evidence)).toMatchObject({
status: "unknown",
changes: [
{
logical_path: "one.js",
classification: "unknown",
dimensions: ["availability"],
},
],
});
});
const observation = (
facts: Pick<ArtifactOccurrence, "hash_status"> & Partial<ArtifactOccurrence>,
changedContent = false,
@@ -496,7 +303,6 @@ const recordObservation = (
}),
artifact_id: artifactId,
parent_occurrence_id: root.occurrence_id,
relationship_coverage: artifactId === null ? "unknown" : "complete",
logical_path: "record",
entry_kind: "metadata",
artifact_kind: "package-metadata",
+45 -58
View File
@@ -1,11 +1,10 @@
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
import { z } from "zod";
import {
artifactOccurrenceRelationshipCoverage,
type ArtifactInventoryResult,
type ArtifactNode,
type ArtifactOccurrence,
import type {
ArtifactInventoryResult,
ArtifactNode,
ArtifactOccurrence,
} from "./artifactGraph.js";
import { AnalysisInputError } from "./analysisErrorCore.js";
import {
@@ -257,8 +256,8 @@ const compareOccurrences = (
const rightNodes = new Map(
right.nodes.map((item) => [item.artifact_id, item]),
);
const leftRelationsFor = relationsForOccurrence(left);
const rightRelationsFor = relationsForOccurrence(right);
const leftRelations = relationsByPath(left);
const rightRelations = relationsByPath(right);
const paths = [
...new Set([...leftByPath.keys(), ...rightByPath.keys()]),
].sort(compareUnicodeCodePoints);
@@ -303,10 +302,8 @@ const compareOccurrences = (
rightOccurrence,
leftNode: nodeFor(leftOccurrence, leftNodes),
rightNode: nodeFor(rightOccurrence, rightNodes),
relations: compareRelations(
leftRelationsFor(leftOccurrence),
rightRelationsFor(rightOccurrence),
),
leftRelations: leftRelations.get(path) ?? [],
rightRelations: rightRelations.get(path) ?? [],
rootCovered: coverage.root,
entriesCovered: coverage.entries,
evidenceLinks,
@@ -341,7 +338,8 @@ interface ClassifyPathInput {
readonly rightOccurrence: ArtifactOccurrence | undefined;
readonly leftNode: ArtifactNode | undefined;
readonly rightNode: ArtifactNode | undefined;
readonly relations: FactComparison;
readonly leftRelations: readonly RelationProjection[];
readonly rightRelations: readonly RelationProjection[];
readonly rootCovered: boolean;
readonly entriesCovered: boolean;
readonly evidenceLinks: readonly string[];
@@ -422,7 +420,7 @@ const classifyPath = (input: ClassifyPathInput): ArtifactChange | null => {
if (known.kind) dimensions.push("kind");
if (known.format) dimensions.push("format");
if (known.executable) dimensions.push("executable");
if (input.relations === "changed") dimensions.push("relations");
if (known.relations) dimensions.push("relations");
if (known.metadata || declaredDigestsDiffer(input))
dimensions.push("metadata");
dimensions.push("availability");
@@ -440,7 +438,6 @@ const classifyPath = (input: ClassifyPathInput): ArtifactChange | null => {
declarationUnknown(input.metadata) ||
occurrence.kindUnknown ||
occurrence.formatUnknown ||
input.relations === "unknown" ||
differsWithUnknown(input.leftNode?.format, input.rightNode?.format) ||
differsWithMissing(
input.leftNode?.media_type,
@@ -469,17 +466,20 @@ type OccurrencePair = Pick<
"leftOccurrence" | "rightOccurrence"
>;
type FactComparison = "unchanged" | "changed" | "unknown";
type MetadataComparison = "unchanged" | "changed" | "unknown";
interface OccurrenceMetadata {
/** ASAR storage is an occurrence field, not format metadata. */
readonly asarPacking: FactComparison;
readonly asarPacking: MetadataComparison;
/** Every occurrence records `encrypted`, so a pair's comparison is known. */
readonly encryption: Exclude<FactComparison, "unknown">;
readonly encryption: Exclude<MetadataComparison, "unknown">;
/** Absent when neither occurrence records a compared declaration. */
readonly formatDeclaration: FactComparison | "absent";
readonly formatDeclaration: MetadataComparison | "absent";
/** Unknown when either side could not read a compared field. */
readonly formatDeclarationAvailability: Exclude<FactComparison, "changed">;
readonly formatDeclarationAvailability: Exclude<
MetadataComparison,
"changed"
>;
}
/** A declaration that differs even when content is unavailable. */
@@ -513,6 +513,8 @@ const occurrenceDifferences = (input: {
readonly metadata: OccurrenceMetadata;
readonly leftOccurrence: ArtifactOccurrence | undefined;
readonly rightOccurrence: ArtifactOccurrence | undefined;
readonly leftRelations: readonly RelationProjection[];
readonly rightRelations: readonly RelationProjection[];
}) => ({
kind:
input.leftOccurrence?.artifact_kind !==
@@ -538,15 +540,24 @@ const occurrenceDifferences = (input: {
),
executable:
input.leftOccurrence?.executable !== input.rightOccurrence?.executable,
// A missing content identity prevents inventory from emitting its edges.
// An empty projection in that case is unknown, not evidence of no relation.
relations:
input.leftOccurrence?.artifact_id != null &&
input.rightOccurrence?.artifact_id != null &&
JSON.stringify(input.leftRelations) !==
JSON.stringify(input.rightRelations),
metadata:
declaredFactChanged(input.metadata) ||
input.leftOccurrence?.entry_kind !== input.rightOccurrence?.entry_kind,
input.leftOccurrence?.entry_kind !== input.rightOccurrence?.entry_kind ||
JSON.stringify(input.leftOccurrence?.limitations) !==
JSON.stringify(input.rightOccurrence?.limitations),
});
const compareAsarPacking = (
left: boolean | undefined,
right: boolean | undefined,
): FactComparison =>
): MetadataComparison =>
left === right
? "unchanged"
: left === undefined || right === undefined
@@ -671,7 +682,8 @@ const changedDimensions = (input: {
readonly rightNode: ArtifactNode | undefined;
readonly leftOccurrence: ArtifactOccurrence | undefined;
readonly rightOccurrence: ArtifactOccurrence | undefined;
readonly relations: FactComparison;
readonly leftRelations: readonly RelationProjection[];
readonly rightRelations: readonly RelationProjection[];
}): z.infer<typeof comparisonDimensionSchema>[] => {
const left = input.leftNode;
const right = input.rightNode;
@@ -691,7 +703,7 @@ const changedDimensions = (input: {
],
["size", left.size !== right.size],
["executable", occurrence.executable],
["relations", input.relations === "changed"],
["relations", occurrence.relations],
["metadata", occurrence.metadata || metadataChanged(left, right)],
];
return checks
@@ -704,7 +716,8 @@ const metadataChanged = (left: ArtifactNode, right: ArtifactNode): boolean =>
!differsWithMissing(left.media_type, right.media_type)) ||
(left.architecture !== right.architecture &&
!differsWithMissing(left.architecture, right.architecture)) ||
left.content_state !== right.content_state;
left.content_state !== right.content_state ||
JSON.stringify(left.limitations) !== JSON.stringify(right.limitations);
const nodeFor = (
occurrence: ArtifactOccurrence | undefined,
@@ -714,16 +727,10 @@ const nodeFor = (
? undefined
: nodes.get(occurrence.artifact_id);
/** An empty projection means observed absence. Missing edges or parent facts leave it unavailable. */
const relationsForOccurrence = (
const relationsByPath = (
inventory: InventorySet,
): ((
occurrence: ArtifactOccurrence | undefined,
) => readonly RelationProjection[] | undefined) => {
if (inventory.edges.length !== inventory.manifest.edge_count)
return () => undefined;
): ReadonlyMap<string, readonly RelationProjection[]> => {
const output = new Map<string, RelationProjection[]>();
const unresolvedPaths = new Set<string>();
const occurrences = new Map(
inventory.occurrences.map((item) => [item.occurrence_id, item]),
);
@@ -732,17 +739,13 @@ const relationsForOccurrence = (
// An incomplete inventory can omit the occurrence that names the child.
if (occurrence?.artifact_id === null || occurrence === undefined) continue;
const path = occurrence.logical_path;
const parent =
occurrence.parent_occurrence_id === null
? null
: occurrences.get(occurrence.parent_occurrence_id);
if (parent === undefined) {
unresolvedPaths.add(path);
continue;
}
const values = output.get(path);
const projection: RelationProjection = {
parent_logical_path: parent?.logical_path ?? null,
parent_logical_path:
occurrence.parent_occurrence_id === null
? null
: (occurrences.get(occurrence.parent_occurrence_id)?.logical_path ??
null),
// Containment roots are path-normalized to avoid graph-wide deltas, but
// a source map's target is a semantic content identity, not its parent
// directory. Preserve that identity in the relation comparison.
@@ -760,23 +763,7 @@ const relationsForOccurrence = (
values.sort((left, right) =>
compareUnicodeCodePoints(canonicalJson(left), canonicalJson(right)),
);
return (occurrence) => {
if (occurrence?.artifact_id == null) return undefined;
if (artifactOccurrenceRelationshipCoverage(occurrence) === "unknown")
return undefined;
const path = occurrence.logical_path;
return unresolvedPaths.has(path) ? undefined : (output.get(path) ?? []);
};
};
const compareRelations = (
left: readonly RelationProjection[] | undefined,
right: readonly RelationProjection[] | undefined,
): FactComparison => {
if (left === undefined || right === undefined) return "unknown";
return JSON.stringify(left) === JSON.stringify(right)
? "unchanged"
: "changed";
return output;
};
interface RelationProjection {
-8
View File
@@ -173,8 +173,6 @@ export const artifactOccurrenceSchema = z.object({
occurrence_id: occurrenceIdSchema,
artifact_id: artifactIdSchema.nullable(),
parent_occurrence_id: occurrenceIdSchema.nullable(),
/** Producer coverage of this occurrence's modeled relationships. Historical omission is unknown. */
relationship_coverage: z.enum(["complete", "unknown"]).exactOptional(),
logical_path: boundedRelativePathSchema,
entry_kind: z.enum(["file", "directory", "symlink", "slice", "metadata"]),
artifact_kind: artifactKindSchema,
@@ -319,12 +317,6 @@ export type ArtifactEdge = z.infer<typeof artifactEdgeSchema>;
/** Parsed artifact occurrence. */
export type ArtifactOccurrence = z.infer<typeof artifactOccurrenceSchema>;
/** Read relationship coverage without changing authenticated historical rows. */
export const artifactOccurrenceRelationshipCoverage = (
occurrence: ArtifactOccurrence,
) => occurrence.relationship_coverage ?? "unknown";
export type IntegrityContradiction = z.infer<
typeof integrityContradictionSchema
>;
@@ -570,45 +570,6 @@ describe("artifact comparison completeness", () => {
}, 15_000);
});
it.runIf(process.platform !== "win32" && process.getuid?.() !== 0)(
"retains an observed map edge without certifying completeness after an omitted subtree",
async () => {
const root = await createTestTempDirectory("rea-map-omitted-subtree-");
const omitted = join(root, "omitted");
await mkdir(omitted);
await Promise.all([
writeFile(join(root, "main.js"), "source();"),
writeFile(join(root, "main.js.map"), "{}"),
writeFile(join(omitted, "member.txt"), "unexamined member"),
]);
const complete = await observe(root);
await chmod(omitted, 0);
const partial = await observe(root).finally(() => chmod(omitted, 0o700));
const { inventory } = parseArtifactInventoryEvidence(partial);
const map = inventory.occurrences.find(
({ logical_path }) => logical_path === "main.js.map",
);
expect(inventory.coverage.status).toBe("unknown");
expect(inventory.occurrences).not.toContainEqual(
expect.objectContaining({ logical_path: "omitted/member.txt" }),
);
expect(inventory.edges).toContainEqual(
expect.objectContaining({
occurrence_id: map?.occurrence_id,
relation: "maps-source",
}),
);
expect(map).toHaveProperty("relationship_coverage", "unknown");
expect(compareArtifacts(complete, partial).changes).toContainEqual(
expect.objectContaining({
logical_path: "main.js.map",
classification: "unknown",
dimensions: ["availability"],
}),
);
},
);
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-");
@@ -656,18 +617,11 @@ 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 uniquePath = join(root, "unique-source-map.zip");
const zip = async (otherSource: string) => {
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 });
await writer.add("other.js", new TextReader(otherSource), { level: 0 });
await writer.add("other.js.map", new TextReader("{}"), { level: 0 });
return Buffer.from(await writer.close());
};
const bytes = await zip("before();");
await writeFile(uniquePath, await zip("after();"));
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],
@@ -706,29 +660,6 @@ describe("artifact source-map relationship coverage", () => {
limitation.toLowerCase().includes("integrity"),
),
).toBe(false);
const unique = await observe(uniquePath);
for (const [left, right] of [
[unique, evidence],
[evidence, unique],
] as const) {
expect(compareArtifacts(left, right).changes).toEqual(
expect.arrayContaining([
expect.objectContaining({
logical_path: "main.js.map",
classification: "unknown",
dimensions: ["availability"],
left_artifact_id: map.artifact_id,
right_artifact_id: map.artifact_id,
}),
expect.objectContaining({
logical_path: "other.js.map",
classification: "changed",
dimensions: ["relations"],
}),
]),
);
}
expect(map).toHaveProperty("relationship_coverage", "unknown");
});
});
@@ -738,15 +669,11 @@ describe("duplicate archive occurrence ownership", () => {
"rea-artifact-duplicate-directory-",
);
const path = join(root, "duplicate-directories.zip");
const uniquePath = join(root, "unique-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"));
await writer.add("standalone.js", new TextReader("source();"));
await writer.add("standalone.js.map", new TextReader("{}"));
const bytes = Buffer.from(await writer.close());
await writeFile(uniquePath, bytes);
for (const [signature, headerSize] of [
[Buffer.from([0x50, 0x4b, 0x03, 0x04]), 30],
[Buffer.from([0x50, 0x4b, 0x01, 0x02]), 46],
@@ -779,27 +706,6 @@ describe("duplicate archive occurrence ownership", () => {
'The parent occurrence for "x/file.txt" is unknown because multiple observed directory or container occurrences share "x".',
);
expect(inventory.coverage.status).toBe("unknown");
const unique = await observe(uniquePath);
for (const [left, right] of [
[unique, evidence],
[evidence, unique],
] as const)
expect(compareArtifacts(left, right).changes).toContainEqual(
expect.objectContaining({
logical_path: "x/file.txt",
classification: "unknown",
dimensions: ["availability"],
left_artifact_id: child?.artifact_id,
right_artifact_id: child?.artifact_id,
}),
);
expect(child).toHaveProperty("relationship_coverage", "unknown");
expect(inventory.occurrences).toContainEqual(
expect.objectContaining({
logical_path: "standalone.js.map",
relationship_coverage: "complete",
}),
);
});
it("retains duplicate path occurrences across pages and leaves pairing unknown", async () => {