From 45a3509db34e2eca5558b90b06fca1baefdf3479 Mon Sep 17 00:00:00 2001 From: Thang Luu Date: Wed, 14 Oct 2026 04:00:27 +0900 Subject: [PATCH] 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 --- docs/mcp-contracts.md | 25 + src/artifacts/ArtifactReader.ts | 33 +- src/artifacts/DirectoryArtifactReader.ts | 39 +- src/artifacts/NativeDmgArtifactReader.ts | 16 +- src/artifacts/inventory/reader.ts | 77 ++-- src/artifacts/inventory/scanCanonical.ts | 37 +- src/artifacts/inventory/scanReader.ts | 39 +- src/domain/android/androidApplication.ts | 5 +- src/domain/apple/appleApplication.ts | 30 +- src/domain/artifactComparison.ts | 65 ++- src/domain/artifactGraph.ts | 19 +- src/domain/artifactInventoryEvidence.ts | 120 +++-- src/domain/artifactInventorySnapshot.ts | 5 +- src/domain/harmony/harmonyApplication.ts | 5 +- .../mobileApplicationGraphProjection.ts | 3 +- .../artifactComparisonCoverage.test.ts | 432 ++++++++++++++++++ .../filesystem/android/application.test.ts | 8 +- .../filesystem/appleApplication.test.ts | 10 +- .../filesystem/artifactComparison.test.ts | 118 ++++- .../artifactInventoryRegularRoot.test.ts | 19 + .../filesystem/harmony/application.test.ts | 10 +- .../mcp/artifactMcp.inlineInventory.test.ts | 10 +- tests/boundary/mcp/artifactMcp.test.ts | 42 +- .../native/nativeDmgArtifactReader.test.ts | 18 +- 24 files changed, 1023 insertions(+), 162 deletions(-) create mode 100644 tests/acceptance/applications/artifactComparisonCoverage.test.ts diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index a2708511cd..307d323dff 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -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": "..." }`. diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index f072dd35cb..ad40de57d6 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -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; + entries( + signal?: AbortSignal, + options?: ArtifactTraversalOptions, + ): AsyncIterable; open(entry: ArtifactEntry, signal?: AbortSignal): Promise; provenance(): readonly ArtifactCommand[]; close(): Promise; diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index 4a1d671352..399bf979b9 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -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 { 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 { 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, diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts index 801ee4dafd..93c12ece36 100644 --- a/src/artifacts/NativeDmgArtifactReader.ts +++ b/src/artifacts/NativeDmgArtifactReader.ts @@ -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 { + async *entries( + signal?: AbortSignal, + options?: ArtifactTraversalOptions, + ): AsyncIterable { 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; diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index f195eac27c..749b6e0b57 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -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."], + }; }; diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index d5234bdf69..2c8679e218 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -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.`, + ]), +]; diff --git a/src/artifacts/inventory/scanReader.ts b/src/artifacts/inventory/scanReader.ts index 6873b14d74..38b67d8435 100644 --- a/src/artifacts/inventory/scanReader.ts +++ b/src/artifacts/inventory/scanReader.ts @@ -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; 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(), 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 => + 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), }); } } diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index c46fb9661a..99b20c321d 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -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.", diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index dfb34e3c54..6dac13f826 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -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, diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index 9d7a766af2..72494136c4 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -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; }; diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index 6b2709baa4..5a804c8d6e 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -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 diff --git a/src/domain/artifactInventoryEvidence.ts b/src/domain/artifactInventoryEvidence.ts index 213364816f..5f95bdad6c 100644 --- a/src/domain/artifactInventoryEvidence.ts +++ b/src/domain/artifactInventoryEvidence.ts @@ -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(); const occurrences = new Map(); const paths = new Map(); @@ -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)); diff --git a/src/domain/artifactInventorySnapshot.ts b/src/domain/artifactInventorySnapshot.ts index 2157c03edf..8626d7465b 100644 --- a/src/domain/artifactInventorySnapshot.ts +++ b/src/domain/artifactInventorySnapshot.ts @@ -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[]; } diff --git a/src/domain/harmony/harmonyApplication.ts b/src/domain/harmony/harmonyApplication.ts index 71755e8281..fff8429cb4 100644 --- a/src/domain/harmony/harmonyApplication.ts +++ b/src/domain/harmony/harmonyApplication.ts @@ -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 diff --git a/src/domain/mobileApplicationGraphProjection.ts b/src/domain/mobileApplicationGraphProjection.ts index 93e4143c5c..13bbe9545c 100644 --- a/src/domain/mobileApplicationGraphProjection.ts +++ b/src/domain/mobileApplicationGraphProjection.ts @@ -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, diff --git a/tests/acceptance/applications/artifactComparisonCoverage.test.ts b/tests/acceptance/applications/artifactComparisonCoverage.test.ts new file mode 100644 index 0000000000..db86603114 --- /dev/null +++ b/tests/acceptance/applications/artifactComparisonCoverage.test.ts @@ -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) => + `\nmessage${message}\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; +}) => { + // 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"), + "CFBundleExecutablemain.js", + ), + 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((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((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); + }, +); diff --git a/tests/boundary/filesystem/android/application.test.ts b/tests/boundary/filesystem/android/application.test.ts index 28fa4d8c86..01a5901c80 100644 --- a/tests/boundary/filesystem/android/application.test.ts +++ b/tests/boundary/filesystem/android/application.test.ts @@ -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); diff --git a/tests/boundary/filesystem/appleApplication.test.ts b/tests/boundary/filesystem/appleApplication.test.ts index 00ef24ba58..31c395b554 100644 --- a/tests/boundary/filesystem/appleApplication.test.ts +++ b/tests/boundary/filesystem/appleApplication.test.ts @@ -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", ); }); diff --git a/tests/boundary/filesystem/artifactComparison.test.ts b/tests/boundary/filesystem/artifactComparison.test.ts index ca40596d38..f298c28dd8 100644 --- a/tests/boundary/filesystem/artifactComparison.test.ts +++ b/tests/boundary/filesystem/artifactComparison.test.ts @@ -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>) => { + 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 () => { diff --git a/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts b/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts index 9ea26cfdae..05e6a0e1a2 100644 --- a/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts +++ b/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts @@ -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", + }); +}); diff --git a/tests/boundary/filesystem/harmony/application.test.ts b/tests/boundary/filesystem/harmony/application.test.ts index b2870e6c58..a50c440e9e 100644 --- a/tests/boundary/filesystem/harmony/application.test.ts +++ b/tests/boundary/filesystem/harmony/application.test.ts @@ -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", ); }); diff --git a/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts b/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts index de78af9d08..fa0481ac74 100644 --- a/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts +++ b/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts @@ -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: {}, diff --git a/tests/boundary/mcp/artifactMcp.test.ts b/tests/boundary/mcp/artifactMcp.test.ts index da8a586d20..96ba07283d 100644 --- a/tests/boundary/mcp/artifactMcp.test.ts +++ b/tests/boundary/mcp/artifactMcp.test.ts @@ -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()]); } diff --git a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts index 5d6af987d6..72d11abece 100644 --- a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts +++ b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts @@ -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[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");