diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 24463a74ba..c85f790e8c 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -1072,6 +1072,13 @@ bytes and returns the `integrity_contradictions` records for the extracted files. An active ASAR unpacked entry whose companion file is absent cannot be materialized; extraction fails as `unavailable` with that logical path. +If native DMG child traversal is unavailable after carrier identification, +the typed failure retains a revalidated root-only inventory under +`details.partial_observation.inventory`, with unknown child coverage and the +producer's diagnostics/provenance. It does not borrow or detach an existing +attachment. Native checksum verification failures still refuse traversal; +`record-and-continue` recovery applies to structured entry contradictions. + Occurrences use normalized logical paths for identity and lookup. Optional `source_path` retains the original path within the owning reader when Unicode NFC or reader admission changes its spelling; it is source evidence, not an diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts index cfc1a9dab7..d11ceae997 100644 --- a/src/artifacts/NativeDmgArtifactReader.ts +++ b/src/artifacts/NativeDmgArtifactReader.ts @@ -413,27 +413,30 @@ export class NativeDmgArtifactReader implements ArtifactReader { ); const mountRoot = await realpath(await mkdtemp(join(tmpdir(), "rea-dmg-"))); this.#mountRoot = mountRoot; + const arguments_ = [ + "attach", + "-readonly", + "-nobrowse", + "-plist", + "-mountroot", + mountRoot, + this.path, + ]; try { this.#attachMayHaveMounted = true; - const attached = await runChecked( - this.host, - [ - "attach", - "-readonly", - "-nobrowse", - "-plist", - "-mountroot", - mountRoot, - this.path, - ], - signal, - { timeoutMs: HDIUTIL_COMMAND_TIMEOUT_MS }, - ); + const attached = await runChecked(this.host, arguments_, signal, { + timeoutMs: HDIUTIL_COMMAND_TIMEOUT_MS, + }); + this.#provenance.push(command(arguments_, ["read", "mount"])); this.#attachOutputReturned = true; let parsed: NativeDmgAttachOutput; try { parsed = parseNativeDmgAttachOutput(attached.stdout); } catch (cause: unknown) { + if (cause instanceof NativeDmgCommandFailure) + this.#provenance.push( + command(arguments_, ["read", "mount"], cause.exitCode), + ); throw new ArtifactReaderFailure( "format", "hdiutil returned a malformed attachment plist", @@ -454,20 +457,6 @@ export class NativeDmgArtifactReader implements ArtifactReader { ? `DMG attachment ownership is uncertain: ${this.#ownershipUncertainty ?? "no unique mount under the owned root"}` : "hdiutil did not mount a new image beneath the owned root", ); - this.#provenance.push( - command( - [ - "attach", - "-readonly", - "-nobrowse", - "-plist", - "-mountroot", - mountRoot, - this.path, - ], - ["read", "mount"], - ), - ); this.#directory = new DirectoryArtifactReader(mountRoot); } catch (cause: unknown) { if (cause instanceof NativeDmgCommandFailure && cause.stdout !== "") { diff --git a/src/artifacts/NativeDmgRootObservation.test.ts b/src/artifacts/NativeDmgRootObservation.test.ts new file mode 100644 index 0000000000..c9f4a884da --- /dev/null +++ b/src/artifacts/NativeDmgRootObservation.test.ts @@ -0,0 +1,74 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { build } from "plist"; +import { expect, it } from "vitest"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { scanCanonicalArtifactInventory } from "../../tests/fixtures/artifactInventory.js"; +import { + NativeDmgArtifactReader, + type NativeDmgHost, +} from "./NativeDmgArtifactReader.js"; +import { translateArtifactFailure } from "./ArtifactOperationFailure.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; + +it("retains authenticated carrier facts and unknown child coverage when native attachment is unavailable", async () => { + const root = await createTestTempDirectory("rea-dmg-unavailable-root-"); + const path = join(root, "image.dmg"); + const bytes = Buffer.alloc(1024); + bytes.write("koly", bytes.length - 512, "ascii"); + await writeFile(path, bytes); + const host: NativeDmgHost = { + run: async (args) => { + if (args[0] === "verify") return { stdout: "", exitCode: 0 }; + if (args[0] === "info") + return { stdout: build({ images: [] }), exitCode: 0 }; + if (args[0] === "attach") + return { + stdout: "", + stderr: "hdiutil: attach failed - Resource busy", + exitCode: 1, + }; + throw new Error(`Unexpected command ${String(args[0])}`); + }, + }; + let failure: unknown; + try { + await scanCanonicalArtifactInventory( + path, + {}, + () => new NativeDmgArtifactReader(path, {}, host), + ); + } catch (cause: unknown) { + failure = cause; + } + const projected = projectAnalysisError( + translateArtifactFailure("inspect_artifact", failure), + ); + expect(projected).toMatchObject({ + category: "unavailable", + details: { + partial_observation: { + kind: "artifact-inventory", + inventory: { + manifest: { + root_sha256: createHash("sha256").update(bytes).digest("hex"), + root_format: "dmg", + }, + coverage: { status: "unknown" }, + occurrences: [{ logical_path: "." }], + provenance: [ + { arguments: ["verify", path], exit_code: 0 }, + { + arguments: expect.arrayContaining(["attach", path]), + exit_code: 1, + }, + ], + }, + }, + }, + }); + expect(projected.details?.partial_observation).toEqual( + expect.objectContaining({ kind: "artifact-inventory" }), + ); +}); diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index 7a2b6a3013..ebded85cf3 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -130,6 +130,73 @@ export const scanCanonicalArtifactInventoryInScope = async ( }; } catch (cause: unknown) { outcome = { kind: "failed", cause }; + // A mount refusal does not erase the independently classified, hashed + // carrier. Retain only that root, with explicitly unexamined children. + if ( + rootFormat === "dmg" && + cause instanceof ArtifactReaderFailure && + cause.reason === "unavailable" && + cause.partialObservation === undefined && + options.signal?.aborted !== true + ) { + let retained: ArtifactInventorySnapshot | undefined; + let rootFailure: unknown; + try { + retained = await buildInventorySnapshot({ + path, + metadata, + rootFormat, + rootDigest, + rootSource, + resourceScope: options.resourceScope, + reader, + signal: options.signal, + nodes: new Map(), + occurrences: [], + pendingContradictions: [], + caseCollisionLimitation: undefined, + unknownParentOccurrences: new Set(), + traversal: { + status: "unknown", + limitations: [ + `DMG child traversal was unavailable: ${cause.message}`, + ], + }, + }); + } catch (failure: unknown) { + rootFailure = failure; + } + outcome = { + kind: "failed", + cause: new ArtifactReaderFailure( + cause.reason, + rootFailure === undefined + ? cause.message + : `${cause.message}; root observations could not be revalidated: ${rootFailure instanceof Error ? rootFailure.message : String(rootFailure)}`, + { + cause: + rootFailure === undefined + ? cause + : new AggregateError( + [cause, rootFailure], + "DMG traversal and root revalidation failed", + { cause }, + ), + cleanup: cause.cleanup, + cleanupLeases: cause.cleanupLeases, + ...(retained === undefined + ? {} + : { + partialObservation: { + kind: "artifact-inventory", + inventory: retained, + }, + }), + }, + cause.details, + ), + }; + } } const cleanupFailure = await cleanupInventoryOwners({ path, diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 7d2813d799..df298d5d22 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -491,7 +491,7 @@ const artifactMessage = ({ if (reason === "unavailable" && details?.unpacked === true) return `ASAR unpacked companion bytes are unavailable for ${details.logicalPath}. Select the archive in place with its .unpacked directory beside it, then retry.`; if (reason === "unavailable") - return "Artifact processing is unavailable for the current target or host. Check artifact support and required tools."; + return "Artifact processing is unavailable. Review the reported availability reason and resolve that condition before retrying."; if ( reason === "integrity" && details !== undefined &&