Merge pull request #921 from morluto/fix/dmg-apfs-detach

fix(artifacts): accept APFS image disks ejected with their container
This commit is contained in:
Kaoru0822 2026-10-07 03:59:12 -07:00 committed by GitHub
commit d93ebec9df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 123 additions and 1 deletions

View File

@ -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,27 @@ export class NativeDmgArtifactReader implements ArtifactReader {
);
}
/**
* 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<boolean> {
try {
const info = await runChecked(this.host, ["info", "-plist"], undefined, {
timeoutMs: DETACH_TIMEOUT_MS,
});
const parsed = infoOutputSchema.parse(parse(info.stdout));
return parsed.images.some((image) =>
image["system-entities"].some(
(entity) => entity["dev-entry"] === device,
),
);
} catch {
return true;
}
}
async attach(signal?: AbortSignal): Promise<void> {
await runChecked(this.host, ["verify", this.path], signal);
this.#provenance.push(command(["verify", this.path], ["read"]));

View File

@ -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,85 @@ 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",
]);
});
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",
);
});
});