mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 17:30:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
452ed97031 | ||
|
|
c837575512 | ||
|
|
cdb51058f6 |
No files matched your search
@@ -260,11 +260,11 @@ describe("source-map artifact relationships", () => {
|
|||||||
);
|
);
|
||||||
sourceMap.artifact_id = contentId("3");
|
sourceMap.artifact_id = contentId("3");
|
||||||
|
|
||||||
const result = createArtifactEdges(
|
const result = createArtifactEdges(contentId("4"), [
|
||||||
contentId("4"),
|
directory,
|
||||||
[directory, ...targets, sourceMap],
|
...targets,
|
||||||
"complete",
|
sourceMap,
|
||||||
);
|
]);
|
||||||
const edge = result.edges.find(
|
const edge = result.edges.find(
|
||||||
({ occurrence_id }) => occurrence_id === sourceMap.occurrence_id,
|
({ occurrence_id }) => occurrence_id === sourceMap.occurrence_id,
|
||||||
);
|
);
|
||||||
@@ -298,11 +298,11 @@ describe("source-map artifact relationships", () => {
|
|||||||
);
|
);
|
||||||
directory.artifact_id = contentId("3");
|
directory.artifact_id = contentId("3");
|
||||||
|
|
||||||
const { edges, limitations } = createArtifactEdges(
|
const { edges, limitations } = createArtifactEdges(contentId("4"), [
|
||||||
contentId("4"),
|
archive,
|
||||||
[archive, script, directory],
|
script,
|
||||||
"complete",
|
directory,
|
||||||
);
|
]);
|
||||||
|
|
||||||
expect(directory).toMatchObject({
|
expect(directory).toMatchObject({
|
||||||
artifact_kind: "container",
|
artifact_kind: "container",
|
||||||
|
|||||||
@@ -28,11 +28,7 @@ import {
|
|||||||
occurrenceIdForLocation,
|
occurrenceIdForLocation,
|
||||||
logicalPathNfc,
|
logicalPathNfc,
|
||||||
} from "../../domain/artifactIdentity.js";
|
} from "../../domain/artifactIdentity.js";
|
||||||
import type {
|
import type { ArtifactEntry, ArtifactReader } from "../ArtifactReader.js";
|
||||||
ArtifactEntry,
|
|
||||||
ArtifactReader,
|
|
||||||
ArtifactReaderTraversal,
|
|
||||||
} from "../ArtifactReader.js";
|
|
||||||
import type {
|
import type {
|
||||||
ArtifactCommand,
|
ArtifactCommand,
|
||||||
ArtifactEdge,
|
ArtifactEdge,
|
||||||
@@ -60,9 +56,6 @@ export interface MutableOccurrence {
|
|||||||
occurrence_id: string;
|
occurrence_id: string;
|
||||||
artifact_id: string | null;
|
artifact_id: string | null;
|
||||||
parent_occurrence_id: string | null;
|
parent_occurrence_id: string | null;
|
||||||
relationship_coverage?: NonNullable<
|
|
||||||
ArtifactOccurrence["relationship_coverage"]
|
|
||||||
>;
|
|
||||||
logical_path: string;
|
logical_path: string;
|
||||||
entry_kind: ArtifactOccurrence["entry_kind"];
|
entry_kind: ArtifactOccurrence["entry_kind"];
|
||||||
artifact_kind: ArtifactOccurrence["artifact_kind"];
|
artifact_kind: ArtifactOccurrence["artifact_kind"];
|
||||||
@@ -351,7 +344,6 @@ export const rekeyOccurrences = (
|
|||||||
export const createArtifactEdges = (
|
export const createArtifactEdges = (
|
||||||
rootArtifactId: string,
|
rootArtifactId: string,
|
||||||
occurrences: MutableOccurrence[],
|
occurrences: MutableOccurrence[],
|
||||||
traversalStatus: ArtifactReaderTraversal["status"],
|
|
||||||
producer?: ArtifactCommand,
|
producer?: ArtifactCommand,
|
||||||
): { readonly edges: ArtifactEdge[]; readonly limitations: string[] } => {
|
): { readonly edges: ArtifactEdge[]; readonly limitations: string[] } => {
|
||||||
const byId = new Map(occurrences.map((item) => [item.occurrence_id, item]));
|
const byId = new Map(occurrences.map((item) => [item.occurrence_id, item]));
|
||||||
@@ -359,17 +351,12 @@ export const createArtifactEdges = (
|
|||||||
const edges: ArtifactEdge[] = [];
|
const edges: ArtifactEdge[] = [];
|
||||||
const limitations: string[] = [];
|
const limitations: string[] = [];
|
||||||
for (const occurrence of occurrences) {
|
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
|
// Use the occurrence's own classification: a directory named `x.map` is
|
||||||
// a container, not a source map of its sibling.
|
// a container, not a source map of its sibling.
|
||||||
const isSourceMap = occurrence.artifact_kind === "source-map";
|
const isSourceMap = occurrence.artifact_kind === "source-map";
|
||||||
const sourcePath =
|
const sourcePath = isSourceMap
|
||||||
isSourceMap && occurrence.logical_path !== "."
|
? occurrence.logical_path.slice(0, -".map".length)
|
||||||
? occurrence.logical_path.slice(0, -".map".length)
|
: undefined;
|
||||||
: undefined;
|
|
||||||
const sourceCandidates =
|
const sourceCandidates =
|
||||||
sourcePath === undefined ? [] : (byPath.get(sourcePath) ?? []);
|
sourcePath === undefined ? [] : (byPath.get(sourcePath) ?? []);
|
||||||
const mappedSource =
|
const mappedSource =
|
||||||
@@ -394,15 +381,10 @@ export const createArtifactEdges = (
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
if (unknownMapping !== undefined) {
|
if (unknownMapping !== undefined) {
|
||||||
relationshipComplete = false;
|
|
||||||
occurrence.limitations.push(unknownMapping.occurrence);
|
occurrence.limitations.push(unknownMapping.occurrence);
|
||||||
limitations.push(unknownMapping.coverage);
|
limitations.push(unknownMapping.coverage);
|
||||||
}
|
}
|
||||||
if (traversalStatus === "unknown") relationshipComplete = false;
|
|
||||||
}
|
}
|
||||||
occurrence.relationship_coverage = relationshipComplete
|
|
||||||
? "complete"
|
|
||||||
: "unknown";
|
|
||||||
if (
|
if (
|
||||||
occurrence.parent_occurrence_id === null ||
|
occurrence.parent_occurrence_id === null ||
|
||||||
occurrence.artifact_id === null
|
occurrence.artifact_id === null
|
||||||
@@ -442,17 +424,15 @@ interface ArtifactParentIndex {
|
|||||||
occurrences?: readonly MutableOccurrence[];
|
occurrences?: readonly MutableOccurrence[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArtifactParentResolution {
|
|
||||||
readonly limitations: readonly string[];
|
|
||||||
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve strict observed ancestors without rebuilding every path prefix. */
|
/** Resolve strict observed ancestors without rebuilding every path prefix. */
|
||||||
export const resolveOccurrenceParents = (
|
export const resolveOccurrenceParents = (
|
||||||
occurrences: readonly MutableOccurrence[],
|
occurrences: readonly MutableOccurrence[],
|
||||||
byPath: ReadonlyMap<string, readonly MutableOccurrence[]>,
|
byPath: ReadonlyMap<string, readonly MutableOccurrence[]>,
|
||||||
expandedContainerIds: ReadonlySet<string>,
|
expandedContainerIds: ReadonlySet<string>,
|
||||||
): ArtifactParentResolution => {
|
): {
|
||||||
|
readonly limitations: readonly string[];
|
||||||
|
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
|
||||||
|
} => {
|
||||||
const root: ArtifactParentIndex = { children: new Map() };
|
const root: ArtifactParentIndex = { children: new Map() };
|
||||||
for (const matches of byPath.values()) {
|
for (const matches of byPath.values()) {
|
||||||
const parents = matches.filter(
|
const parents = matches.filter(
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
materializeDirectoryNodes,
|
materializeDirectoryNodes,
|
||||||
rekeyOccurrences,
|
rekeyOccurrences,
|
||||||
rootOccurrenceFor,
|
rootOccurrenceFor,
|
||||||
type ArtifactParentResolution,
|
|
||||||
type MutableOccurrence,
|
type MutableOccurrence,
|
||||||
} from "./ArtifactGraphConstruction.js";
|
} from "./ArtifactGraphConstruction.js";
|
||||||
import {
|
import {
|
||||||
@@ -161,7 +160,7 @@ const scanCanonical = async (
|
|||||||
pendingContradictions,
|
pendingContradictions,
|
||||||
caseCollisionLimitation,
|
caseCollisionLimitation,
|
||||||
inesRegionScan,
|
inesRegionScan,
|
||||||
parentResolution,
|
unknownParentOccurrences,
|
||||||
traversal,
|
traversal,
|
||||||
rootContradiction,
|
rootContradiction,
|
||||||
} = await scanReader(
|
} = await scanReader(
|
||||||
@@ -187,7 +186,7 @@ const scanCanonical = async (
|
|||||||
pendingContradictions,
|
pendingContradictions,
|
||||||
caseCollisionLimitation,
|
caseCollisionLimitation,
|
||||||
inesRegionScan,
|
inesRegionScan,
|
||||||
parentResolution,
|
unknownParentOccurrences,
|
||||||
traversal,
|
traversal,
|
||||||
rootContradiction,
|
rootContradiction,
|
||||||
recognitionLimitations,
|
recognitionLimitations,
|
||||||
@@ -221,10 +220,7 @@ const scanCanonical = async (
|
|||||||
pendingContradictions: [],
|
pendingContradictions: [],
|
||||||
caseCollisionLimitation: undefined,
|
caseCollisionLimitation: undefined,
|
||||||
inesRegionScan: undefined,
|
inesRegionScan: undefined,
|
||||||
parentResolution: {
|
unknownParentOccurrences: new Set(),
|
||||||
unknownParentOccurrences: new Set(),
|
|
||||||
limitations: [],
|
|
||||||
},
|
|
||||||
rootContradiction: undefined,
|
rootContradiction: undefined,
|
||||||
traversal: {
|
traversal: {
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
@@ -427,7 +423,7 @@ interface SnapshotBuildInput {
|
|||||||
readonly pendingContradictions: PendingIntegrityContradiction[];
|
readonly pendingContradictions: PendingIntegrityContradiction[];
|
||||||
readonly caseCollisionLimitation: string | undefined;
|
readonly caseCollisionLimitation: string | undefined;
|
||||||
readonly inesRegionScan: InesRegionScan | undefined;
|
readonly inesRegionScan: InesRegionScan | undefined;
|
||||||
readonly parentResolution: ArtifactParentResolution;
|
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
|
||||||
readonly traversal: ArtifactReaderTraversal;
|
readonly traversal: ArtifactReaderTraversal;
|
||||||
readonly rootContradiction: ArtifactContainerIntegrityPair | undefined;
|
readonly rootContradiction: ArtifactContainerIntegrityPair | undefined;
|
||||||
}
|
}
|
||||||
@@ -455,7 +451,7 @@ const buildInventorySnapshot = async (
|
|||||||
for (const occurrence of input.occurrences)
|
for (const occurrence of input.occurrences)
|
||||||
if (
|
if (
|
||||||
occurrence.parent_occurrence_id === null &&
|
occurrence.parent_occurrence_id === null &&
|
||||||
!input.parentResolution.unknownParentOccurrences.has(occurrence)
|
!input.unknownParentOccurrences.has(occurrence)
|
||||||
)
|
)
|
||||||
occurrence.parent_occurrence_id = rootOccurrence.occurrence_id;
|
occurrence.parent_occurrence_id = rootOccurrence.occurrence_id;
|
||||||
input.occurrences.unshift(rootOccurrence);
|
input.occurrences.unshift(rootOccurrence);
|
||||||
@@ -486,7 +482,6 @@ const buildInventorySnapshot = async (
|
|||||||
const edgeResult = createArtifactEdges(
|
const edgeResult = createArtifactEdges(
|
||||||
rootNode.artifact_id,
|
rootNode.artifact_id,
|
||||||
input.occurrences,
|
input.occurrences,
|
||||||
input.traversal.status,
|
|
||||||
reader?.provenance()[0],
|
reader?.provenance()[0],
|
||||||
);
|
);
|
||||||
const { edges } = edgeResult;
|
const { edges } = edgeResult;
|
||||||
@@ -533,12 +528,8 @@ const buildInventorySnapshot = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const traversal = inventoryCoverage(rootFormat, reader, input.traversal);
|
const traversal = inventoryCoverage(rootFormat, reader, input.traversal);
|
||||||
const relationshipLimitations = [
|
|
||||||
...input.parentResolution.limitations,
|
|
||||||
...edgeResult.limitations,
|
|
||||||
];
|
|
||||||
const coverage =
|
const coverage =
|
||||||
relationshipLimitations.length === 0
|
edgeResult.limitations.length === 0
|
||||||
? traversal.coverage
|
? traversal.coverage
|
||||||
: { ...traversal.coverage, status: "unknown" as const };
|
: { ...traversal.coverage, status: "unknown" as const };
|
||||||
return {
|
return {
|
||||||
@@ -553,7 +544,7 @@ const buildInventorySnapshot = async (
|
|||||||
limitations: buildLimitations(
|
limitations: buildLimitations(
|
||||||
[
|
[
|
||||||
...traversal.limitations,
|
...traversal.limitations,
|
||||||
...relationshipLimitations,
|
...edgeResult.limitations,
|
||||||
...(input.recognitionLimitations ?? []),
|
...(input.recognitionLimitations ?? []),
|
||||||
],
|
],
|
||||||
integrityContradictions,
|
integrityContradictions,
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import {
|
|||||||
noteUnreadContainerSuffix,
|
noteUnreadContainerSuffix,
|
||||||
indexDestinationOccurrencesByPath,
|
indexDestinationOccurrencesByPath,
|
||||||
resolveOccurrenceParents,
|
resolveOccurrenceParents,
|
||||||
type ArtifactParentResolution,
|
|
||||||
type MutableOccurrence,
|
type MutableOccurrence,
|
||||||
} from "./ArtifactGraphConstruction.js";
|
} from "./ArtifactGraphConstruction.js";
|
||||||
import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
|
import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
|
||||||
@@ -90,7 +89,7 @@ export const scanReader = async (
|
|||||||
readonly pendingContradictions: PendingIntegrityContradiction[];
|
readonly pendingContradictions: PendingIntegrityContradiction[];
|
||||||
readonly caseCollisionLimitation: string | undefined;
|
readonly caseCollisionLimitation: string | undefined;
|
||||||
readonly inesRegionScan: InesRegionScan | undefined;
|
readonly inesRegionScan: InesRegionScan | undefined;
|
||||||
readonly parentResolution: ArtifactParentResolution;
|
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
|
||||||
readonly traversal: ArtifactReaderTraversal;
|
readonly traversal: ArtifactReaderTraversal;
|
||||||
/** A recorded disagreement between the root's own checksum and its bytes. */
|
/** A recorded disagreement between the root's own checksum and its bytes. */
|
||||||
readonly rootContradiction: ArtifactContainerIntegrityPair | undefined;
|
readonly rootContradiction: ArtifactContainerIntegrityPair | undefined;
|
||||||
@@ -103,10 +102,7 @@ export const scanReader = async (
|
|||||||
pendingContradictions,
|
pendingContradictions,
|
||||||
caseCollisionLimitation: undefined,
|
caseCollisionLimitation: undefined,
|
||||||
inesRegionScan: undefined,
|
inesRegionScan: undefined,
|
||||||
parentResolution: {
|
unknownParentOccurrences: new Set(),
|
||||||
unknownParentOccurrences: new Set(),
|
|
||||||
limitations: [],
|
|
||||||
},
|
|
||||||
traversal: { status: "complete", limitations: [] },
|
traversal: { status: "complete", limitations: [] },
|
||||||
rootContradiction: undefined,
|
rootContradiction: undefined,
|
||||||
};
|
};
|
||||||
@@ -138,6 +134,11 @@ export const scanReader = async (
|
|||||||
byPath,
|
byPath,
|
||||||
context.expandedContainerIds,
|
context.expandedContainerIds,
|
||||||
);
|
);
|
||||||
|
const structuralLimitations = [...parentResolution.limitations];
|
||||||
|
context.traversal.onCoverage({
|
||||||
|
status: structuralLimitations.length === 0 ? "complete" : "unknown",
|
||||||
|
limitations: structuralLimitations,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
nodes,
|
nodes,
|
||||||
occurrences,
|
occurrences,
|
||||||
@@ -151,7 +152,7 @@ export const scanReader = async (
|
|||||||
bytes: context.reconstruction.bytes,
|
bytes: context.reconstruction.bytes,
|
||||||
sha256: context.reconstruction.hash.digest("hex"),
|
sha256: context.reconstruction.hash.digest("hex"),
|
||||||
},
|
},
|
||||||
parentResolution,
|
unknownParentOccurrences: parentResolution.unknownParentOccurrences,
|
||||||
traversal: context.traversal.snapshot(),
|
traversal: context.traversal.snapshot(),
|
||||||
rootContradiction,
|
rootContradiction,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ it("reads a Python 3.9 reference-flagged PYZ table", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads marshal one-byte string tags as Latin-1", () => {
|
||||||
|
for (const hex of ["6101000000e9", "4101000000e9", "7a01e9", "5a01e9"])
|
||||||
|
expect(readMarshalSubset(bytes(hex))).toEqual({ ok: true, value: "é" });
|
||||||
|
});
|
||||||
|
|
||||||
it("decodes CPython marshal Unicode with surrogatepass", () => {
|
it("decodes CPython marshal Unicode with surrogatepass", () => {
|
||||||
expect(
|
expect(
|
||||||
readMarshalSubset(
|
readMarshalSubset(
|
||||||
|
|||||||
@@ -168,14 +168,8 @@ const readAsciiBytes = (
|
|||||||
): Result<string, PyInstallerFailure> => {
|
): Result<string, PyInstallerFailure> => {
|
||||||
const bytes = readBytes(cursor, length);
|
const bytes = readBytes(cursor, length);
|
||||||
if (!bytes.ok) return bytes;
|
if (!bytes.ok) return bytes;
|
||||||
if (bytes.value.some((byte) => byte > 0x7f))
|
// These tags use CPython's one-byte Unicode constructor, not an ASCII check.
|
||||||
return err(
|
return ok(bytes.value.toString("latin1"));
|
||||||
pyInstallerFailure(
|
|
||||||
"format",
|
|
||||||
"PYZ marshal ASCII string contains a non-ASCII byte",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return ok(bytes.value.toString("ascii"));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const readUtf8 = (
|
const readUtf8 = (
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ const inventory = (digit: string) => {
|
|||||||
}),
|
}),
|
||||||
artifact_id: artifactId,
|
artifact_id: artifactId,
|
||||||
parent_occurrence_id: null,
|
parent_occurrence_id: null,
|
||||||
relationship_coverage: "complete",
|
|
||||||
logical_path: ".",
|
logical_path: ".",
|
||||||
entry_kind: "file",
|
entry_kind: "file",
|
||||||
artifact_kind: "resource",
|
artifact_kind: "resource",
|
||||||
|
|||||||
@@ -16,12 +16,11 @@ import {
|
|||||||
import type { AnalysisViewParent } from "./analysisView.js";
|
import type { AnalysisViewParent } from "./analysisView.js";
|
||||||
|
|
||||||
const carrier = ARTIFACT_COMPARISON_EXAMPLE.left;
|
const carrier = ARTIFACT_COMPARISON_EXAMPLE.left;
|
||||||
const freshInventory = (version: "current" | "historical" = "current") => {
|
const freshInventory = () => {
|
||||||
const inventory = artifactInventoryResultSchema.parse(
|
const inventory = artifactInventoryResultSchema.parse(
|
||||||
carrier.normalized_result,
|
carrier.normalized_result,
|
||||||
);
|
);
|
||||||
const root = inventory.occurrences[0]!;
|
const root = inventory.occurrences[0]!;
|
||||||
if (version === "historical") delete root.relationship_coverage;
|
|
||||||
const rootNode = inventory.nodes[0]!;
|
const rootNode = inventory.nodes[0]!;
|
||||||
root.artifact_kind = "container";
|
root.artifact_kind = "container";
|
||||||
root.artifact_format = "zip";
|
root.artifact_format = "zip";
|
||||||
@@ -260,19 +259,6 @@ it("does not fabricate a selected field absent from the owning report", () => {
|
|||||||
).toThrow();
|
).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", () => {
|
it("borrows authenticated immutable facts and validates once across retained queries", () => {
|
||||||
const inventory = freezeJsonSnapshot(freshInventory());
|
const inventory = freezeJsonSnapshot(freshInventory());
|
||||||
const source = parent(inventory);
|
const source = parent(inventory);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
artifactInventoryCoverageSchema,
|
artifactInventoryCoverageSchema,
|
||||||
artifactNodeSchema,
|
artifactNodeSchema,
|
||||||
artifactOccurrenceSchema,
|
artifactOccurrenceSchema,
|
||||||
artifactOccurrenceRelationshipCoverage,
|
|
||||||
artifactEdgeSchema,
|
artifactEdgeSchema,
|
||||||
integrityContradictionSchema,
|
integrityContradictionSchema,
|
||||||
} from "../artifactGraph.js";
|
} from "../artifactGraph.js";
|
||||||
@@ -575,12 +574,6 @@ const selectOccurrenceFields = (
|
|||||||
? row
|
? row
|
||||||
: {
|
: {
|
||||||
...selectFields(row, fields),
|
...selectFields(row, fields),
|
||||||
...(fields.includes("relationship_coverage")
|
|
||||||
? {
|
|
||||||
relationship_coverage:
|
|
||||||
artifactOccurrenceRelationshipCoverage(row),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
occurrence_id: row.occurrence_id,
|
occurrence_id: row.occurrence_id,
|
||||||
artifact_id: row.artifact_id,
|
artifact_id: row.artifact_id,
|
||||||
parent_occurrence_id: row.parent_occurrence_id,
|
parent_occurrence_id: row.parent_occurrence_id,
|
||||||
|
|||||||
@@ -11,32 +11,12 @@ import {
|
|||||||
type ArtifactOccurrence,
|
type ArtifactOccurrence,
|
||||||
} from "./artifactGraph.js";
|
} from "./artifactGraph.js";
|
||||||
import {
|
import {
|
||||||
artifactEdgeId,
|
|
||||||
artifactGraphDigest,
|
artifactGraphDigest,
|
||||||
artifactIdForContent,
|
artifactIdForContent,
|
||||||
artifactManifestId,
|
artifactManifestId,
|
||||||
occurrenceIdForLocation,
|
occurrenceIdForLocation,
|
||||||
} from "./artifactIdentity.js";
|
} from "./artifactIdentity.js";
|
||||||
import { createEvidence } from "./evidence.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. */
|
/** Recommit observation variants so comparisons exercise valid Evidence carriers. */
|
||||||
const commit = (inventory: ArtifactInventoryResult) => {
|
const commit = (inventory: ArtifactInventoryResult) => {
|
||||||
@@ -51,196 +31,23 @@ const commit = (inventory: ArtifactInventoryResult) => {
|
|||||||
inventory.manifest.root_artifact_id,
|
inventory.manifest.root_artifact_id,
|
||||||
graphSha256,
|
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 = (
|
const observation = (
|
||||||
facts: Pick<ArtifactOccurrence, "hash_status"> & Partial<ArtifactOccurrence>,
|
facts: Pick<ArtifactOccurrence, "hash_status"> & Partial<ArtifactOccurrence>,
|
||||||
changedContent = false,
|
changedContent = false,
|
||||||
@@ -496,7 +303,6 @@ const recordObservation = (
|
|||||||
}),
|
}),
|
||||||
artifact_id: artifactId,
|
artifact_id: artifactId,
|
||||||
parent_occurrence_id: root.occurrence_id,
|
parent_occurrence_id: root.occurrence_id,
|
||||||
relationship_coverage: artifactId === null ? "unknown" : "complete",
|
|
||||||
logical_path: "record",
|
logical_path: "record",
|
||||||
entry_kind: "metadata",
|
entry_kind: "metadata",
|
||||||
artifact_kind: "package-metadata",
|
artifact_kind: "package-metadata",
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
artifactOccurrenceRelationshipCoverage,
|
ArtifactInventoryResult,
|
||||||
type ArtifactInventoryResult,
|
ArtifactNode,
|
||||||
type ArtifactNode,
|
ArtifactOccurrence,
|
||||||
type ArtifactOccurrence,
|
|
||||||
} from "./artifactGraph.js";
|
} from "./artifactGraph.js";
|
||||||
import { AnalysisInputError } from "./analysisErrorCore.js";
|
import { AnalysisInputError } from "./analysisErrorCore.js";
|
||||||
import {
|
import {
|
||||||
@@ -257,8 +256,8 @@ const compareOccurrences = (
|
|||||||
const rightNodes = new Map(
|
const rightNodes = new Map(
|
||||||
right.nodes.map((item) => [item.artifact_id, item]),
|
right.nodes.map((item) => [item.artifact_id, item]),
|
||||||
);
|
);
|
||||||
const leftRelationsFor = relationsForOccurrence(left);
|
const leftRelations = relationsByPath(left);
|
||||||
const rightRelationsFor = relationsForOccurrence(right);
|
const rightRelations = relationsByPath(right);
|
||||||
const paths = [
|
const paths = [
|
||||||
...new Set([...leftByPath.keys(), ...rightByPath.keys()]),
|
...new Set([...leftByPath.keys(), ...rightByPath.keys()]),
|
||||||
].sort(compareUnicodeCodePoints);
|
].sort(compareUnicodeCodePoints);
|
||||||
@@ -303,10 +302,8 @@ const compareOccurrences = (
|
|||||||
rightOccurrence,
|
rightOccurrence,
|
||||||
leftNode: nodeFor(leftOccurrence, leftNodes),
|
leftNode: nodeFor(leftOccurrence, leftNodes),
|
||||||
rightNode: nodeFor(rightOccurrence, rightNodes),
|
rightNode: nodeFor(rightOccurrence, rightNodes),
|
||||||
relations: compareRelations(
|
leftRelations: leftRelations.get(path) ?? [],
|
||||||
leftRelationsFor(leftOccurrence),
|
rightRelations: rightRelations.get(path) ?? [],
|
||||||
rightRelationsFor(rightOccurrence),
|
|
||||||
),
|
|
||||||
rootCovered: coverage.root,
|
rootCovered: coverage.root,
|
||||||
entriesCovered: coverage.entries,
|
entriesCovered: coverage.entries,
|
||||||
evidenceLinks,
|
evidenceLinks,
|
||||||
@@ -341,7 +338,8 @@ interface ClassifyPathInput {
|
|||||||
readonly rightOccurrence: ArtifactOccurrence | undefined;
|
readonly rightOccurrence: ArtifactOccurrence | undefined;
|
||||||
readonly leftNode: ArtifactNode | undefined;
|
readonly leftNode: ArtifactNode | undefined;
|
||||||
readonly rightNode: ArtifactNode | undefined;
|
readonly rightNode: ArtifactNode | undefined;
|
||||||
readonly relations: FactComparison;
|
readonly leftRelations: readonly RelationProjection[];
|
||||||
|
readonly rightRelations: readonly RelationProjection[];
|
||||||
readonly rootCovered: boolean;
|
readonly rootCovered: boolean;
|
||||||
readonly entriesCovered: boolean;
|
readonly entriesCovered: boolean;
|
||||||
readonly evidenceLinks: readonly string[];
|
readonly evidenceLinks: readonly string[];
|
||||||
@@ -422,7 +420,7 @@ const classifyPath = (input: ClassifyPathInput): ArtifactChange | null => {
|
|||||||
if (known.kind) dimensions.push("kind");
|
if (known.kind) dimensions.push("kind");
|
||||||
if (known.format) dimensions.push("format");
|
if (known.format) dimensions.push("format");
|
||||||
if (known.executable) dimensions.push("executable");
|
if (known.executable) dimensions.push("executable");
|
||||||
if (input.relations === "changed") dimensions.push("relations");
|
if (known.relations) dimensions.push("relations");
|
||||||
if (known.metadata || declaredDigestsDiffer(input))
|
if (known.metadata || declaredDigestsDiffer(input))
|
||||||
dimensions.push("metadata");
|
dimensions.push("metadata");
|
||||||
dimensions.push("availability");
|
dimensions.push("availability");
|
||||||
@@ -440,7 +438,6 @@ const classifyPath = (input: ClassifyPathInput): ArtifactChange | null => {
|
|||||||
declarationUnknown(input.metadata) ||
|
declarationUnknown(input.metadata) ||
|
||||||
occurrence.kindUnknown ||
|
occurrence.kindUnknown ||
|
||||||
occurrence.formatUnknown ||
|
occurrence.formatUnknown ||
|
||||||
input.relations === "unknown" ||
|
|
||||||
differsWithUnknown(input.leftNode?.format, input.rightNode?.format) ||
|
differsWithUnknown(input.leftNode?.format, input.rightNode?.format) ||
|
||||||
differsWithMissing(
|
differsWithMissing(
|
||||||
input.leftNode?.media_type,
|
input.leftNode?.media_type,
|
||||||
@@ -469,17 +466,20 @@ type OccurrencePair = Pick<
|
|||||||
"leftOccurrence" | "rightOccurrence"
|
"leftOccurrence" | "rightOccurrence"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type FactComparison = "unchanged" | "changed" | "unknown";
|
type MetadataComparison = "unchanged" | "changed" | "unknown";
|
||||||
|
|
||||||
interface OccurrenceMetadata {
|
interface OccurrenceMetadata {
|
||||||
/** ASAR storage is an occurrence field, not format metadata. */
|
/** 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. */
|
/** 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. */
|
/** 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. */
|
/** 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. */
|
/** A declaration that differs even when content is unavailable. */
|
||||||
@@ -513,6 +513,8 @@ const occurrenceDifferences = (input: {
|
|||||||
readonly metadata: OccurrenceMetadata;
|
readonly metadata: OccurrenceMetadata;
|
||||||
readonly leftOccurrence: ArtifactOccurrence | undefined;
|
readonly leftOccurrence: ArtifactOccurrence | undefined;
|
||||||
readonly rightOccurrence: ArtifactOccurrence | undefined;
|
readonly rightOccurrence: ArtifactOccurrence | undefined;
|
||||||
|
readonly leftRelations: readonly RelationProjection[];
|
||||||
|
readonly rightRelations: readonly RelationProjection[];
|
||||||
}) => ({
|
}) => ({
|
||||||
kind:
|
kind:
|
||||||
input.leftOccurrence?.artifact_kind !==
|
input.leftOccurrence?.artifact_kind !==
|
||||||
@@ -538,15 +540,24 @@ const occurrenceDifferences = (input: {
|
|||||||
),
|
),
|
||||||
executable:
|
executable:
|
||||||
input.leftOccurrence?.executable !== input.rightOccurrence?.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:
|
metadata:
|
||||||
declaredFactChanged(input.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 = (
|
const compareAsarPacking = (
|
||||||
left: boolean | undefined,
|
left: boolean | undefined,
|
||||||
right: boolean | undefined,
|
right: boolean | undefined,
|
||||||
): FactComparison =>
|
): MetadataComparison =>
|
||||||
left === right
|
left === right
|
||||||
? "unchanged"
|
? "unchanged"
|
||||||
: left === undefined || right === undefined
|
: left === undefined || right === undefined
|
||||||
@@ -671,7 +682,8 @@ const changedDimensions = (input: {
|
|||||||
readonly rightNode: ArtifactNode | undefined;
|
readonly rightNode: ArtifactNode | undefined;
|
||||||
readonly leftOccurrence: ArtifactOccurrence | undefined;
|
readonly leftOccurrence: ArtifactOccurrence | undefined;
|
||||||
readonly rightOccurrence: ArtifactOccurrence | undefined;
|
readonly rightOccurrence: ArtifactOccurrence | undefined;
|
||||||
readonly relations: FactComparison;
|
readonly leftRelations: readonly RelationProjection[];
|
||||||
|
readonly rightRelations: readonly RelationProjection[];
|
||||||
}): z.infer<typeof comparisonDimensionSchema>[] => {
|
}): z.infer<typeof comparisonDimensionSchema>[] => {
|
||||||
const left = input.leftNode;
|
const left = input.leftNode;
|
||||||
const right = input.rightNode;
|
const right = input.rightNode;
|
||||||
@@ -691,7 +703,7 @@ const changedDimensions = (input: {
|
|||||||
],
|
],
|
||||||
["size", left.size !== right.size],
|
["size", left.size !== right.size],
|
||||||
["executable", occurrence.executable],
|
["executable", occurrence.executable],
|
||||||
["relations", input.relations === "changed"],
|
["relations", occurrence.relations],
|
||||||
["metadata", occurrence.metadata || metadataChanged(left, right)],
|
["metadata", occurrence.metadata || metadataChanged(left, right)],
|
||||||
];
|
];
|
||||||
return checks
|
return checks
|
||||||
@@ -704,7 +716,8 @@ const metadataChanged = (left: ArtifactNode, right: ArtifactNode): boolean =>
|
|||||||
!differsWithMissing(left.media_type, right.media_type)) ||
|
!differsWithMissing(left.media_type, right.media_type)) ||
|
||||||
(left.architecture !== right.architecture &&
|
(left.architecture !== right.architecture &&
|
||||||
!differsWithMissing(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 = (
|
const nodeFor = (
|
||||||
occurrence: ArtifactOccurrence | undefined,
|
occurrence: ArtifactOccurrence | undefined,
|
||||||
@@ -714,16 +727,10 @@ const nodeFor = (
|
|||||||
? undefined
|
? undefined
|
||||||
: nodes.get(occurrence.artifact_id);
|
: nodes.get(occurrence.artifact_id);
|
||||||
|
|
||||||
/** An empty projection means observed absence. Missing edges or parent facts leave it unavailable. */
|
const relationsByPath = (
|
||||||
const relationsForOccurrence = (
|
|
||||||
inventory: InventorySet,
|
inventory: InventorySet,
|
||||||
): ((
|
): ReadonlyMap<string, readonly RelationProjection[]> => {
|
||||||
occurrence: ArtifactOccurrence | undefined,
|
|
||||||
) => readonly RelationProjection[] | undefined) => {
|
|
||||||
if (inventory.edges.length !== inventory.manifest.edge_count)
|
|
||||||
return () => undefined;
|
|
||||||
const output = new Map<string, RelationProjection[]>();
|
const output = new Map<string, RelationProjection[]>();
|
||||||
const unresolvedPaths = new Set<string>();
|
|
||||||
const occurrences = new Map(
|
const occurrences = new Map(
|
||||||
inventory.occurrences.map((item) => [item.occurrence_id, item]),
|
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.
|
// An incomplete inventory can omit the occurrence that names the child.
|
||||||
if (occurrence?.artifact_id === null || occurrence === undefined) continue;
|
if (occurrence?.artifact_id === null || occurrence === undefined) continue;
|
||||||
const path = occurrence.logical_path;
|
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 values = output.get(path);
|
||||||
const projection: RelationProjection = {
|
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
|
// Containment roots are path-normalized to avoid graph-wide deltas, but
|
||||||
// a source map's target is a semantic content identity, not its parent
|
// a source map's target is a semantic content identity, not its parent
|
||||||
// directory. Preserve that identity in the relation comparison.
|
// directory. Preserve that identity in the relation comparison.
|
||||||
@@ -760,23 +763,7 @@ const relationsForOccurrence = (
|
|||||||
values.sort((left, right) =>
|
values.sort((left, right) =>
|
||||||
compareUnicodeCodePoints(canonicalJson(left), canonicalJson(right)),
|
compareUnicodeCodePoints(canonicalJson(left), canonicalJson(right)),
|
||||||
);
|
);
|
||||||
return (occurrence) => {
|
return output;
|
||||||
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";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface RelationProjection {
|
interface RelationProjection {
|
||||||
|
|||||||
@@ -173,8 +173,6 @@ export const artifactOccurrenceSchema = z.object({
|
|||||||
occurrence_id: occurrenceIdSchema,
|
occurrence_id: occurrenceIdSchema,
|
||||||
artifact_id: artifactIdSchema.nullable(),
|
artifact_id: artifactIdSchema.nullable(),
|
||||||
parent_occurrence_id: occurrenceIdSchema.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,
|
logical_path: boundedRelativePathSchema,
|
||||||
entry_kind: z.enum(["file", "directory", "symlink", "slice", "metadata"]),
|
entry_kind: z.enum(["file", "directory", "symlink", "slice", "metadata"]),
|
||||||
artifact_kind: artifactKindSchema,
|
artifact_kind: artifactKindSchema,
|
||||||
@@ -319,12 +317,6 @@ export type ArtifactEdge = z.infer<typeof artifactEdgeSchema>;
|
|||||||
|
|
||||||
/** Parsed artifact occurrence. */
|
/** Parsed artifact occurrence. */
|
||||||
export type ArtifactOccurrence = z.infer<typeof artifactOccurrenceSchema>;
|
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<
|
export type IntegrityContradiction = z.infer<
|
||||||
typeof integrityContradictionSchema
|
typeof integrityContradictionSchema
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -407,6 +407,30 @@ it("charges long digits to the string budget and keeps large previews bounded",
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads marshal one-byte string tags as Latin-1 with Unicode identity", () => {
|
||||||
|
for (const tag of [0x61, 0x41, 0x7a, 0x5a]) {
|
||||||
|
const encoded = Buffer.concat([
|
||||||
|
Buffer.from([tag]),
|
||||||
|
tag === 0x61 || tag === 0x41 ? u32(1) : Buffer.from([1]),
|
||||||
|
Buffer.from([0xe9]),
|
||||||
|
]);
|
||||||
|
const sameUnicode = marshalUnicode(Buffer.from("é", "utf8"));
|
||||||
|
const set = Buffer.concat([
|
||||||
|
Buffer.from([0x3c]),
|
||||||
|
u32(2),
|
||||||
|
encoded,
|
||||||
|
sameUnicode,
|
||||||
|
]);
|
||||||
|
const report = root(parse(withConsts([encoded, set])));
|
||||||
|
expect(report.consts[0]).toMatchObject({
|
||||||
|
kind: "str",
|
||||||
|
value: "é",
|
||||||
|
bytes: 1,
|
||||||
|
});
|
||||||
|
expect(report.consts[1]).toEqual({ kind: "set", length: 1 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("decodes marshal strings with CPython surrogatepass rules", () => {
|
it("decodes marshal strings with CPython surrogatepass rules", () => {
|
||||||
const report = parse(
|
const report = parse(
|
||||||
withConsts([marshalUnicode(Buffer.from("efbbbf73eda080efbbbf", "hex"))]),
|
withConsts([marshalUnicode(Buffer.from("efbbbf73eda080efbbbf", "hex"))]),
|
||||||
@@ -1534,6 +1558,36 @@ it("hashes a long TYPE_REF tuple chain without recursing", () => {
|
|||||||
expect(pythonPycSchema.safeParse(report).success).toBe(true);
|
expect(pythonPycSchema.safeParse(report).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hashes shallow TYPE_REF code chains without using the JavaScript stack", () => {
|
||||||
|
const links = 3000;
|
||||||
|
const codes = Array.from({ length: links }, (_, index) =>
|
||||||
|
marshalCode("py311", {
|
||||||
|
flagged: true,
|
||||||
|
name: "f",
|
||||||
|
consts: index === 0 ? [Buffer.from([0x4e])] : [marshalRef(index - 1)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const bytes = Buffer.concat([
|
||||||
|
pythonPycHeader(3531),
|
||||||
|
marshalCode("py311", {
|
||||||
|
constsObject: Buffer.concat([
|
||||||
|
Buffer.from([0x28]),
|
||||||
|
u32(links + 1),
|
||||||
|
...codes,
|
||||||
|
Buffer.from([0x3c]),
|
||||||
|
u32(1),
|
||||||
|
marshalRef(links - 1),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const report = parseWith(bytes, { max_objects: 65_536 });
|
||||||
|
expect(root(report).consts.at(-1)).toEqual({ kind: "set", length: 1 });
|
||||||
|
expect(report.coverage).toMatchObject({
|
||||||
|
status: "complete",
|
||||||
|
code_objects: links + 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects code CPython treats as corrupt instead of complete coverage", () => {
|
it("rejects code CPython treats as corrupt instead of complete coverage", () => {
|
||||||
const file = (
|
const file = (
|
||||||
layout: "py37" | "py38" | "py311",
|
layout: "py37" | "py38" | "py311",
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ class MarshalReader {
|
|||||||
readonly #codeKeys = new WeakMap<CodeNode, string>();
|
readonly #codeKeys = new WeakMap<CodeNode, string>();
|
||||||
readonly #codeIdentities = new WeakMap<CodeNode, number>();
|
readonly #codeIdentities = new WeakMap<CodeNode, number>();
|
||||||
readonly #codeEquality = new WeakMap<CodeNode, CodeEquality>();
|
readonly #codeEquality = new WeakMap<CodeNode, CodeEquality>();
|
||||||
/** Python hashability per code object. False while that check is in progress. */
|
/** Completed Python hashability checks per code object. */
|
||||||
readonly #codeHashable = new WeakMap<CodeNode, boolean>();
|
readonly #codeHashable = new WeakMap<CodeNode, boolean>();
|
||||||
/** Tuples whose items are still being read; CPython cannot hash them yet. */
|
/** Tuples whose items are still being read; CPython cannot hash them yet. */
|
||||||
readonly #readingTuples = new WeakSet<MarshalValue>();
|
readonly #readingTuples = new WeakSet<MarshalValue>();
|
||||||
@@ -436,13 +436,19 @@ class MarshalReader {
|
|||||||
};
|
};
|
||||||
case TYPE_INTERNED:
|
case TYPE_INTERNED:
|
||||||
case TYPE_UNICODE:
|
case TYPE_UNICODE:
|
||||||
return this.utf8(this.nonNegativeInt32("Marshal string length"), false);
|
return this.string(
|
||||||
|
this.nonNegativeInt32("Marshal string length"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
case TYPE_ASCII:
|
case TYPE_ASCII:
|
||||||
case TYPE_ASCII_INTERNED:
|
case TYPE_ASCII_INTERNED:
|
||||||
return this.utf8(this.nonNegativeInt32("Marshal string length"), true);
|
return this.string(
|
||||||
|
this.nonNegativeInt32("Marshal string length"),
|
||||||
|
"latin1",
|
||||||
|
);
|
||||||
case TYPE_SHORT_ASCII:
|
case TYPE_SHORT_ASCII:
|
||||||
case TYPE_SHORT_ASCII_INTERNED:
|
case TYPE_SHORT_ASCII_INTERNED:
|
||||||
return this.utf8(this.u8(), true);
|
return this.string(this.u8(), "latin1");
|
||||||
case TYPE_TUPLE:
|
case TYPE_TUPLE:
|
||||||
return this.sequenceValue(
|
return this.sequenceValue(
|
||||||
"tuple",
|
"tuple",
|
||||||
@@ -1309,13 +1315,70 @@ class MarshalReader {
|
|||||||
#isCodeHashable(node: CodeNode): boolean {
|
#isCodeHashable(node: CodeNode): boolean {
|
||||||
const known = this.#codeHashable.get(node);
|
const known = this.#codeHashable.get(node);
|
||||||
if (known !== undefined) return known;
|
if (known !== undefined) return known;
|
||||||
this.#codeHashable.set(node, false);
|
const frames: { value: MarshalValue; finish: boolean }[] = [
|
||||||
const material =
|
{ value: { kind: "code", value: node }, finish: false },
|
||||||
this.#codeEquality.get(node) ??
|
];
|
||||||
malformed("Code object is missing equality material.");
|
const gray = new Set<MarshalValue | CodeNode>();
|
||||||
const hashable = this.#equalityKey(material.constsTuple, "hash") !== null;
|
const complete = new Set<MarshalValue>();
|
||||||
this.#codeHashable.set(node, hashable);
|
const reject = (): false => {
|
||||||
return hashable;
|
this.#codeHashable.set(node, false);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
while (frames.length > 0) {
|
||||||
|
this.#takeKeyStep();
|
||||||
|
const frame = frames.pop()!;
|
||||||
|
const value = frame.value;
|
||||||
|
const identity = value.kind === "code" ? value.value : value;
|
||||||
|
if (frame.finish) {
|
||||||
|
gray.delete(identity);
|
||||||
|
if (value.kind === "code") {
|
||||||
|
const material =
|
||||||
|
this.#codeEquality.get(value.value) ??
|
||||||
|
malformed("Code object is missing equality material.");
|
||||||
|
// Child code checks are now complete, so the existing tuple key
|
||||||
|
// walker preserves its equality and expansion budgets without recursion.
|
||||||
|
const hashable =
|
||||||
|
this.#equalityKey(material.constsTuple, "hash") !== null;
|
||||||
|
this.#codeHashable.set(value.value, hashable);
|
||||||
|
if (!hashable) return reject();
|
||||||
|
} else complete.add(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value.kind === "code") {
|
||||||
|
const cached = this.#codeHashable.get(value.value);
|
||||||
|
if (cached === false) return reject();
|
||||||
|
if (cached === true) continue;
|
||||||
|
} else {
|
||||||
|
const cached = this.#keyCache.get(value);
|
||||||
|
if (cached === null) return reject();
|
||||||
|
if (cached !== undefined || complete.has(value)) continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
gray.has(identity) ||
|
||||||
|
this.#readingTuples.has(value) ||
|
||||||
|
value.kind === "list" ||
|
||||||
|
value.kind === "set" ||
|
||||||
|
value.kind === "dict" ||
|
||||||
|
value.kind === "null"
|
||||||
|
)
|
||||||
|
return reject();
|
||||||
|
const children =
|
||||||
|
value.kind === "code"
|
||||||
|
? [
|
||||||
|
(
|
||||||
|
this.#codeEquality.get(value.value) ??
|
||||||
|
malformed("Code object is missing equality material.")
|
||||||
|
).constsTuple,
|
||||||
|
]
|
||||||
|
: value.kind === "tuple" || value.kind === "frozenset"
|
||||||
|
? value.items
|
||||||
|
: [];
|
||||||
|
gray.add(identity);
|
||||||
|
frames.push({ value, finish: true });
|
||||||
|
for (let index = children.length - 1; index >= 0; index -= 1)
|
||||||
|
frames.push({ value: children[index]!, finish: false });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
#codeIdentity(node: CodeNode): number {
|
#codeIdentity(node: CodeNode): number {
|
||||||
@@ -1492,17 +1555,20 @@ class MarshalReader {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
utf8(length: number, ascii: boolean): MarshalValue {
|
string(length: number, encoding: "latin1" | "utf8"): MarshalValue {
|
||||||
const bytes = this.blob(length);
|
const bytes = this.blob(length);
|
||||||
if (ascii) {
|
if (encoding === "latin1") {
|
||||||
if (bytes.some((byte) => byte > 0x7f))
|
// CPython reads the ASCII-tagged forms as one-byte Unicode, even when
|
||||||
malformed("Marshal ASCII string contains a non-ASCII byte.");
|
// high bytes appear. Identity must agree with the UTF-8 string form.
|
||||||
|
const value = bytes.toString("latin1");
|
||||||
return {
|
return {
|
||||||
kind: "str",
|
kind: "str",
|
||||||
value: bytes.toString("latin1"),
|
value,
|
||||||
previewEnd: Math.min(length, PYTHON_PYC_CONST_PREVIEW_LIMIT),
|
previewEnd: Math.min(length, PYTHON_PYC_CONST_PREVIEW_LIMIT),
|
||||||
bytes: length,
|
bytes: length,
|
||||||
raw: bytes,
|
raw: bytes.some((byte) => byte > 0x7f)
|
||||||
|
? Buffer.from(value, "utf8")
|
||||||
|
: bytes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const decoded = decodeMarshalUtf8(bytes, PYTHON_PYC_CONST_PREVIEW_LIMIT);
|
const decoded = decodeMarshalUtf8(bytes, PYTHON_PYC_CONST_PREVIEW_LIMIT);
|
||||||
|
|||||||
@@ -570,45 +570,6 @@ describe("artifact comparison completeness", () => {
|
|||||||
}, 15_000);
|
}, 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", () => {
|
describe("artifact source-map relationship coverage", () => {
|
||||||
it("reports when an unchanged map points to changed source content", async () => {
|
it("reports when an unchanged map points to changed source content", async () => {
|
||||||
const root = await createTestTempDirectory("rea-map-target-change-");
|
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 () => {
|
it("preserves unknown source-map relationships for duplicate ZIP paths", async () => {
|
||||||
const root = await createTestTempDirectory("rea-duplicate-source-map-");
|
const root = await createTestTempDirectory("rea-duplicate-source-map-");
|
||||||
const path = join(root, "duplicate-source-map.zip");
|
const path = join(root, "duplicate-source-map.zip");
|
||||||
const uniquePath = join(root, "unique-source-map.zip");
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||||
const zip = async (otherSource: string) => {
|
await writer.add("main.js", new TextReader("first();"), { level: 0 });
|
||||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
await writer.add("your.js", new TextReader("second();"), { level: 0 });
|
||||||
await writer.add("main.js", new TextReader("first();"), { level: 0 });
|
await writer.add("main.js.map", new TextReader("{}"), { level: 0 });
|
||||||
await writer.add("your.js", new TextReader("second();"), { level: 0 });
|
const bytes = Buffer.from(await writer.close());
|
||||||
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();"));
|
|
||||||
for (const [signature, headerSize] of [
|
for (const [signature, headerSize] of [
|
||||||
[Buffer.from([0x50, 0x4b, 0x03, 0x04]), 30],
|
[Buffer.from([0x50, 0x4b, 0x03, 0x04]), 30],
|
||||||
[Buffer.from([0x50, 0x4b, 0x01, 0x02]), 46],
|
[Buffer.from([0x50, 0x4b, 0x01, 0x02]), 46],
|
||||||
@@ -706,29 +660,6 @@ describe("artifact source-map relationship coverage", () => {
|
|||||||
limitation.toLowerCase().includes("integrity"),
|
limitation.toLowerCase().includes("integrity"),
|
||||||
),
|
),
|
||||||
).toBe(false);
|
).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-",
|
"rea-artifact-duplicate-directory-",
|
||||||
);
|
);
|
||||||
const path = join(root, "duplicate-directories.zip");
|
const path = join(root, "duplicate-directories.zip");
|
||||||
const uniquePath = join(root, "unique-directories.zip");
|
|
||||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||||
await writer.add("x/", new TextReader(""));
|
await writer.add("x/", new TextReader(""));
|
||||||
await writer.add("y/", new TextReader(""));
|
await writer.add("y/", new TextReader(""));
|
||||||
await writer.add("x/file.txt", new TextReader("member"));
|
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());
|
const bytes = Buffer.from(await writer.close());
|
||||||
await writeFile(uniquePath, bytes);
|
|
||||||
for (const [signature, headerSize] of [
|
for (const [signature, headerSize] of [
|
||||||
[Buffer.from([0x50, 0x4b, 0x03, 0x04]), 30],
|
[Buffer.from([0x50, 0x4b, 0x03, 0x04]), 30],
|
||||||
[Buffer.from([0x50, 0x4b, 0x01, 0x02]), 46],
|
[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".',
|
'The parent occurrence for "x/file.txt" is unknown because multiple observed directory or container occurrences share "x".',
|
||||||
);
|
);
|
||||||
expect(inventory.coverage.status).toBe("unknown");
|
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 () => {
|
it("retains duplicate path occurrences across pages and leaves pairing unknown", async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user