Compare commits

..
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 790ecf825a fix(artifacts): keep DMG root observations when the image is already attached
When hdiutil refuses a private read-only attachment because another owner
already attached the same image, the native DMG reader now reports unknown
container-entry coverage with a busy-attachment limitation instead of
failing the whole inventory. Root identity, digest and the failed attach
command provenance are retained; the existing attachment is never
traversed, borrowed or detached.

The downgrade applies only when the pre-attach hdiutil inventory lists the
same image path and ownership reconciliation finds no new mount under the
reader's root; every other attach failure stays a typed failure.

Fixes #5546

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:59:13 +00:00
7 changed files with 275 additions and 164 deletions

No files matched your search

+1 -5
View File
@@ -322,11 +322,7 @@ does not emit a `changed_from` edge; its pairing and uncertainty remain in the i
One-sided absence is `added` or `removed` only when the opposite input graph has
complete coverage. With partial, unavailable, or truncated input, the same
condition is `unknown`. A one-sided node whose observations are all
`unavailable`, such as an unselected or failed parse scope, records an analysis
gap rather than an application entity; it stays `unknown` with its
`availability` and `coverage` dimensions and the gap's limitations, whatever
the opposite coverage. The comparison retains every classified item and
condition is `unknown`. The comparison retains every classified item and
ambiguity candidate. Coverage carries omitted counts and limits from each input
graph; comparison adds no caller-selected item, candidate, graph node, or edge
budget. Unresolved comparison items are recorded as a residual unknown linked
+69 -29
View File
@@ -9,6 +9,7 @@ import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactReader,
type ArtifactReaderTraversal,
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
@@ -149,6 +150,7 @@ export class NativeDmgArtifactReader implements ArtifactReader {
#attachOutputReturned = false;
#attachMayHaveMounted = false;
#ownershipUncertainty: string | undefined;
#preexistingAttachment: ArtifactReaderTraversal | undefined;
#closed = false;
#closePromise: Promise<void> | undefined;
readonly #activeDirectoryOperations = new Set<Promise<unknown>>();
@@ -172,6 +174,10 @@ export class NativeDmgArtifactReader implements ArtifactReader {
options?: ArtifactTraversalOptions,
): AsyncIterable<ArtifactEntry> {
await this.#ensureAttached(signal);
if (this.#preexistingAttachment !== undefined) {
options?.onCoverage?.(this.#preexistingAttachment);
return;
}
const directory = this.#directory;
if (directory === undefined)
throw new ArtifactReaderFailure("unavailable", "DMG is not attached");
@@ -413,22 +419,20 @@ export class NativeDmgArtifactReader implements ArtifactReader {
);
const mountRoot = await realpath(await mkdtemp(join(tmpdir(), "rea-dmg-")));
this.#mountRoot = mountRoot;
const attachArguments = [
"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, attachArguments, signal, {
timeoutMs: HDIUTIL_COMMAND_TIMEOUT_MS,
});
this.#attachOutputReturned = true;
let parsed: NativeDmgAttachOutput;
try {
@@ -454,20 +458,7 @@ 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.#provenance.push(command(attachArguments, ["read", "mount"]));
this.#directory = new DirectoryArtifactReader(mountRoot);
} catch (cause: unknown) {
if (cause instanceof NativeDmgCommandFailure && cause.stdout !== "") {
@@ -482,10 +473,59 @@ export class NativeDmgArtifactReader implements ArtifactReader {
this.#attachMayHaveMounted = false;
this.#ownershipUncertainty = undefined;
}
throw cause;
this.#preexistingAttachment = await this.#preexistingAttachmentCoverage(
cause,
baseline,
attachArguments,
);
if (this.#preexistingAttachment === undefined) throw cause;
}
}
/**
* hdiutil refuses a private attachment while another owner holds this image.
* The root remains observable; child coverage stays unknown, and the
* existing attachment is never traversed, borrowed or detached.
*/
async #preexistingAttachmentCoverage(
cause: unknown,
baseline: NativeDmgInfo,
attachArguments: readonly string[],
): Promise<ArtifactReaderTraversal | undefined> {
if (
!(cause instanceof NativeDmgCommandFailure) ||
cause.exitCode === null ||
cause.reason !== "unavailable" ||
!(await this.#baselineAttachesTarget(baseline))
)
return undefined;
try {
if ((await this.#resolveAttachOwnership()) !== "none") return undefined;
} catch {
// The attach failure remains primary; close() reconciles ownership again.
return undefined;
}
this.#provenance.push(command(attachArguments, ["read"], cause.exitCode));
return {
status: "unknown",
limitations: [
`DMG child inventory is unknown: hdiutil refused a private read-only attachment (exit ${String(cause.exitCode)}) while it already listed an attachment of this image path. REA did not traverse, borrow or detach that attachment; release it and retry to inventory children.`,
],
};
}
async #baselineAttachesTarget(baseline: NativeDmgInfo): Promise<boolean> {
const target = await realpath(this.path).catch(() => undefined);
if (target === undefined) return false;
for (const image of baseline.images) {
const imagePath = image["image-path"];
if (imagePath === undefined) continue;
if ((await realpath(imagePath).catch(() => undefined)) === target)
return true;
}
return false;
}
async #verify(signal?: AbortSignal): Promise<void> {
const arguments_ = ["verify", this.path];
const deadline = performance.now() + HDIUTIL_COMMAND_TIMEOUT_MS;
+1
View File
@@ -15,6 +15,7 @@ const attachOutputSchema = z.object({
const infoOutputSchema = z.object({
images: z.array(
z.object({
"image-path": z.string().optional(),
"system-entities": z.array(systemEntitySchema),
}),
),
@@ -220,17 +220,6 @@ const leftOnlyItem = (
dimensions: ["availability"],
limitations: [AMBIGUOUS_MATCH_LIMITATION],
});
const unavailable = unavailableAnalysisLimitations(node);
if (unavailable !== undefined)
return itemWithId({
...common,
status: "unknown",
match: unmatchedMatch(),
dimensions: ["availability", "coverage"],
limitations: unavailable,
left_node_id: node.node_id,
right_node_id: null,
});
const absenceObserved = absenceClaimable(context.rightGraph.coverage);
return itemWithId({
...common,
@@ -271,17 +260,6 @@ const rightOnlyItem = (
dimensions: ["availability"],
limitations: [AMBIGUOUS_MATCH_LIMITATION],
});
const unavailable = unavailableAnalysisLimitations(node);
if (unavailable !== undefined)
return itemWithId({
...common,
status: "unknown",
match: unmatchedMatch(),
dimensions: ["availability", "coverage"],
limitations: unavailable,
left_node_id: null,
right_node_id: node.node_id,
});
const absenceObserved = absenceClaimable(context.leftGraph.coverage);
return itemWithId({
...common,
@@ -296,23 +274,6 @@ const rightOnlyItem = (
});
};
/**
* An unmatched node whose every observation is unavailable is the producer's
* marker for an analysis gap, not a positive application entity, so its
* presence on one side cannot establish an addition or removal.
*/
const unavailableAnalysisLimitations = (
node: ApplicationNode,
): string[] | undefined =>
node.observations.every(({ evidence }) => evidence.state === "unavailable")
? [
...new Set([
UNAVAILABLE_ANALYSIS_LIMITATION,
...node.observations.flatMap(({ evidence }) => evidence.limitations),
]),
]
: undefined;
const matchedMatch = (
pair: ApplicationVersionNodePair,
): Extract<
@@ -367,9 +328,6 @@ const unmatchedMatch = (): Extract<
const AMBIGUOUS_MATCH_LIMITATION =
"Multiple nodes share the same permitted match key; no cross-version pair was selected.";
const UNAVAILABLE_ANALYSIS_LIMITATION =
"This node records unavailable analysis on one side, so its absence from the other side is not an addition or removal.";
const INCOMPLETE_GRAPH_LIMITATION =
"The opposite graph is incomplete, so an unmatched node cannot be classified as added or removed.";
@@ -347,91 +347,3 @@ cliTest(
).toBe(true);
},
);
cliTest(
"keeps unselected source scopes unknown while genuine additions remain added",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-version-unselected-");
const write = async (name: string, files: Record<string, string>) => {
const directory = join(root, name);
await mkdir(directory);
for (const [path, text] of Object.entries(files))
await writeFile(join(directory, path), text);
return directory;
};
const lib = "export function shared() {\n return 1;\n}\n";
const main = 'export const name = "one";\n';
const full = await write("full", { "lib.js": lib, "main.js": main });
const copy = await write("copy", { "lib.js": lib, "main.js": main });
const mainOnly = await write("main-only", { "main.js": main });
const analyze = async (directory: string, ...selection: string[]) => {
const response = await cli.run({
arguments: [
"analyze-javascript-application",
directory,
...selection,
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode, response.stderr).toBe(0);
return parseEvidence(response.json);
};
const compare = async (name: string, left: unknown, right: unknown) => {
const input = join(root, `${name}.json`);
await writeFile(input, JSON.stringify({ left, right }));
const response = await cli.run({
arguments: ["compare-application-versions", input, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode, response.stderr).toBe(0);
return applicationVersionComparisonResultSchema.parse(
parseEvidence(response.json).normalized_result,
);
};
const complete = await analyze(full);
const selected = await analyze(full, "--source-path", "main.js");
const scope = javascriptApplicationAnalysisResultSchema
.parse(selected.normalized_result)
.graph.nodes.find((node) =>
node.observations.some(
({ properties }) =>
properties.path === "lib.js" &&
properties.operation === "select-javascript-sources",
),
);
if (scope === undefined) throw new Error("Missing unselected scope");
for (const [name, left, right, side] of [
["forward", complete, selected, "right_node_id"],
["reverse", selected, complete, "left_node_id"],
] as const) {
const result = await compare(name, left, right);
expect(result.summary).toMatchObject({ added: 0, removed: 0 });
const item = result.items.find((value) => value[side] === scope.node_id);
expect(item).toMatchObject({
status: "unknown",
node_kind: "unknown",
dimensions: ["availability", "coverage"],
});
expect(item?.limitations).toContain(
"Source is outside the caller-selected source_paths; it was inventoried but not analyzed.",
);
}
const control = await compare("control", complete, await analyze(copy));
expect(control.summary).toMatchObject({
added: 0,
removed: 0,
changed: 0,
unknown: 0,
});
const growth = await compare("growth", await analyze(mainOnly), complete);
expect(growth.items).toContainEqual(
expect.objectContaining({
status: "added",
node_kind: "javascript-asset",
}),
);
},
);
@@ -0,0 +1,128 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { parse } from "plist";
import { describe, expect } from "vitest";
import { z } from "zod";
import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js";
import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js";
import { parseEvidence } from "../../src/domain/evidence.js";
import { execFileOutput } from "../../src/process/ExecFileOutput.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
const HDIUTIL = "/usr/bin/hdiutil";
const attachedDevices = (stdout: string): string[] =>
z
.object({
"system-entities": z.array(z.object({ "dev-entry": z.string() })),
})
.parse(parse(stdout))
["system-entities"].map(({ "dev-entry": device }) => device);
const imageAttached = async (imagePath: string): Promise<boolean> =>
z
.object({
images: z.array(z.object({ "image-path": z.string().optional() })),
})
.parse(parse((await execFileOutput(HDIUTIL, ["info", "-plist"])).stdout))
.images.some((image) => image["image-path"] === imagePath);
describe.skipIf(process.platform !== "darwin")(
"DMG inspection with a preexisting attachment",
() => {
cliTest(
"retains root observations and leaves another owner's attachment alone",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-dmg-preexisting-");
const source = join(root, "source");
const mountRoot = join(root, "foreign-mounts");
const imagePath = join(root, "fixture.dmg");
await mkdir(source);
await mkdir(mountRoot);
await writeFile(join(source, "file.txt"), "fixture\n");
await execFileOutput(HDIUTIL, [
"create",
"-quiet",
"-srcfolder",
source,
"-volname",
"PreexistingFixture",
"-format",
"UDZO",
imagePath,
]);
const devices = attachedDevices(
(
await execFileOutput(HDIUTIL, [
"attach",
"-readonly",
"-nobrowse",
"-plist",
"-mountroot",
mountRoot,
imagePath,
])
).stdout,
);
try {
const inspected = await cli.run({
arguments: ["inspect-artifact", imagePath, "--json"],
});
expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(
0,
);
const inspection = artifactInspectionResultSchema.parse(
parseEvidence(inspected.json).normalized_result,
);
const inventoryEvidence = inspection.substeps[0]?.evidence;
if (inventoryEvidence === undefined)
throw new Error("CLI inspection did not retain DMG inventory");
const inventory = artifactInventoryResultSchema.parse(
inventoryEvidence.normalized_result,
);
expect(inventory.manifest).toMatchObject({
root_format: "dmg",
occurrence_count: 1,
});
expect(inventory.manifest.root_sha256).toBe(
createHash("sha256")
.update(await readFile(imagePath))
.digest("hex"),
);
expect(inventory.coverage).toEqual({
scope: "container-entries",
status: "unknown",
});
expect(inventory.limitations).toEqual([
expect.stringMatching(
/^DMG child inventory is unknown: hdiutil refused a private read-only attachment .*release it and retry/u,
),
]);
expect(inventory.provenance).toContainEqual(
expect.objectContaining({
arguments: expect.arrayContaining(["attach", imagePath]),
exit_code: expect.any(Number),
effects: ["read"],
}),
);
expect(inspection.limitations).toEqual(
expect.arrayContaining(inventory.limitations),
);
expect(await imageAttached(imagePath)).toBe(true);
} finally {
const wholeDisk =
devices.find((device) => /^\/dev\/disk\d+$/u.test(device)) ??
devices[0];
if (wholeDisk !== undefined)
await execFileOutput(HDIUTIL, ["detach", wholeDisk]);
}
expect(await imageAttached(imagePath)).toBe(false);
},
120_000,
);
},
);
@@ -1102,6 +1102,82 @@ describe("native DMG command failure diagnostics", () => {
});
});
describe("native DMG preexisting attachment", () => {
it.each([
{ listed: "target", outcome: "unknown coverage" },
{ listed: "other", outcome: "failure" },
] as const)(
"reports $outcome when attach fails while hdiutil lists the $listed image",
async ({ listed }) => {
const parent = await createTestTempDirectory("rea-dmg-preexisting-");
const path = join(parent, "image.dmg");
const image = Buffer.alloc(512);
image.write("koly", 0, "ascii");
await writeFile(path, image);
const calls: string[][] = [];
const host: NativeDmgHost = {
run(arguments_) {
calls.push([...arguments_]);
if (arguments_[0] === "info")
return Promise.resolve({
stdout: infoPlist(
listed === "target" ? path : join(parent, "other.dmg"),
[{ "dev-entry": "/dev/disk51", "mount-point": "/Volumes/X" }],
),
exitCode: 0,
});
if (arguments_[0] === "attach")
return Promise.resolve({
stdout: "",
stderr: "hdiutil: attach failed - Resource busy\n",
exitCode: 1,
});
return Promise.resolve({ stdout: "", exitCode: 0 });
},
};
const resourceScope = new ArtifactResourceScope();
onTestFinished(() => resourceScope.close());
const inventory = scanCanonicalArtifactInventory(
path,
{ resourceScope },
(inputPath, _format, environment) =>
new NativeDmgArtifactReader(inputPath, environment, host),
);
if (listed === "other")
await expect(inventory).rejects.toMatchObject({
reason: "unavailable",
message: expect.stringContaining("Resource busy"),
});
else {
const snapshot = await inventory;
expect(snapshot.manifest).toMatchObject({
root_format: "dmg",
occurrence_count: 1,
});
expect(snapshot.coverage).toEqual({
scope: "container-entries",
status: "unknown",
});
expect(snapshot.limitations).toEqual([
expect.stringMatching(
/^DMG child inventory is unknown: hdiutil refused a private read-only attachment \(exit 1\)/u,
),
]);
expect(snapshot.provenance.map((entry) => entry.exit_code)).toEqual([
0, 1,
]);
}
expect(calls.map(([operation]) => operation)).toEqual([
"verify",
"info",
"attach",
"info",
]);
},
);
});
describe("native DMG command diagnostics", () => {
it("keeps unknown attach failures unavailable with command evidence", async () => {
await expect(