From b72c079a9a90c374bd6dd30ff8c4387306b8f59b Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:06:37 +0000 Subject: [PATCH 1/2] fix(artifacts): accept APFS image disks ejected with their container An APFS disk image attaches as an image disk plus a synthesized APFS container, and hdiutil reports both as whole disks. Detaching the container also ejects the image disk, so the reverse-order detach then failed with "No such file or directory" and every APFS DMG inventory ended in "DMG detach or mount-root cleanup failed". APFS is the default format of `hdiutil create` on current macOS. When a detach fails, ask `hdiutil info -plist` whether the device is still attached. A device that is gone counts as detached; one that is still listed, or whose state cannot be read, remains a cleanup failure. Co-Authored-By: Claude Opus 5.5 --- src/artifacts/NativeDmgArtifactReader.ts | 30 +++++- .../native/nativeDmgArtifactReader.test.ts | 92 +++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts index c29ba9d0e..b6dc543c0 100644 --- a/src/artifacts/NativeDmgArtifactReader.ts +++ b/src/artifacts/NativeDmgArtifactReader.ts @@ -23,6 +23,13 @@ const attachOutputSchema = z.object({ }), ), }); +const infoOutputSchema = z.object({ + images: z.array( + z.object({ + "system-entities": z.array(z.object({ "dev-entry": z.string() })), + }), + ), +}); /** Narrow host seam for tested, shell-free hdiutil lifecycle operations. */ export interface NativeDmgHost { @@ -113,7 +120,10 @@ export class NativeDmgArtifactReader implements ArtifactReader { }); this.#provenance.push(command(["detach", device], ["mount"])); } catch (cause: unknown) { - detachFailure ??= cause; + // Detaching a synthesized APFS container also ejects the image that + // backs it, so a later whole disk of the same image may already be + // gone. Only a device that is still attached is a cleanup failure. + if (await this.#isAttached(device)) detachFailure ??= cause; } } this.#devices = []; @@ -131,6 +141,24 @@ export class NativeDmgArtifactReader implements ArtifactReader { ); } + /** Report whether hdiutil still lists a device; unknown state counts as attached. */ + async #isAttached(device: string): Promise { + try { + const info = await runChecked(this.host, ["info", "-plist"], undefined, { + timeoutMs: DETACH_TIMEOUT_MS, + }); + const parsed = infoOutputSchema.parse(parse(info.stdout)); + this.#provenance.push(command(["info", "-plist"], ["read"])); + return parsed.images.some((image) => + image["system-entities"].some( + (entity) => entity["dev-entry"] === device, + ), + ); + } catch { + return true; + } + } + async attach(signal?: AbortSignal): Promise { await runChecked(this.host, ["verify", this.path], signal); this.#provenance.push(command(["verify", this.path], ["read"])); diff --git a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts index 6f3ec89eb..e5fcd9f70 100644 --- a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts +++ b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts @@ -61,6 +61,15 @@ describe("native DMG artifact reader", () => { run(arguments_) { if (arguments_[0] === "detach") return Promise.reject(new Error("detach failed")); + if (arguments_[0] === "info") + return Promise.resolve({ + stdout: build({ + images: [ + { "system-entities": [{ "dev-entry": "/dev/disk-fixture" }] }, + ], + }), + exitCode: 0, + }); if (arguments_[0] !== "attach") return Promise.resolve({ stdout: "", exitCode: 0 }); return Promise.resolve({ @@ -125,3 +134,86 @@ it("owns the canonical mount root and detaches each observed whole image once", ["detach", "/dev/disk41"], ]); }); + +describe("APFS disk image detach", () => { + // hdiutil reports the image disk and its synthesized APFS container as two + // whole disks; detaching the container ejects the image disk as well. + const apfsHost = (stillListed: readonly string[]) => { + const calls: string[][] = []; + const host: NativeDmgHost = { + async run(arguments_) { + calls.push([...arguments_]); + if (arguments_[0] === "detach" && arguments_[1] === "/dev/disk4") + throw new Error("hdiutil: detach failed - No such file or directory"); + if (arguments_[0] === "info") + return { + stdout: build({ + images: + stillListed.length === 0 + ? [] + : [ + { + "system-entities": stillListed.map((device) => ({ + "dev-entry": device, + })), + }, + ], + }), + exitCode: 0, + }; + if (arguments_[0] !== "attach") return { stdout: "", exitCode: 0 }; + const mountRoot = arguments_[arguments_.indexOf("-mountroot") + 1]; + if (mountRoot === undefined) throw new Error("missing mount root"); + const mountPoint = join(mountRoot, "Fixture"); + await mkdir(mountPoint); + return { + stdout: build({ + "system-entities": [ + { "dev-entry": "/dev/disk4" }, + { "dev-entry": "/dev/disk4s1" }, + { "dev-entry": "/dev/disk5s1", "mount-point": mountPoint }, + { "dev-entry": "/dev/disk5" }, + ], + }), + exitCode: 0, + }; + }, + }; + return { calls, host }; + }; + + it("accepts an image disk that its container detach already ejected", async () => { + const { calls, host } = apfsHost([]); + const reader = await NativeDmgArtifactReader.create( + "/tmp/image.dmg", + undefined, + host, + ); + await reader.close(); + expect( + calls.filter((call) => call[0] !== "verify" && call[0] !== "attach"), + ).toEqual([ + ["detach", "/dev/disk5"], + ["detach", "/dev/disk4"], + ["info", "-plist"], + ]); + expect(reader.provenance().map(({ arguments: args }) => args[0])).toEqual([ + "verify", + "attach", + "detach", + "info", + ]); + }); + + it("still fails when the device that rejected detach remains attached", async () => { + const { host } = apfsHost(["/dev/disk4", "/dev/disk4s1"]); + const reader = await NativeDmgArtifactReader.create( + "/tmp/image.dmg", + undefined, + host, + ); + await expect(reader.close()).rejects.toThrow( + "DMG detach or mount-root cleanup failed", + ); + }); +}); From 62634e4c0177bde8a08decc356da98fbe25d2000 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:28:03 +0000 Subject: [PATCH 2/2] fix(artifacts): keep the post-snapshot detach check out of provenance The inventory snapshot captures reader provenance before close() runs, so a `hdiutil info -plist` entry recorded during cleanup never reached CLI or MCP results. Detach entries already share that timing. Record neither instead of implying the query is observable. Co-Authored-By: Claude Opus 5.5 --- src/artifacts/NativeDmgArtifactReader.ts | 7 +++++-- .../providers/native/nativeDmgArtifactReader.test.ts | 1 - 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts index b6dc543c0..b5c6ee73a 100644 --- a/src/artifacts/NativeDmgArtifactReader.ts +++ b/src/artifacts/NativeDmgArtifactReader.ts @@ -141,14 +141,17 @@ export class NativeDmgArtifactReader implements ArtifactReader { ); } - /** Report whether hdiutil still lists a device; unknown state counts as attached. */ + /** + * Report whether hdiutil still lists a device; unknown state counts as + * attached. Like detach, this cleanup query runs after the inventory + * snapshot has captured provenance, so it is not recorded there. + */ async #isAttached(device: string): Promise { try { const info = await runChecked(this.host, ["info", "-plist"], undefined, { timeoutMs: DETACH_TIMEOUT_MS, }); const parsed = infoOutputSchema.parse(parse(info.stdout)); - this.#provenance.push(command(["info", "-plist"], ["read"])); return parsed.images.some((image) => image["system-entities"].some( (entity) => entity["dev-entry"] === device, diff --git a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts index e5fcd9f70..b04633b96 100644 --- a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts +++ b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts @@ -201,7 +201,6 @@ describe("APFS disk image detach", () => { "verify", "attach", "detach", - "info", ]); });