mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(artifacts): compare inventories using declared coverage (#2214)
* fix(artifacts): preserve complete root inventory comparisons * fix(artifacts): declare inventory coverage before comparison * fix(artifacts): preserve traversal omissions and materialization limits * fix(artifacts): preserve coverage declarations and projection scope Remove the duplicate inventory-schema import. Compare original coverage declarations, including field omission, before adapting legacy pages. Require explicit scope for completeness and container-entry coverage for application projections. Source inspection and git diff --check only. Tests, builds and typechecks skipped by explicit user request. GitHub review writes paused globally. * fix(artifacts): require entry coverage for child changes * fix(artifacts): accept source-valid inventory limitations * style(artifacts): format mobile graph projection --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: OpenAI Codex <openai-codex@users.noreply.github.com>
This commit is contained in:
@@ -21,6 +21,31 @@ directly to `compare_functions`, and `inspect_artifact` Evidence to
|
||||
`compare_artifacts`. Use `get_evidence_bundle` when the task needs broader
|
||||
retained session history or an explicit bundle for transfer.
|
||||
|
||||
Artifact inventories declare `coverage.scope` (`root` or `container-entries`)
|
||||
and `coverage.status` (`complete` or `unknown`) independently of diagnostic
|
||||
wording. Comparison requires matching scopes, complete graph members and complete
|
||||
traversal coverage before concluding equality. A fully observed root without
|
||||
container children can be `unchanged`; its scope notice stays in `limitations`
|
||||
without creating a gap. Content and executable metadata changes still produce
|
||||
root deltas. Missing graph members yield `truncated`; unknown traversal coverage
|
||||
or unavailable occurrence hashes retain uncertainty. Integrity mismatches remain
|
||||
contradictions. Static artifact equality does not establish unchanged runtime
|
||||
behavior.
|
||||
|
||||
Exhausted traversals with deliberately omitted filesystem entries retain
|
||||
`unknown` coverage and identify each omitted path/type in `limitations`; FIFO,
|
||||
socket and device entries are not opened. Case-distinct archive members remain
|
||||
separate observations. Their case-insensitive materialization diagnostic does
|
||||
not reduce traversal coverage when both spellings were read.
|
||||
|
||||
Current output also retains `coverage_status` (`complete` or `partial`) for existing clients, derived from the scoped traversal status. The owning parser normalizes retained `coverage_status` records to scoped coverage and rejects conflicting declarations.
|
||||
|
||||
The coverage field is optional when reading older retained Evidence. The owning
|
||||
inventory parser recognizes historical REA scope notices for compatibility;
|
||||
unfamiliar legacy limitations remain conservative. Typed records do not derive
|
||||
coverage from limitation text. Pages from one inventory must carry the same
|
||||
coverage declaration.
|
||||
|
||||
Function dossiers can declare per-facet coverage in the optional
|
||||
`normalized_result.facet_availability` map. Each supplied facet is either
|
||||
`{ "available": true }` or `{ "available": false, "reason": "..." }`.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import type { ArtifactCommand } from "../domain/artifactGraph.js";
|
||||
import type {
|
||||
ArtifactCommand,
|
||||
ArtifactInventoryCoverage,
|
||||
} from "../domain/artifactGraph.js";
|
||||
import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
|
||||
import type {
|
||||
AnalysisCleanupObservation,
|
||||
@@ -90,10 +93,36 @@ export const sameArtifactEntry = (
|
||||
entry.sourceIdentity?.mtimeNs === produced.sourceIdentity?.mtimeNs &&
|
||||
entry.sourceIdentity?.ctimeNs === produced.sourceIdentity?.ctimeNs;
|
||||
|
||||
/** One entry or subtree deliberately excluded from a traversal. */
|
||||
export interface ArtifactTraversalOmission {
|
||||
readonly path: string;
|
||||
readonly kind:
|
||||
| "fifo"
|
||||
| "socket"
|
||||
| "block-device"
|
||||
| "character-device"
|
||||
| "unsupported"
|
||||
| "directory-descendants";
|
||||
}
|
||||
|
||||
/** Facts from an exhausted traversal, separate from entry content certainty. */
|
||||
export interface ArtifactReaderTraversal {
|
||||
readonly status: ArtifactInventoryCoverage["status"];
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
/** Per-iterator observation sink; adapters report intentional omissions without shared state. */
|
||||
export interface ArtifactTraversalOptions {
|
||||
readonly onOmission?: (omission: ArtifactTraversalOmission) => void;
|
||||
}
|
||||
|
||||
/** Read-only adapter over one directory, archive, or virtual container. */
|
||||
export interface ArtifactReader {
|
||||
readonly format: "directory" | ZipPackageFormat | "asar" | "file";
|
||||
entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry>;
|
||||
entries(
|
||||
signal?: AbortSignal,
|
||||
options?: ArtifactTraversalOptions,
|
||||
): AsyncIterable<ArtifactEntry>;
|
||||
open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable>;
|
||||
provenance(): readonly ArtifactCommand[];
|
||||
close(): Promise<void>;
|
||||
|
||||
@@ -14,12 +14,18 @@ import {
|
||||
sameArtifactSourceIdentity,
|
||||
type ArtifactEntry,
|
||||
type ArtifactReader,
|
||||
type ArtifactTraversalOmission,
|
||||
type ArtifactTraversalOptions,
|
||||
} from "./ArtifactReader.js";
|
||||
import { hostExecutableMarking } from "./hostExecutableMarking.js";
|
||||
import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
|
||||
|
||||
interface DirectoryTraversalOptions extends ArtifactTraversalOptions {
|
||||
readonly descendInto?: (path: string) => boolean;
|
||||
}
|
||||
|
||||
/** Non-symlink-following directory reader. */
|
||||
export class DirectoryArtifactReader implements ArtifactReader {
|
||||
readonly format = "directory" as const;
|
||||
@@ -35,9 +41,11 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
/** Enumerate entries, optionally restricting descent while retaining directories. */
|
||||
async *entries(
|
||||
signal?: AbortSignal,
|
||||
descendInto?: (path: string) => boolean,
|
||||
selection?: DirectoryTraversalOptions | ((path: string) => boolean),
|
||||
): AsyncIterable<ArtifactEntry> {
|
||||
const root = await this.#resolveRoot();
|
||||
const options =
|
||||
typeof selection === "function" ? { descendInto: selection } : selection;
|
||||
const pending = [root];
|
||||
while (pending.length > 0) {
|
||||
abortIfNeeded(signal);
|
||||
@@ -50,7 +58,7 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
directory,
|
||||
pending,
|
||||
signal,
|
||||
descendInto,
|
||||
options,
|
||||
))
|
||||
yield entry;
|
||||
await withDirectoryIoContext("inspect directory", directory, () =>
|
||||
@@ -75,7 +83,7 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
directory: string,
|
||||
pending: string[],
|
||||
signal?: AbortSignal,
|
||||
descendInto?: (path: string) => boolean,
|
||||
options?: DirectoryTraversalOptions,
|
||||
): AsyncIterable<ArtifactEntry> {
|
||||
const root = await this.#resolveRoot();
|
||||
const handle = new OwnedDirectoryHandle(
|
||||
@@ -103,10 +111,16 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
);
|
||||
const path = relative(root, absolute).split(sep).join("/");
|
||||
const entry = artifactEntry(path, absolute, metadata);
|
||||
if (entry === undefined) continue;
|
||||
if (entry === undefined) {
|
||||
options?.onOmission?.({ path, kind: unsupportedEntryKind(metadata) });
|
||||
continue;
|
||||
}
|
||||
yield entry;
|
||||
if (entry.kind === "directory" && (descendInto?.(path) ?? true))
|
||||
childDirectories.push(absolute);
|
||||
if (entry.kind === "directory") {
|
||||
if (options?.descendInto?.(path) ?? true)
|
||||
childDirectories.push(absolute);
|
||||
else options?.onOmission?.({ path, kind: "directory-descendants" });
|
||||
}
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
traversal = {
|
||||
@@ -256,6 +270,19 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
}
|
||||
}
|
||||
|
||||
const unsupportedEntryKind = (
|
||||
metadata: BigIntStats,
|
||||
): ArtifactTraversalOmission["kind"] =>
|
||||
metadata.isFIFO()
|
||||
? "fifo"
|
||||
: metadata.isSocket()
|
||||
? "socket"
|
||||
: metadata.isBlockDevice()
|
||||
? "block-device"
|
||||
: metadata.isCharacterDevice()
|
||||
? "character-device"
|
||||
: "unsupported";
|
||||
|
||||
const artifactEntry = (
|
||||
path: string,
|
||||
absolute: string,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ArtifactReaderFailure,
|
||||
type ArtifactEntry,
|
||||
type ArtifactReader,
|
||||
type ArtifactTraversalOptions,
|
||||
} from "./ArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
|
||||
import {
|
||||
@@ -149,13 +150,24 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
this.host = host ?? createSystemHost(environment);
|
||||
}
|
||||
|
||||
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
|
||||
async *entries(
|
||||
signal?: AbortSignal,
|
||||
options?: ArtifactTraversalOptions,
|
||||
): AsyncIterable<ArtifactEntry> {
|
||||
await this.#ensureAttached(signal);
|
||||
const directory = this.#directory;
|
||||
if (directory === undefined)
|
||||
throw new ArtifactReaderFailure("unavailable", "DMG is not attached");
|
||||
const prefix = basename(this.path);
|
||||
const entries = directory.entries(signal)[Symbol.asyncIterator]();
|
||||
const entries = directory
|
||||
.entries(signal, {
|
||||
onOmission: (omission) =>
|
||||
options?.onOmission?.({
|
||||
...omission,
|
||||
path: `${prefix}/${omission.path}`,
|
||||
}),
|
||||
})
|
||||
[Symbol.asyncIterator]();
|
||||
try {
|
||||
for (;;) {
|
||||
if (this.#closed) return;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
||||
import type { ArtifactReader } from "../ArtifactReader.js";
|
||||
import type {
|
||||
ArtifactReader,
|
||||
ArtifactReaderTraversal,
|
||||
} from "../ArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js";
|
||||
import { NativeDmgArtifactReader } from "../NativeDmgArtifactReader.js";
|
||||
@@ -7,7 +10,6 @@ import { ZipArtifactReader } from "../ZipArtifactReader.js";
|
||||
import type {
|
||||
ArtifactInventoryCoverage,
|
||||
ArtifactOccurrence,
|
||||
IntegrityContradiction,
|
||||
} from "../../domain/artifactGraph.js";
|
||||
import {
|
||||
isZipPackageFormat,
|
||||
@@ -40,42 +42,47 @@ export const createReader = (
|
||||
}
|
||||
};
|
||||
|
||||
/** Classify source coverage and preserve its user-facing explanation once. */
|
||||
export const inventoryAssessment = (
|
||||
/** Decide traversal scope and availability once, independently of diagnostic wording. */
|
||||
export const inventoryCoverage = (
|
||||
format: ArtifactOccurrence["artifact_format"],
|
||||
reader: ArtifactReader | undefined,
|
||||
integrityContradictions: readonly IntegrityContradiction[],
|
||||
caseCollisionLimitation: string | undefined,
|
||||
traversal: ArtifactReaderTraversal,
|
||||
): {
|
||||
readonly coverage_status: ArtifactInventoryCoverage;
|
||||
readonly coverage: ArtifactInventoryCoverage;
|
||||
readonly limitations: string[];
|
||||
} => {
|
||||
let coverageStatus: ArtifactInventoryCoverage = "complete";
|
||||
const limitations: string[] = [];
|
||||
if (reader !== undefined) {
|
||||
if (isZipPackageFormat(reader.format))
|
||||
limitations.push(ZIP_NON_ENTRY_TAIL_LIMITATION);
|
||||
} else if (format === "dmg" || format === "pkg") {
|
||||
coverageStatus = "partial";
|
||||
limitations.push(
|
||||
`${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`,
|
||||
);
|
||||
} else if (format === "mach-o-universal" && process.platform !== "darwin") {
|
||||
coverageStatus = "partial";
|
||||
limitations.push(
|
||||
"Universal Mach-O slices require the native macOS lipo adapter.",
|
||||
);
|
||||
} else {
|
||||
// The graph's modeled scope is the root itself for recognized leaf formats.
|
||||
limitations.push("Artifact has no child container entries.");
|
||||
}
|
||||
if (caseCollisionLimitation !== undefined) {
|
||||
coverageStatus = "partial";
|
||||
limitations.push(caseCollisionLimitation);
|
||||
}
|
||||
if (integrityContradictions.length > 0)
|
||||
limitations.push(
|
||||
`${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
|
||||
);
|
||||
return { coverage_status: coverageStatus, limitations };
|
||||
if (reader !== undefined)
|
||||
return {
|
||||
coverage: { scope: "container-entries", status: traversal.status },
|
||||
limitations: [
|
||||
...(isZipPackageFormat(reader.format)
|
||||
? [ZIP_NON_ENTRY_TAIL_LIMITATION]
|
||||
: []),
|
||||
...traversal.limitations,
|
||||
],
|
||||
};
|
||||
// dmg and pkg reach here only after classifyRootFormat matched their bytes.
|
||||
if (format === "dmg" || format === "pkg")
|
||||
return {
|
||||
coverage: { scope: "container-entries", status: "unknown" },
|
||||
limitations: [
|
||||
`${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`,
|
||||
],
|
||||
};
|
||||
if (format === "mach-o-universal")
|
||||
return {
|
||||
coverage: { scope: "container-entries", status: "unknown" },
|
||||
limitations: [
|
||||
"Universal Mach-O slices require the native macOS lipo adapter.",
|
||||
],
|
||||
};
|
||||
if (isZipPackageFormat(format) || format === "directory" || format === "asar")
|
||||
return {
|
||||
coverage: { scope: "container-entries", status: "unknown" },
|
||||
limitations: [`${format} child inventory reader is unavailable.`],
|
||||
};
|
||||
return {
|
||||
coverage: { scope: "root", status: "complete" },
|
||||
limitations: ["Artifact has no child container entries."],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { RootInventorySource } from "./classify.js";
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
type ArtifactReader,
|
||||
type ArtifactReaderTraversal,
|
||||
} from "../ArtifactReader.js";
|
||||
import type { ArtifactResourceOwner } from "../ArtifactResourceScope.js";
|
||||
import {
|
||||
@@ -40,7 +41,7 @@ import {
|
||||
hashStableRootArtifact,
|
||||
hashStableRootArtifactHandle,
|
||||
} from "./hashStableRootArtifact.js";
|
||||
import { createReader, inventoryAssessment } from "./reader.js";
|
||||
import { createReader, inventoryCoverage } from "./reader.js";
|
||||
import {
|
||||
scanReader,
|
||||
type PendingIntegrityContradiction,
|
||||
@@ -99,6 +100,7 @@ export const scanCanonicalArtifactInventoryInScope = async (
|
||||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation,
|
||||
traversal,
|
||||
} = await scanReader(reader, ownedReaders, options.signal, integrity);
|
||||
outcome = {
|
||||
kind: "completed",
|
||||
@@ -115,6 +117,7 @@ export const scanCanonicalArtifactInventoryInScope = async (
|
||||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation,
|
||||
traversal,
|
||||
}),
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
@@ -196,6 +199,7 @@ interface SnapshotBuildInput {
|
||||
readonly occurrences: MutableOccurrence[];
|
||||
readonly pendingContradictions: PendingIntegrityContradiction[];
|
||||
readonly caseCollisionLimitation: string | undefined;
|
||||
readonly traversal: ArtifactReaderTraversal;
|
||||
}
|
||||
|
||||
const buildInventorySnapshot = async (
|
||||
@@ -268,12 +272,7 @@ const buildInventorySnapshot = async (
|
||||
orderedOccurrences,
|
||||
orderedEdges,
|
||||
});
|
||||
const assessment = inventoryAssessment(
|
||||
rootFormat,
|
||||
reader,
|
||||
integrityContradictions,
|
||||
input.caseCollisionLimitation,
|
||||
);
|
||||
const traversal = inventoryCoverage(rootFormat, reader, input.traversal);
|
||||
return {
|
||||
manifest,
|
||||
nodes: orderedNodes,
|
||||
@@ -281,8 +280,14 @@ const buildInventorySnapshot = async (
|
||||
edges: orderedEdges,
|
||||
provenance: reader?.provenance() ?? [],
|
||||
integrity_contradictions: integrityContradictions,
|
||||
coverage_status: assessment.coverage_status,
|
||||
limitations: assessment.limitations,
|
||||
coverage: traversal.coverage,
|
||||
coverage_status:
|
||||
traversal.coverage.status === "complete" ? "complete" : "partial",
|
||||
limitations: buildLimitations(
|
||||
traversal.limitations,
|
||||
integrityContradictions,
|
||||
input.caseCollisionLimitation,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -395,3 +400,17 @@ const buildManifest = ({
|
||||
occurrence_count: orderedOccurrences.length,
|
||||
edge_count: orderedEdges.length,
|
||||
});
|
||||
|
||||
const buildLimitations = (
|
||||
scopeLimitations: readonly string[],
|
||||
integrityContradictions: readonly IntegrityContradiction[],
|
||||
caseCollisionLimitation: string | undefined,
|
||||
): string[] => [
|
||||
...scopeLimitations,
|
||||
...(caseCollisionLimitation === undefined ? [] : [caseCollisionLimitation]),
|
||||
...(integrityContradictions.length === 0
|
||||
? []
|
||||
: [
|
||||
`${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
|
||||
]),
|
||||
];
|
||||
|
||||
@@ -9,9 +9,12 @@ import {
|
||||
ArtifactReaderFailure,
|
||||
type ArtifactEntry,
|
||||
type ArtifactReader,
|
||||
type ArtifactReaderTraversal,
|
||||
type ArtifactTraversalOmission,
|
||||
} from "../ArtifactReader.js";
|
||||
import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
||||
import type { ArtifactNode } from "../../domain/artifactGraph.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
import {
|
||||
classifyArtifactContent,
|
||||
classifyArtifactBytes,
|
||||
@@ -55,6 +58,7 @@ interface ScanContext {
|
||||
readonly registry: ArtifactPathRegistry;
|
||||
readonly expandedContainerIds: Set<string>;
|
||||
readonly ownedReaders: ArtifactReader[];
|
||||
readonly omissions: ArtifactTraversalOmission[];
|
||||
}
|
||||
|
||||
export const scanReader = async (
|
||||
@@ -67,6 +71,7 @@ export const scanReader = async (
|
||||
readonly occurrences: MutableOccurrence[];
|
||||
readonly pendingContradictions: PendingIntegrityContradiction[];
|
||||
readonly caseCollisionLimitation: string | undefined;
|
||||
readonly traversal: ArtifactReaderTraversal;
|
||||
}> => {
|
||||
const { nodes, occurrences, pendingContradictions } = emptyScan();
|
||||
if (reader === undefined)
|
||||
@@ -75,6 +80,7 @@ export const scanReader = async (
|
||||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation: undefined,
|
||||
traversal: { status: "complete", limitations: [] },
|
||||
};
|
||||
const context: ScanContext = {
|
||||
reader,
|
||||
@@ -87,6 +93,7 @@ export const scanReader = async (
|
||||
registry: new ArtifactPathRegistry(),
|
||||
expandedContainerIds: new Set<string>(),
|
||||
ownedReaders,
|
||||
omissions: [],
|
||||
};
|
||||
await visitArtifactEntries(context, reader, "");
|
||||
// Archive directories may appear after their children. Resolve containment
|
||||
@@ -101,9 +108,37 @@ export const scanReader = async (
|
||||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation: annotateEnglishUnicodeCaseCollisions(occurrences),
|
||||
traversal: {
|
||||
status: context.omissions.length === 0 ? "complete" : "unknown",
|
||||
limitations: [
|
||||
...new Set(
|
||||
context.omissions.map(
|
||||
({ path, kind }) =>
|
||||
`Artifact traversal omitted ${kind} at ${JSON.stringify(path)}.`,
|
||||
),
|
||||
),
|
||||
].sort(compareUnicodeCodePoints),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const inventoryEntries = (
|
||||
context: ScanContext,
|
||||
reader: ArtifactReader,
|
||||
prefix: string,
|
||||
): AsyncIterator<ArtifactEntry> =>
|
||||
reader
|
||||
.entries(context.signal, {
|
||||
onOmission: (omission) =>
|
||||
context.omissions.push({
|
||||
...omission,
|
||||
path: normalizeArtifactPath(
|
||||
prefix.length === 0 ? omission.path : `${prefix}/${omission.path}`,
|
||||
),
|
||||
}),
|
||||
})
|
||||
[Symbol.asyncIterator]();
|
||||
|
||||
const visitArtifactEntries = async (
|
||||
context: ScanContext,
|
||||
currentReader: ArtifactReader,
|
||||
@@ -117,7 +152,7 @@ const visitArtifactEntries = async (
|
||||
{
|
||||
reader: currentReader,
|
||||
prefix,
|
||||
iterator: currentReader.entries(context.signal)[Symbol.asyncIterator](),
|
||||
iterator: inventoryEntries(context, currentReader, prefix),
|
||||
},
|
||||
];
|
||||
let failure: { readonly cause: unknown } | undefined;
|
||||
@@ -191,7 +226,7 @@ const visitArtifactEntries = async (
|
||||
stack.push({
|
||||
reader: nested,
|
||||
prefix: logicalPath,
|
||||
iterator: nested.entries(context.signal)[Symbol.asyncIterator](),
|
||||
iterator: inventoryEntries(context, nested, logicalPath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export const androidApplicationProjectionResultSchema = z.strictObject({
|
||||
// Count completeness is separate from the declared coverage status.
|
||||
inventory_complete: z.boolean(),
|
||||
}),
|
||||
limitations: z.array(z.string().min(1)),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type AndroidApplicationProjectionInput = z.infer<
|
||||
@@ -99,7 +99,8 @@ export const projectAndroidApplication = (
|
||||
);
|
||||
const components = classified;
|
||||
const limitations = [
|
||||
...(!hasCompleteInventoryCoverage(inventory)
|
||||
...inventory.limitations,
|
||||
...(!hasCompleteInventoryCoverage(inventory, "container-entries")
|
||||
? ["Source inventory coverage is partial; absence is unknown."]
|
||||
: []),
|
||||
"Manifest, resource, signing, and bytecode semantics require a dedicated Android provider; this projection reports exact inventory paths and hashes only.",
|
||||
|
||||
@@ -94,7 +94,7 @@ export const appleApplicationProjectionResultSchema = z.strictObject({
|
||||
// Count completeness is separate from the declared coverage status.
|
||||
inventory_complete: z.boolean(),
|
||||
}),
|
||||
limitations: z.array(z.string().min(1)),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type AppleApplicationProjectionInput = z.infer<
|
||||
@@ -169,18 +169,24 @@ export const projectAppleApplication = (
|
||||
);
|
||||
const uninventoriedImage =
|
||||
rootFormat === "dmg" && inventory.occurrences.length === 1;
|
||||
const inventoryComplete = hasCompleteInventoryCoverage(inventory);
|
||||
const inventoryComplete = hasCompleteInventoryCoverage(
|
||||
inventory,
|
||||
"container-entries",
|
||||
);
|
||||
const complete = inventoryComplete && !uninventoriedImage;
|
||||
const limitations = projectionLimitations({
|
||||
complete: inventoryComplete,
|
||||
uninventoriedImage,
|
||||
roots,
|
||||
bundles,
|
||||
sidecars: entries.some(({ path }) => isSidecar(path)),
|
||||
symlinks: entries.some(({ kind }) => kind === "symlink"),
|
||||
unattributed,
|
||||
bridgeCandidateCoverage: bridgeProjection.coverage,
|
||||
});
|
||||
const limitations = [
|
||||
...inventory.limitations,
|
||||
...projectionLimitations({
|
||||
complete: inventoryComplete,
|
||||
uninventoriedImage,
|
||||
roots,
|
||||
bundles,
|
||||
sidecars: entries.some(({ path }) => isSidecar(path)),
|
||||
symlinks: entries.some(({ kind }) => kind === "symlink"),
|
||||
unattributed,
|
||||
bridgeCandidateCoverage: bridgeProjection.coverage,
|
||||
}),
|
||||
];
|
||||
const withoutId = {
|
||||
root_sha256: inventory.manifest.root_sha256,
|
||||
root_format: rootFormat,
|
||||
|
||||
@@ -104,13 +104,51 @@ export const compareArtifacts = (
|
||||
const right = parseArtifactInventoryEvidence(rightEvidence);
|
||||
const leftComplete = left.inventory.complete;
|
||||
const rightComplete = right.inventory.complete;
|
||||
const leftCovered = hasCompleteInventoryCoverage(left.inventory);
|
||||
const rightCovered = hasCompleteInventoryCoverage(right.inventory);
|
||||
const sameScope =
|
||||
left.inventory.coverage.scope === right.inventory.coverage.scope;
|
||||
const rootCoverageComplete =
|
||||
sameScope &&
|
||||
left.inventory.complete &&
|
||||
right.inventory.complete &&
|
||||
left.inventory.coverage.status === "complete" &&
|
||||
right.inventory.coverage.status === "complete";
|
||||
const entryCoverageComplete =
|
||||
rootCoverageComplete &&
|
||||
left.inventory.coverage.scope === "container-entries";
|
||||
const leftCovered =
|
||||
sameScope &&
|
||||
hasCompleteInventoryCoverage(left.inventory, left.inventory.coverage.scope);
|
||||
const rightCovered =
|
||||
sameScope &&
|
||||
hasCompleteInventoryCoverage(
|
||||
right.inventory,
|
||||
right.inventory.coverage.scope,
|
||||
);
|
||||
const fullyCovered =
|
||||
leftCovered &&
|
||||
rightCovered &&
|
||||
(entryCoverageComplete ||
|
||||
(rootCoverageComplete &&
|
||||
left.inventory.manifest.occurrence_count === 1 &&
|
||||
right.inventory.manifest.occurrence_count === 1));
|
||||
const limitations = [
|
||||
...left.inventory.limitations.map((item) => `Left: ${item}`),
|
||||
...right.inventory.limitations.map((item) => `Right: ${item}`),
|
||||
...(leftComplete ? [] : ["Left artifact inventory is incomplete."]),
|
||||
...(rightComplete ? [] : ["Right artifact inventory is incomplete."]),
|
||||
...(sameScope ? [] : ["Artifact inventory scopes differ."]),
|
||||
...(sameScope &&
|
||||
left.inventory.coverage.scope === "root" &&
|
||||
(left.inventory.manifest.occurrence_count > 1 ||
|
||||
right.inventory.manifest.occurrence_count > 1)
|
||||
? ["Container entry coverage is unavailable for child paths."]
|
||||
: []),
|
||||
...(left.inventory.coverage.status === "complete"
|
||||
? []
|
||||
: ["Left artifact inventory traversal coverage is unknown."]),
|
||||
...(right.inventory.coverage.status === "complete"
|
||||
? []
|
||||
: ["Right artifact inventory traversal coverage is unknown."]),
|
||||
];
|
||||
const changes = compareOccurrences(
|
||||
left.inventory,
|
||||
@@ -119,7 +157,7 @@ export const compareArtifacts = (
|
||||
...left.evidence.map(({ evidence_id: id }) => id),
|
||||
...right.evidence.map(({ evidence_id: id }) => id),
|
||||
],
|
||||
leftCovered && rightCovered,
|
||||
{ root: rootCoverageComplete, entries: entryCoverageComplete },
|
||||
);
|
||||
applyRootHashChange(left.inventory, right.inventory, changes, [
|
||||
...left.evidence.map(({ evidence_id: id }) => id),
|
||||
@@ -129,7 +167,7 @@ export const compareArtifacts = (
|
||||
left.inventory,
|
||||
right.inventory,
|
||||
changes,
|
||||
leftCovered && rightCovered,
|
||||
fullyCovered,
|
||||
);
|
||||
return artifactComparisonResultSchema.parse({
|
||||
status:
|
||||
@@ -139,7 +177,7 @@ export const compareArtifacts = (
|
||||
({ classification }) => classification === "contradiction",
|
||||
)
|
||||
? "contradiction"
|
||||
: !leftCovered || !rightCovered
|
||||
: !fullyCovered
|
||||
? "unknown"
|
||||
: changes.some(({ classification }) => classification === "unknown")
|
||||
? "unknown"
|
||||
@@ -158,7 +196,7 @@ const compareOccurrences = (
|
||||
left: InventorySet,
|
||||
right: InventorySet,
|
||||
evidenceLinks: readonly string[],
|
||||
complete: boolean,
|
||||
coverage: { readonly root: boolean; readonly entries: boolean },
|
||||
): ArtifactChange[] => {
|
||||
const leftByPath = new Map(
|
||||
left.occurrences.map((item) => [item.logical_path, item]),
|
||||
@@ -187,7 +225,8 @@ const compareOccurrences = (
|
||||
rightNode: nodeFor(rightOccurrence, rightNodes),
|
||||
leftRelations: leftRelations.get(path) ?? [],
|
||||
rightRelations: rightRelations.get(path) ?? [],
|
||||
complete,
|
||||
rootCovered: coverage.root,
|
||||
entriesCovered: coverage.entries,
|
||||
evidenceLinks,
|
||||
});
|
||||
if (change !== null) output.push(change);
|
||||
@@ -203,7 +242,8 @@ interface ClassifyPathInput {
|
||||
readonly rightNode: ArtifactNode | undefined;
|
||||
readonly leftRelations: readonly RelationProjection[];
|
||||
readonly rightRelations: readonly RelationProjection[];
|
||||
readonly complete: boolean;
|
||||
readonly rootCovered: boolean;
|
||||
readonly entriesCovered: boolean;
|
||||
readonly evidenceLinks: readonly string[];
|
||||
}
|
||||
|
||||
@@ -217,17 +257,18 @@ const buildArtifactChangeBase = (input: ClassifyPathInput) => ({
|
||||
});
|
||||
|
||||
const classifyPresence = (input: ClassifyPathInput): ArtifactChange | null => {
|
||||
const covered = input.path === "." ? input.rootCovered : input.entriesCovered;
|
||||
if (input.leftOccurrence === undefined)
|
||||
return {
|
||||
...buildArtifactChangeBase(input),
|
||||
classification: input.complete ? "added" : "unknown",
|
||||
dimensions: [input.complete ? "content" : "availability"],
|
||||
classification: covered ? "added" : "unknown",
|
||||
dimensions: [covered ? "content" : "availability"],
|
||||
};
|
||||
if (input.rightOccurrence === undefined)
|
||||
return {
|
||||
...buildArtifactChangeBase(input),
|
||||
classification: input.complete ? "removed" : "unknown",
|
||||
dimensions: [input.complete ? "content" : "availability"],
|
||||
classification: covered ? "removed" : "unknown",
|
||||
dimensions: [covered ? "content" : "availability"],
|
||||
};
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -188,8 +188,11 @@ export const artifactGraphManifestSchema = z.object({
|
||||
edge_count: z.number().int().min(0),
|
||||
});
|
||||
|
||||
/** Whether the inventory producer covered its modeled child-entry scope. */
|
||||
export const artifactInventoryCoverageSchema = z.enum(["complete", "partial"]);
|
||||
/** Producer-owned traversal coverage; content certainty remains on each occurrence. */
|
||||
export const artifactInventoryCoverageSchema = z.strictObject({
|
||||
scope: z.enum(["root", "container-entries"]),
|
||||
status: z.enum(["complete", "unknown"]),
|
||||
});
|
||||
export type ArtifactInventoryCoverage = z.infer<
|
||||
typeof artifactInventoryCoverageSchema
|
||||
>;
|
||||
@@ -202,15 +205,19 @@ export const artifactInventoryResultSchema = z.object({
|
||||
edges: z.array(artifactEdgeSchema),
|
||||
provenance: z.array(artifactCommandSchema),
|
||||
integrity_contradictions: z.array(integrityContradictionSchema).default([]),
|
||||
// Omitted by older retained Evidence; the inventory parser owns its compatibility policy.
|
||||
coverage: artifactInventoryCoverageSchema.optional(),
|
||||
limitations: z.array(z.string()),
|
||||
/** Optional only for persisted inventory Evidence written before this field. */
|
||||
coverage_status: artifactInventoryCoverageSchema.optional(),
|
||||
coverage_status: z.enum(["complete", "partial"]).optional(),
|
||||
});
|
||||
|
||||
/** Current inventory producers always classify the coverage they examined. */
|
||||
/** Current producers always declare coverage; historical Evidence inputs may omit it. */
|
||||
export const artifactInventoryOutputSchema =
|
||||
artifactInventoryResultSchema.extend({
|
||||
coverage_status: artifactInventoryCoverageSchema,
|
||||
artifactInventoryResultSchema.safeExtend({
|
||||
coverage: artifactInventoryCoverageSchema,
|
||||
coverage_status:
|
||||
artifactInventoryResultSchema.shape.coverage_status.unwrap(),
|
||||
});
|
||||
export type ArtifactInventoryOutput = z.infer<
|
||||
typeof artifactInventoryOutputSchema
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
} from "./artifactIdentity.js";
|
||||
import { parseEvidence, type Evidence } from "./evidence.js";
|
||||
import { artifactInspectionResultSchema } from "./artifactInspection.js";
|
||||
import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "./zipPackageFormat.js";
|
||||
import {
|
||||
isZipPackageFormat,
|
||||
ZIP_NON_ENTRY_TAIL_LIMITATION,
|
||||
} from "./zipPackageFormat.js";
|
||||
|
||||
/** Complete or partially assembled inventory used by comparison workflows. */
|
||||
export interface InventorySet {
|
||||
@@ -30,13 +33,17 @@ export interface InventorySet {
|
||||
readonly limitations: readonly string[];
|
||||
/** True when merged node, occurrence, and edge counts match the manifest. */
|
||||
readonly complete: boolean;
|
||||
readonly coverage_status: ArtifactInventoryCoverage;
|
||||
readonly coverage: ArtifactInventoryCoverage;
|
||||
}
|
||||
|
||||
/** Whether all graph members are present and the producer reports full coverage. */
|
||||
export const hasCompleteInventoryCoverage = (
|
||||
inventory: InventorySet,
|
||||
): boolean => inventory.complete && inventory.coverage_status === "complete";
|
||||
requiredScope: ArtifactInventoryCoverage["scope"],
|
||||
): boolean =>
|
||||
inventory.complete &&
|
||||
inventory.coverage.scope === requiredScope &&
|
||||
inventory.coverage.status === "complete";
|
||||
|
||||
export interface ParsedInventorySet {
|
||||
readonly evidence: readonly Evidence[];
|
||||
@@ -132,11 +139,14 @@ const assembleInventoryResults = (
|
||||
const first = pages[0];
|
||||
if (first === undefined)
|
||||
throw new TypeError("Artifact inventory requires Evidence pages");
|
||||
const coverageStatuses = new Set(pages.map(resolveInventoryCoverageStatus));
|
||||
if (coverageStatuses.size !== 1)
|
||||
throw new TypeError("Artifact inventory pages disagree on coverage status");
|
||||
const coverageStatus = [...coverageStatuses][0]!;
|
||||
const manifestJson = canonicalJson(first.manifest, "Artifact inventory");
|
||||
const coverageJson = canonicalJson(
|
||||
{
|
||||
coverage: first.coverage ?? null,
|
||||
coverage_status: first.coverage_status ?? null,
|
||||
},
|
||||
"Artifact inventory coverage",
|
||||
);
|
||||
const nodes = new Map<string, ArtifactNode>();
|
||||
const occurrences = new Map<string, ArtifactOccurrence>();
|
||||
const paths = new Map<string, ArtifactOccurrence>();
|
||||
@@ -144,6 +154,18 @@ const assembleInventoryResults = (
|
||||
for (const page of pages) {
|
||||
if (canonicalJson(page.manifest, "Artifact inventory") !== manifestJson)
|
||||
throw new TypeError("Artifact inventory pages do not share one manifest");
|
||||
if (
|
||||
canonicalJson(
|
||||
{
|
||||
coverage: page.coverage ?? null,
|
||||
coverage_status: page.coverage_status ?? null,
|
||||
},
|
||||
"Artifact inventory coverage",
|
||||
) !== coverageJson
|
||||
)
|
||||
throw new TypeError(
|
||||
"Artifact inventory pages do not share one coverage declaration",
|
||||
);
|
||||
if (
|
||||
canonicalJson(page.integrity_contradictions, "Artifact inventory") !==
|
||||
canonicalJson(first.integrity_contradictions, "Artifact inventory")
|
||||
@@ -165,12 +187,11 @@ const assembleInventoryResults = (
|
||||
for (const edge of page.edges)
|
||||
mergeExact(edges, edge.edge_id, edge, "edge");
|
||||
}
|
||||
const inventory = orderedInventory(
|
||||
first,
|
||||
pages,
|
||||
{ nodes, occurrences, edges },
|
||||
coverageStatus,
|
||||
);
|
||||
const inventory = orderedInventory(first, pages, {
|
||||
nodes,
|
||||
occurrences,
|
||||
edges,
|
||||
});
|
||||
if (inventory.complete) validateCompleteInventory(inventory);
|
||||
return inventory;
|
||||
};
|
||||
@@ -186,7 +207,6 @@ const orderedInventory = (
|
||||
ArtifactInventoryResult["edges"][number]
|
||||
>;
|
||||
},
|
||||
coverageStatus: ArtifactInventoryCoverage,
|
||||
): InventorySet => {
|
||||
const nodes = [...values.nodes.values()].sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.artifact_id, right.artifact_id),
|
||||
@@ -197,40 +217,78 @@ const orderedInventory = (
|
||||
const edges = [...values.edges.values()].sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.edge_id, right.edge_id),
|
||||
);
|
||||
const limitations = [
|
||||
...new Set(pages.flatMap((inventory) => inventory.limitations)),
|
||||
].sort(compareUnicodeCodePoints);
|
||||
return {
|
||||
manifest: first.manifest,
|
||||
nodes,
|
||||
occurrences,
|
||||
edges,
|
||||
integrityContradictions: first.integrity_contradictions,
|
||||
limitations: [
|
||||
...new Set(pages.flatMap((inventory) => inventory.limitations)),
|
||||
].sort(compareUnicodeCodePoints),
|
||||
limitations,
|
||||
coverage: resolveInventoryCoverage({ ...first, limitations }),
|
||||
complete:
|
||||
nodes.length === first.manifest.node_count &&
|
||||
occurrences.length === first.manifest.occurrence_count &&
|
||||
edges.length === first.manifest.edge_count,
|
||||
coverage_status: coverageStatus,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveInventoryCoverageStatus = (
|
||||
// Normalize both retained coverage formats at this owning boundary.
|
||||
const resolveInventoryCoverage = (
|
||||
inventory: ArtifactInventoryResult,
|
||||
): ArtifactInventoryCoverage => {
|
||||
if (inventory.coverage_status !== undefined) return inventory.coverage_status;
|
||||
// Older retained Evidence has no typed status. Keep only its known
|
||||
// informational notices complete; unknown limitation text fails closed.
|
||||
return inventory.limitations.every(isKnownNonCoverageNotice)
|
||||
? "complete"
|
||||
: "partial";
|
||||
const legacy = legacyInventoryCoverage(
|
||||
inventory.manifest.root_format,
|
||||
inventory.limitations,
|
||||
inventory.integrity_contradictions.length,
|
||||
);
|
||||
if (inventory.coverage !== undefined) {
|
||||
if (
|
||||
inventory.coverage_status !== undefined &&
|
||||
(inventory.coverage.status === "complete") !==
|
||||
(inventory.coverage_status === "complete")
|
||||
)
|
||||
throw new TypeError(
|
||||
"Artifact inventory has conflicting coverage declarations",
|
||||
);
|
||||
return inventory.coverage;
|
||||
}
|
||||
return inventory.coverage_status === undefined
|
||||
? legacy
|
||||
: {
|
||||
...legacy,
|
||||
status:
|
||||
inventory.coverage_status === "complete" ? "complete" : "unknown",
|
||||
};
|
||||
};
|
||||
|
||||
const isKnownNonCoverageNotice = (limitation: string): boolean =>
|
||||
limitation === "Artifact has no child container entries." ||
|
||||
limitation === ZIP_NON_ENTRY_TAIL_LIMITATION ||
|
||||
/^\d+ integrity contradiction\(s\) were recorded; mismatched content is observed-untrusted\.$/u.test(
|
||||
limitation,
|
||||
);
|
||||
// Retained pre-coverage records cannot classify arbitrary prose. Recognize only
|
||||
// the historical producer vocabulary here; unfamiliar diagnostics remain unknown.
|
||||
const legacyInventoryCoverage = (
|
||||
format: ArtifactInventoryResult["manifest"]["root_format"],
|
||||
limitations: readonly string[],
|
||||
contradictionCount: number,
|
||||
): ArtifactInventoryCoverage => ({
|
||||
scope:
|
||||
isZipPackageFormat(format) ||
|
||||
["directory", "asar", "dmg", "pkg", "mach-o-universal"].includes(format)
|
||||
? "container-entries"
|
||||
: "root",
|
||||
status: limitations.some(
|
||||
(limitation) =>
|
||||
limitation !== "Artifact has no child container entries." &&
|
||||
limitation !== ZIP_NON_ENTRY_TAIL_LIMITATION &&
|
||||
!(
|
||||
contradictionCount > 0 &&
|
||||
limitation ===
|
||||
`${String(contradictionCount)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`
|
||||
),
|
||||
)
|
||||
? "unknown"
|
||||
: "complete",
|
||||
});
|
||||
|
||||
const validateCompleteInventory = (inventory: InventorySet): void => {
|
||||
const nodeIds = new Set(inventory.nodes.map(({ artifact_id: id }) => id));
|
||||
|
||||
@@ -7,7 +7,9 @@ import type {
|
||||
|
||||
/** Immutable inventory produced by one complete artifact scan. */
|
||||
export interface ArtifactInventorySnapshot {
|
||||
readonly coverage_status: ArtifactInventoryCoverage;
|
||||
readonly coverage_status: NonNullable<
|
||||
ArtifactInventoryResult["coverage_status"]
|
||||
>;
|
||||
readonly manifest: ArtifactInventoryResult["manifest"];
|
||||
readonly nodes: readonly ArtifactNode[];
|
||||
readonly occurrences: ArtifactInventoryResult["occurrences"];
|
||||
@@ -16,5 +18,6 @@ export interface ArtifactInventorySnapshot {
|
||||
ArtifactInventoryResult["provenance"][number]
|
||||
>;
|
||||
readonly integrity_contradictions: readonly IntegrityContradiction[];
|
||||
readonly coverage?: ArtifactInventoryCoverage;
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export const harmonyApplicationProjectionResultSchema = z.strictObject({
|
||||
// Count completeness is separate from the declared coverage status.
|
||||
inventory_complete: z.boolean(),
|
||||
}),
|
||||
limitations: z.array(z.string().min(1)),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type HarmonyApplicationProjectionInput = z.infer<
|
||||
@@ -112,7 +112,8 @@ export const projectHarmonyApplication = (
|
||||
classified.native_libraries,
|
||||
);
|
||||
const limitations = [
|
||||
...(!hasCompleteInventoryCoverage(inventory)
|
||||
...inventory.limitations,
|
||||
...(!hasCompleteInventoryCoverage(inventory, "container-entries")
|
||||
? ["Source inventory coverage is partial; absence is unknown."]
|
||||
: []),
|
||||
...(unavailable.length > 0
|
||||
|
||||
@@ -56,7 +56,8 @@ export const projectionCoverage = (
|
||||
inventory_complete: boolean;
|
||||
} => ({
|
||||
status:
|
||||
hasCompleteInventoryCoverage(inventory) && budgetComplete
|
||||
hasCompleteInventoryCoverage(inventory, "container-entries") &&
|
||||
budgetComplete
|
||||
? ("complete-within-inventory" as const)
|
||||
: ("partial" as const),
|
||||
inventory_complete: inventory.complete,
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import {
|
||||
artifactInventoryResultSchema,
|
||||
type ArtifactInventoryResult,
|
||||
} from "../../../src/domain/artifactGraph.js";
|
||||
import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js";
|
||||
import {
|
||||
createEvidence,
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../../../src/domain/evidence.js";
|
||||
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const plist = (message: string) =>
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n<plist version="1.0"><dict><key>message</key><string>${message}</string></dict></plist>\n`;
|
||||
|
||||
const inventoryEvidence = (input: unknown): Evidence => {
|
||||
const inspection = parseEvidence(input);
|
||||
const inventory = artifactInspectionResultSchema
|
||||
.parse(inspection.normalized_result)
|
||||
.substeps.find(
|
||||
({ evidence }) => evidence.operation === "inventory_artifact",
|
||||
)?.evidence;
|
||||
if (inventory === undefined) throw new Error("Expected inventory Evidence");
|
||||
return parseEvidence(inventory);
|
||||
};
|
||||
|
||||
const inspectPair = async (
|
||||
cli: TestCli,
|
||||
leftPath: string,
|
||||
rightPath: string,
|
||||
) => {
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-artifact", leftPath, "--json"],
|
||||
});
|
||||
expect(result.exitCode, `${result.stderr}\n${result.stdout}`).toBe(0);
|
||||
const left = inventoryEvidence(result.json);
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const opened = await call("open_binary", { path: rightPath });
|
||||
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
|
||||
const inspected = await call("inspect_artifact", {});
|
||||
expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true);
|
||||
return { left, right: inventoryEvidence(inspected.structuredContent), call };
|
||||
};
|
||||
|
||||
const assertCoverageControls = async ({
|
||||
left,
|
||||
right,
|
||||
inventory,
|
||||
compare,
|
||||
}: {
|
||||
left: Evidence;
|
||||
right: Evidence;
|
||||
inventory: ArtifactInventoryResult;
|
||||
compare: (left: Evidence, right: Evidence) => Promise<Evidence>;
|
||||
}) => {
|
||||
// Authenticated derived controls isolate missing members and genuine coverage
|
||||
// gaps; they do not claim that the complete plist producer omitted children.
|
||||
const derive = (result: unknown) =>
|
||||
createEvidence(
|
||||
{
|
||||
path: right.subject?.local_path ?? "",
|
||||
sha256: inventory.manifest.root_sha256,
|
||||
format: "plist",
|
||||
},
|
||||
right.provider,
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse(result),
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
);
|
||||
const partial = await compare(left, derive({ ...inventory, nodes: [] }));
|
||||
expect(partial.normalized_result).toMatchObject({
|
||||
status: "truncated",
|
||||
summary: { unchanged: 0, unknown: 1 },
|
||||
});
|
||||
const reworded = await compare(
|
||||
left,
|
||||
derive({
|
||||
...inventory,
|
||||
coverage: { scope: "root", status: "complete" },
|
||||
limitations: ["Root bytes observed; no container children are modeled."],
|
||||
}),
|
||||
);
|
||||
expect(reworded.normalized_result).toMatchObject({
|
||||
status: "unchanged",
|
||||
summary: { unchanged: 1, unknown: 0 },
|
||||
limitations: expect.arrayContaining([
|
||||
"Right: Root bytes observed; no container children are modeled.",
|
||||
]),
|
||||
});
|
||||
const gap = await compare(
|
||||
left,
|
||||
derive({
|
||||
...inventory,
|
||||
coverage: { scope: "root", status: "unknown" },
|
||||
coverage_status: "partial",
|
||||
limitations: [],
|
||||
}),
|
||||
);
|
||||
expect(gap.normalized_result).toMatchObject({
|
||||
status: "unknown",
|
||||
summary: { unchanged: 0, unknown: 1 },
|
||||
});
|
||||
const differentScope = await compare(
|
||||
left,
|
||||
derive({
|
||||
...inventory,
|
||||
coverage: { scope: "container-entries", status: "complete" },
|
||||
}),
|
||||
);
|
||||
expect(differentScope.normalized_result).toMatchObject({
|
||||
status: "unknown",
|
||||
limitations: expect.arrayContaining(["Artifact inventory scopes differ."]),
|
||||
});
|
||||
|
||||
const { coverage, coverage_status: _coverageStatus, ...legacy } = inventory;
|
||||
expect(coverage).toBeDefined();
|
||||
const retained = await compare(left, derive(legacy));
|
||||
expect(retained.normalized_result).toMatchObject({
|
||||
status: "unchanged",
|
||||
summary: { unchanged: 1, unknown: 0 },
|
||||
});
|
||||
for (const limitation of [
|
||||
"Child inventory is unavailable.",
|
||||
"Missing child observations; 1 integrity contradiction(s) were recorded.",
|
||||
]) {
|
||||
const unfamiliarLegacy = await compare(
|
||||
left,
|
||||
derive({ ...legacy, limitations: [limitation] }),
|
||||
);
|
||||
expect(unfamiliarLegacy.normalized_result).toMatchObject({
|
||||
status: "unknown",
|
||||
summary: { unchanged: 0, unknown: 1 },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
cliTest(
|
||||
"compares fully observed roots without inventing coverage gaps through CLI and MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-artifact-coverage-");
|
||||
const leftPath = join(root, "left.plist");
|
||||
const rightPath = join(root, "right.plist");
|
||||
await Promise.all([
|
||||
writeFile(leftPath, plist("hello")),
|
||||
writeFile(rightPath, plist("hello")),
|
||||
]);
|
||||
const leftResult = await cli.run({
|
||||
arguments: ["inspect-artifact", leftPath, "--json"],
|
||||
});
|
||||
expect(leftResult.exitCode, leftResult.stderr).toBe(0);
|
||||
const left = inventoryEvidence(leftResult.json);
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const inspect = async (path: string) => {
|
||||
const opened = await call("open_binary", { path });
|
||||
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
|
||||
const result = await call("inspect_artifact", {});
|
||||
expect(result.isError, JSON.stringify(result)).not.toBe(true);
|
||||
return inventoryEvidence(result.structuredContent);
|
||||
};
|
||||
const compare = async (left: Evidence, right: Evidence) => {
|
||||
const result = await call("compare_artifacts", { left, right });
|
||||
expect(result.isError, JSON.stringify(result)).not.toBe(true);
|
||||
return parseEvidence(result.structuredContent);
|
||||
};
|
||||
const right = await inspect(rightPath);
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
right.normalized_result,
|
||||
);
|
||||
expect(inventory.coverage).toEqual({ scope: "root", status: "complete" });
|
||||
expect(inventory.manifest).toMatchObject({
|
||||
root_format: "plist",
|
||||
node_count: 1,
|
||||
occurrence_count: 1,
|
||||
edge_count: 0,
|
||||
});
|
||||
expect(inventory.limitations).toEqual([
|
||||
"Artifact has no child container entries.",
|
||||
]);
|
||||
const equal = await compare(left, right);
|
||||
expect(equal.normalized_result).toMatchObject({
|
||||
status: "unchanged",
|
||||
summary: { unchanged: 1, unknown: 0 },
|
||||
changes: [],
|
||||
limitations: [
|
||||
"Left: Artifact has no child container entries.",
|
||||
"Right: Artifact has no child container entries.",
|
||||
],
|
||||
});
|
||||
const behavior = await call("find_changed_behavior", {
|
||||
comparisons: [equal],
|
||||
});
|
||||
expect(behavior.isError, JSON.stringify(behavior)).not.toBe(true);
|
||||
expect(behavior.structuredContent).toMatchObject({
|
||||
normalized_result: {
|
||||
behavior_status: "unknown",
|
||||
summary: { unresolved: 0 },
|
||||
findings: { items: [] },
|
||||
limitations: expect.arrayContaining([
|
||||
"No process comparison Evidence was supplied.",
|
||||
]),
|
||||
},
|
||||
});
|
||||
const unknowns = await call("list_unknowns", {
|
||||
domain: "artifact-comparison",
|
||||
});
|
||||
expect(unknowns.isError, JSON.stringify(unknowns)).not.toBe(true);
|
||||
expect(unknowns.structuredContent).toMatchObject({
|
||||
result: { items: [], total: 0 },
|
||||
});
|
||||
|
||||
await writeFile(rightPath, plist("world"));
|
||||
const changed = await compare(left, await inspect(rightPath));
|
||||
expect(changed.normalized_result).toMatchObject({
|
||||
status: "changed",
|
||||
summary: { unchanged: 0, changed: 1, unknown: 0 },
|
||||
changes: [
|
||||
expect.objectContaining({ logical_path: ".", dimensions: ["content"] }),
|
||||
],
|
||||
});
|
||||
|
||||
const zipPaths = [join(root, "left.zip"), join(root, "right.zip")] as const;
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("same.txt", new TextReader("same"));
|
||||
const bytes = await writer.close();
|
||||
await Promise.all(zipPaths.map((path) => writeFile(path, bytes)));
|
||||
const zip = await compare(
|
||||
await inspect(zipPaths[0]),
|
||||
await inspect(zipPaths[1]),
|
||||
);
|
||||
expect(zip.normalized_result).toMatchObject({
|
||||
status: "unchanged",
|
||||
summary: { unchanged: 2, unknown: 0 },
|
||||
changes: [],
|
||||
});
|
||||
|
||||
await assertCoverageControls({ left, right, inventory, compare });
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
cliTest.skipIf(process.platform === "win32")(
|
||||
"preserves executable metadata changes for fully observed plist roots",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-artifact-mode-");
|
||||
const leftPath = join(root, "left.plist");
|
||||
const rightPath = join(root, "right.plist");
|
||||
await Promise.all([
|
||||
writeFile(leftPath, plist("hello")),
|
||||
writeFile(rightPath, plist("hello")),
|
||||
]);
|
||||
await Promise.all([chmod(leftPath, 0o644), chmod(rightPath, 0o755)]);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-artifact", leftPath, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const left = inventoryEvidence(result.json);
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const opened = await call("open_binary", { path: rightPath });
|
||||
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
|
||||
const inspected = await call("inspect_artifact", {});
|
||||
expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true);
|
||||
const right = inventoryEvidence(inspected.structuredContent);
|
||||
expect(right.subject?.digest.sha256).toBe(left.subject?.digest.sha256);
|
||||
const comparison = await call("compare_artifacts", { left, right });
|
||||
expect(comparison.isError, JSON.stringify(comparison)).not.toBe(true);
|
||||
expect(
|
||||
parseEvidence(comparison.structuredContent).normalized_result,
|
||||
).toMatchObject({
|
||||
status: "changed",
|
||||
summary: { unchanged: 0, changed: 1, unknown: 0 },
|
||||
changes: [
|
||||
expect.objectContaining({
|
||||
logical_path: ".",
|
||||
dimensions: ["executable"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
cliTest.skipIf(process.platform === "win32")(
|
||||
"retains traversal uncertainty when a directory omits special filesystem entries",
|
||||
async ({ cli, processes }) => {
|
||||
const root = await createTestTempDirectory("rea-gap-");
|
||||
const leftPath = join(root, "l.app"),
|
||||
rightPath = join(root, "r.app");
|
||||
await Promise.all([mkdir(leftPath), mkdir(rightPath)]);
|
||||
await Promise.all([
|
||||
writeFile(join(leftPath, "same.txt"), "same"),
|
||||
writeFile(join(rightPath, "same.txt"), "same"),
|
||||
]);
|
||||
await Promise.all(
|
||||
[leftPath, rightPath].flatMap((path) => [
|
||||
writeFile(
|
||||
join(path, "Info.plist"),
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>main.js</string></dict></plist>",
|
||||
),
|
||||
writeFile(join(path, "main.js"), "export const value = 1;"),
|
||||
]),
|
||||
);
|
||||
const fifo = await processes.run("mkfifo", [join(leftPath, "p")]);
|
||||
expect(fifo.exitCode, fifo.stderr).toBe(0);
|
||||
const socket = createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("error", reject);
|
||||
socket.listen(join(leftPath, "s"), resolve);
|
||||
});
|
||||
try {
|
||||
const { left, right, call } = await inspectPair(cli, leftPath, rightPath);
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
left.normalized_result,
|
||||
);
|
||||
expect(inventory.coverage).toEqual({
|
||||
scope: "container-entries",
|
||||
status: "unknown",
|
||||
});
|
||||
expect(inventory.limitations).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Artifact traversal omitted fifo at "p".',
|
||||
'Artifact traversal omitted socket at "s".',
|
||||
]),
|
||||
);
|
||||
const compared = await call("compare_artifacts", { left, right });
|
||||
expect(compared.isError, JSON.stringify(compared)).not.toBe(true);
|
||||
expect(
|
||||
parseEvidence(compared.structuredContent).normalized_result,
|
||||
).toMatchObject({ status: "unknown", summary: { unchanged: 0 } });
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
socket.close((cause) =>
|
||||
cause === undefined ? resolve() : reject(cause),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"compares fully traversed case-distinct ZIP entries while retaining materialization limits",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-case-coverage-");
|
||||
const leftPath = join(root, "left.zip"),
|
||||
rightPath = join(root, "right.zip");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("A", new TextReader("upper"));
|
||||
await writer.add("a", new TextReader("lower"));
|
||||
const bytes = await writer.close();
|
||||
await Promise.all([
|
||||
writeFile(leftPath, bytes),
|
||||
writeFile(rightPath, bytes),
|
||||
]);
|
||||
const { left, right, call } = await inspectPair(cli, leftPath, rightPath);
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
left.normalized_result,
|
||||
);
|
||||
expect(inventory.coverage).toEqual({
|
||||
scope: "container-entries",
|
||||
status: "complete",
|
||||
});
|
||||
expect(
|
||||
inventory.occurrences.map(({ logical_path }) => logical_path).sort(),
|
||||
).toEqual([".", "A", "a"]);
|
||||
const compared = await call("compare_artifacts", { left, right });
|
||||
expect(compared.isError, JSON.stringify(compared)).not.toBe(true);
|
||||
expect(
|
||||
parseEvidence(compared.structuredContent).normalized_result,
|
||||
).toMatchObject({
|
||||
status: "unchanged",
|
||||
summary: { unchanged: 3, unknown: 0 },
|
||||
changes: [],
|
||||
limitations: expect.arrayContaining([
|
||||
expect.stringContaining("case-insensitive destination"),
|
||||
]),
|
||||
});
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"keeps unreadable encrypted ZIP content unknown with complete traversal",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-encrypted-comparison-");
|
||||
const leftPath = join(root, "left.zip");
|
||||
const rightPath = join(root, "right.zip");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("secret.txt", new TextReader("secret bytes"), {
|
||||
password: "fixture-password",
|
||||
});
|
||||
const bytes = await writer.close();
|
||||
await Promise.all([
|
||||
writeFile(leftPath, bytes),
|
||||
writeFile(rightPath, bytes),
|
||||
]);
|
||||
const { left, right, call } = await inspectPair(cli, leftPath, rightPath);
|
||||
for (const evidence of [left, right])
|
||||
expect(
|
||||
artifactInventoryResultSchema.parse(evidence.normalized_result),
|
||||
).toMatchObject({
|
||||
coverage: { scope: "container-entries", status: "complete" },
|
||||
occurrences: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
logical_path: "secret.txt",
|
||||
hash_status: "unavailable",
|
||||
encrypted: true,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
const compared = await call("compare_artifacts", { left, right });
|
||||
expect(compared.isError, JSON.stringify(compared)).not.toBe(true);
|
||||
const result = parseEvidence(compared.structuredContent).normalized_result;
|
||||
expect(result).toMatchObject({
|
||||
status: "unknown",
|
||||
summary: { unknown: expect.any(Number) },
|
||||
});
|
||||
expect(result).toHaveProperty("summary.unknown", 1);
|
||||
},
|
||||
);
|
||||
@@ -228,7 +228,7 @@ describe("case-distinct APK resource names", () => {
|
||||
await runProviderAnalysis(path, "inventory_artifact", {}),
|
||||
);
|
||||
expect(inventory.normalized_result).toMatchObject({
|
||||
coverage_status: "partial",
|
||||
coverage_status: "complete",
|
||||
});
|
||||
const result = projectAndroidApplicationEvidence({
|
||||
inventory_evidence: [inventory],
|
||||
@@ -238,11 +238,11 @@ describe("case-distinct APK resource names", () => {
|
||||
);
|
||||
expect(projection.root_format).toBe("apk");
|
||||
expect(projection.coverage).toEqual({
|
||||
status: "partial",
|
||||
status: "complete-within-inventory",
|
||||
inventory_complete: true,
|
||||
});
|
||||
expect(projection.limitations).toContain(
|
||||
"Source inventory coverage is partial; absence is unknown.",
|
||||
expect(projection.limitations.join(" ")).toContain(
|
||||
"case-insensitive destination",
|
||||
);
|
||||
expect(projection.components.manifests).toHaveLength(1);
|
||||
expect(projection.components.dex).toHaveLength(1);
|
||||
|
||||
@@ -75,7 +75,7 @@ async function createCompleteAppleProjection() {
|
||||
}
|
||||
|
||||
describe("Apple application projection", () => {
|
||||
it("keeps a case-colliding real IPA inventory partial", async () => {
|
||||
it("retains complete traversal and case-collision limits for a real IPA", async () => {
|
||||
const root = await createTestTempDirectory("rea-apple-case-collision-");
|
||||
const path = join(root, "CaseCollision.ipa");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
@@ -93,7 +93,7 @@ describe("Apple application projection", () => {
|
||||
await runProviderAnalysis(path, "inventory_artifact", {}),
|
||||
);
|
||||
expect(inventory.normalized_result).toMatchObject({
|
||||
coverage_status: "partial",
|
||||
coverage_status: "complete",
|
||||
});
|
||||
const result = projectAppleApplicationEvidence({
|
||||
inventory_evidence: [inventory],
|
||||
@@ -103,11 +103,11 @@ describe("Apple application projection", () => {
|
||||
);
|
||||
|
||||
expect(projection.coverage).toEqual({
|
||||
status: "partial",
|
||||
status: "complete-within-inventory",
|
||||
inventory_complete: true,
|
||||
});
|
||||
expect(projection.limitations).toContain(
|
||||
"Source inventory coverage is partial; absence is unknown.",
|
||||
expect(projection.limitations.join(" ")).toContain(
|
||||
"case-insensitive destination",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -77,7 +77,11 @@ describe("leaf artifact comparison", () => {
|
||||
// Persisted inventory Evidence predates coverage_status. The known leaf
|
||||
// notice remains complete when normalized at the inventory boundary.
|
||||
const parsed = artifactInventoryResultSchema.parse(left.normalized_result);
|
||||
const { coverage_status: _coverageStatus, ...legacyInventory } = parsed;
|
||||
const {
|
||||
coverage: _coverage,
|
||||
coverage_status: _coverageStatus,
|
||||
...legacyInventory
|
||||
} = parsed;
|
||||
const legacy = createEvidence(
|
||||
{
|
||||
path: left.subject!.local_path,
|
||||
@@ -157,7 +161,7 @@ describe("artifact inventory coverage", () => {
|
||||
const graph = artifactInventoryResultSchema.parse(
|
||||
complete.normalized_result,
|
||||
);
|
||||
const partial = createEvidence(
|
||||
const contradictory = createEvidence(
|
||||
{
|
||||
path: complete.subject!.local_path,
|
||||
sha256: complete.subject!.digest.sha256,
|
||||
@@ -168,6 +172,29 @@ describe("artifact inventory coverage", () => {
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse({ ...graph, coverage_status: "partial" }),
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
);
|
||||
expect(() => parseArtifactInventoryEvidence(contradictory)).toThrow(
|
||||
"Artifact inventory has conflicting coverage declarations",
|
||||
);
|
||||
|
||||
const { coverage: _coverage, ...legacyStatusGraph } = graph;
|
||||
const partial = createEvidence(
|
||||
{
|
||||
path: complete.subject!.local_path,
|
||||
sha256: complete.subject!.digest.sha256,
|
||||
format: complete.subject!.format,
|
||||
},
|
||||
PROVIDER,
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse({
|
||||
...legacyStatusGraph,
|
||||
coverage_status: "partial",
|
||||
}),
|
||||
confidence: "observed",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
@@ -180,7 +207,8 @@ describe("artifact inventory coverage", () => {
|
||||
summary: { unchanged: 0, unknown: 1 },
|
||||
});
|
||||
|
||||
const { coverage_status: _coverageStatus, ...legacyGraph } = graph;
|
||||
const { coverage_status: _coverageStatus, ...legacyGraph } =
|
||||
legacyStatusGraph;
|
||||
const unknownLegacy = createEvidence(
|
||||
{
|
||||
path: complete.subject!.local_path,
|
||||
@@ -219,6 +247,7 @@ describe("artifact inventory coverage", () => {
|
||||
const fragmentGraph = artifactInventoryResultSchema.parse(
|
||||
fragment.normalized_result,
|
||||
);
|
||||
const { coverage: _coverage, ...legacyFragmentGraph } = fragmentGraph;
|
||||
const mixed = createEvidence(
|
||||
{
|
||||
path: fragment.subject!.local_path,
|
||||
@@ -230,7 +259,7 @@ describe("artifact inventory coverage", () => {
|
||||
operation: "inventory_artifact",
|
||||
parameters: fragment.parameters,
|
||||
result: jsonValueSchema.parse({
|
||||
...fragmentGraph,
|
||||
...legacyFragmentGraph,
|
||||
coverage_status: "partial",
|
||||
}),
|
||||
confidence: "derived",
|
||||
@@ -239,7 +268,7 @@ describe("artifact inventory coverage", () => {
|
||||
);
|
||||
expect(() =>
|
||||
parseArtifactInventoryEvidence([firstFragment, mixed]),
|
||||
).toThrow("Artifact inventory pages disagree on coverage status");
|
||||
).toThrow("Artifact inventory pages do not share one coverage declaration");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -314,6 +343,61 @@ describe("artifact comparison", () => {
|
||||
});
|
||||
|
||||
describe("artifact comparison completeness", () => {
|
||||
it("does not infer child changes from root-only coverage", async () => {
|
||||
const root = await createTestTempDirectory("rea-artifact-root-coverage-");
|
||||
const leftPath = join(root, "left.app");
|
||||
const rightPath = join(root, "right.app");
|
||||
await Promise.all([mkdir(leftPath), mkdir(rightPath)]);
|
||||
await Promise.all([
|
||||
writeFile(join(leftPath, "left-only.txt"), "left"),
|
||||
writeFile(join(rightPath, "right-only.txt"), "right"),
|
||||
]);
|
||||
const [left, right] = await Promise.all([
|
||||
observe(leftPath),
|
||||
observe(rightPath),
|
||||
]);
|
||||
const rootOnly = (evidence: Awaited<ReturnType<typeof observe>>) => {
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
return createEvidence(
|
||||
{
|
||||
path: evidence.subject?.local_path ?? "",
|
||||
sha256: inventory.manifest.root_sha256,
|
||||
format: inventory.manifest.root_format,
|
||||
},
|
||||
PROVIDER,
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse({
|
||||
...inventory,
|
||||
coverage: { scope: "root", status: "complete" },
|
||||
}),
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
);
|
||||
};
|
||||
const compared = compareArtifacts(rootOnly(left), rootOnly(right));
|
||||
expect(compared.status).toBe("unknown");
|
||||
expect(compared.summary).toMatchObject({ added: 0, removed: 0 });
|
||||
expect(
|
||||
compared.changes
|
||||
.filter(({ logical_path }) => logical_path !== ".")
|
||||
.map(({ logical_path, classification }) => ({
|
||||
logical_path,
|
||||
classification,
|
||||
})),
|
||||
).toEqual([
|
||||
{ logical_path: "left-only.txt", classification: "unknown" },
|
||||
{ logical_path: "right-only.txt", classification: "unknown" },
|
||||
]);
|
||||
expect(compared.limitations).toContain(
|
||||
"Container entry coverage is unavailable for child paths.",
|
||||
);
|
||||
});
|
||||
|
||||
it("compares every graph member for inventories larger than 500 entries", async () => {
|
||||
const root = await createTestTempDirectory("rea-artifact-pages-");
|
||||
const entryCount = 501;
|
||||
@@ -415,6 +499,30 @@ describe("artifact comparison completeness", () => {
|
||||
evidence_links: citations,
|
||||
})),
|
||||
});
|
||||
const fragment = leftFragments[0];
|
||||
if (fragment === undefined) throw new Error("Expected inventory fragments");
|
||||
const conflictingScope = createEvidence(
|
||||
{
|
||||
path: leftPath,
|
||||
sha256: left.subject?.digest.sha256 ?? "",
|
||||
format: "directory",
|
||||
},
|
||||
PROVIDER,
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse({
|
||||
...artifactInventoryResultSchema.parse(fragment.normalized_result),
|
||||
coverage: { scope: "root", status: "complete" },
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(() =>
|
||||
compareArtifacts(
|
||||
[conflictingScope, ...leftFragments.slice(1)],
|
||||
rightFragments,
|
||||
),
|
||||
).toThrow(/coverage declaration/u);
|
||||
}, 15_000);
|
||||
|
||||
it("rejects non-inventory and tampered Evidence", async () => {
|
||||
|
||||
@@ -52,3 +52,22 @@ it("rejects a root path replaced between its initial stat and open", async () =>
|
||||
message: `Root artifact changed before inventory: ${path}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unavailable container traversal distinct from observed root bytes", async () => {
|
||||
const root = await createTestTempDirectory("rea-artifact-root-scope-");
|
||||
const path = join(root, "fixture.pkg");
|
||||
// The recognized XAR prefix selects PKG inventory; no child decoder is installed.
|
||||
await writeFile(path, Buffer.from("xar!fixture"));
|
||||
const inventory = await inventoryArtifact(path);
|
||||
expect(inventory.manifest).toMatchObject({
|
||||
root_format: "pkg",
|
||||
node_count: 1,
|
||||
occurrence_count: 1,
|
||||
edge_count: 0,
|
||||
});
|
||||
expect(inventory.occurrences[0]?.hash_status).toBe("verified");
|
||||
expect(inventory.coverage).toEqual({
|
||||
scope: "container-entries",
|
||||
status: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,7 +214,7 @@ describe("HarmonyOS packaging boundaries", () => {
|
||||
});
|
||||
|
||||
describe("HarmonyOS inventory coverage", () => {
|
||||
it("keeps a case-colliding inventory partial in the application projection", async () => {
|
||||
it("retains complete traversal and case-collision limits in the application projection", async () => {
|
||||
const path = await writeApplicationZip(
|
||||
"rea-harmony-case-collision-",
|
||||
"Fixture.hap",
|
||||
@@ -225,7 +225,7 @@ describe("HarmonyOS inventory coverage", () => {
|
||||
);
|
||||
const inventory = await inventoryApplicationPackage(path);
|
||||
expect(inventory.normalized_result).toMatchObject({
|
||||
coverage_status: "partial",
|
||||
coverage_status: "complete",
|
||||
});
|
||||
|
||||
const projection = requireDeterministicProjection(
|
||||
@@ -234,11 +234,11 @@ describe("HarmonyOS inventory coverage", () => {
|
||||
harmonyApplicationProjectionResultSchema,
|
||||
);
|
||||
expect(projection.coverage).toEqual({
|
||||
status: "partial",
|
||||
status: "complete-within-inventory",
|
||||
inventory_complete: true,
|
||||
});
|
||||
expect(projection.limitations).toContain(
|
||||
"Source inventory coverage is partial; absence is unknown.",
|
||||
expect(projection.limitations.join(" ")).toContain(
|
||||
"case-insensitive destination",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -254,8 +254,11 @@ const assertHistoricalInventory = async (
|
||||
const { archive, parent, inventoryEvidence, graph } = input;
|
||||
// Historical records may omit a later inventory field. Input parsing must
|
||||
// not insert defaults inside that authenticated child Evidence.
|
||||
const { integrity_contradictions: _contradictions, ...legacyInventory } =
|
||||
graph;
|
||||
const {
|
||||
integrity_contradictions: _contradictions,
|
||||
coverage: _coverage,
|
||||
...legacyInventory
|
||||
} = graph;
|
||||
const legacyTarget = {
|
||||
path: archive,
|
||||
sha256: graph.manifest.root_sha256,
|
||||
@@ -275,6 +278,9 @@ const assertHistoricalInventory = async (
|
||||
expect(
|
||||
legacyInspection.substeps[0]?.evidence.normalized_result,
|
||||
).not.toHaveProperty("integrity_contradictions");
|
||||
expect(
|
||||
legacyInspection.substeps[0]?.evidence.normalized_result,
|
||||
).not.toHaveProperty("coverage");
|
||||
const legacyParent = createEvidence(legacyTarget, parent.provider, {
|
||||
operation: "inspect_artifact",
|
||||
parameters: {},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
|
||||
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
|
||||
import { ok as resultOk } from "../../../src/domain/result.js";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -98,20 +100,6 @@ it.each([
|
||||
20_000,
|
||||
);
|
||||
|
||||
const artifactInventoryResultSchema = z.object({
|
||||
occurrences: z.array(
|
||||
z.object({ logical_path: z.string(), hash_status: z.string() }),
|
||||
),
|
||||
integrity_contradictions: z.array(
|
||||
z.object({
|
||||
logical_path: z.string(),
|
||||
declared_sha256: z.string(),
|
||||
observed_sha256: z.string(),
|
||||
trust: z.literal("observed-untrusted"),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
it("extracts an active archive through MCP when requested", async () => {
|
||||
const root = await createTestTempDirectory("rea-artifact-extract-mcp-");
|
||||
const archive = join(root, "fixture.zip");
|
||||
@@ -318,6 +306,30 @@ it("records an explicitly continued mismatch, preserves verified siblings, and n
|
||||
}),
|
||||
]),
|
||||
});
|
||||
const { coverage: _coverage, ...legacyInventory } = inventory;
|
||||
const historical = createEvidence(
|
||||
{ path: archive, sha256: inventory.manifest.root_sha256, format: "asar" },
|
||||
compact.provider,
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse(legacyInventory),
|
||||
},
|
||||
);
|
||||
const retainedComparison = await client.callTool({
|
||||
name: "compare_artifacts",
|
||||
arguments: { left: evidence, right: historical },
|
||||
});
|
||||
expect(
|
||||
retainedComparison.isError,
|
||||
JSON.stringify(retainedComparison),
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
toolEvidence(retainedComparison.structuredContent).normalized_result,
|
||||
).toMatchObject({
|
||||
status: "contradiction",
|
||||
summary: { unchanged: 2, contradiction: 2, unknown: 0 },
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { execFileOutput } from "../../../../src/process/ExecFileOutput.js";
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
type ArtifactEntry,
|
||||
type ArtifactTraversalOmission,
|
||||
} from "../../../../src/artifacts/ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { DirectoryArtifactReader } from "../../../../src/artifacts/DirectoryArtifactReader.js";
|
||||
@@ -473,10 +474,10 @@ describe("native DMG traversal shutdown", () => {
|
||||
.mockImplementation(async function* (
|
||||
this: DirectoryArtifactReader,
|
||||
signal?: AbortSignal,
|
||||
descendInto?: (path: string) => boolean,
|
||||
selection?: Parameters<DirectoryArtifactReader["entries"]>[1],
|
||||
) {
|
||||
const iterator = originalEntries
|
||||
.call(this, signal, descendInto)
|
||||
.call(this, signal, selection)
|
||||
[Symbol.asyncIterator]();
|
||||
try {
|
||||
for (;;) {
|
||||
@@ -606,9 +607,20 @@ describe("native DMG artifact reader", () => {
|
||||
undefined,
|
||||
host,
|
||||
);
|
||||
if (mountPoint === undefined) throw new Error("expected mounted directory");
|
||||
if (process.platform !== "win32")
|
||||
await execFileOutput("mkfifo", [join(mountPoint, "special.pipe")]);
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry.path);
|
||||
const omissions: ArtifactTraversalOmission[] = [];
|
||||
for await (const entry of reader.entries(undefined, {
|
||||
onOmission: (omission) => omissions.push(omission),
|
||||
}))
|
||||
entries.push(entry.path);
|
||||
expect(entries).toContain("image.dmg/Fixture/hello.txt");
|
||||
if (process.platform !== "win32")
|
||||
expect(omissions).toEqual([
|
||||
{ path: "image.dmg/Fixture/special.pipe", kind: "fifo" },
|
||||
]);
|
||||
const provenance = reader.provenance();
|
||||
const first = provenance[0];
|
||||
if (first === undefined) throw new Error("expected attachment provenance");
|
||||
|
||||
Reference in New Issue
Block a user