Files
rea/src/artifacts/NativeDmgArtifactReader.ts
T

887 lines
29 KiB
TypeScript

import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
import { mkdtemp, realpath, rm, type FileHandle } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import type { Readable } from "node:stream";
import type { ArtifactCommand } from "../domain/artifactGraph.js";
import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactReader,
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
import {
NonRegularFileReadError,
RegularFileAdmissionFailure,
openRegularFile,
} from "../filesystem/RegularFile.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import {
ExecFileOutputCancelledError,
execFileOutput,
execFileOutputFailure,
} from "../process/ExecFileOutput.js";
import {
parseNativeDmgAttachOutput,
parseNativeDmgInfo,
resolveNativeDmgOwnership,
type NativeDmgAttachOutput,
type NativeDmgInfo,
} from "./NativeDmgOwnership.js";
const HDIUTIL_COMMAND_TIMEOUT_MS = 120_000;
const VERIFY_SETTLE_DELAYS_MS = [250, 500] as const;
// APFS ejects and busy reporting can lag a detach attempt.
const DETACH_SETTLE_DELAYS_MS = [250, 500, 1000] as const;
const delay = (milliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
/** Narrow host seam for tested, shell-free hdiutil lifecycle operations. */
export interface NativeDmgHost {
run(
arguments_: readonly string[],
signal?: AbortSignal,
options?: { readonly timeoutMs: number },
): Promise<{
readonly stdout: string;
readonly stderr?: string;
readonly exitCode: number;
readonly cause?: unknown;
}>;
/** Wait between detach rechecks; tests may resolve immediately. */
delay?(milliseconds: number): Promise<void>;
}
interface NativeDmgFailureClassification {
readonly reason: ArtifactReaderFailure["reason"];
readonly verifyDiagnostic?: string;
/**
* hdiutil could not recognize the target, for a reason it reported only in
* localized text; the cause stays unknown unless the target cannot be opened.
*/
readonly unrecognizedImage?: boolean;
}
class NativeDmgCommandFailure extends ArtifactReaderFailure {
readonly processNotStarted: boolean;
readonly verifyDiagnostic: string | undefined;
readonly unrecognizedImage: boolean;
readonly exitCode: number | null;
constructor(
failure: NativeDmgFailureClassification,
message: string,
readonly stdout: string,
options?: {
readonly cause?: unknown;
readonly cancellation?: { readonly reason: unknown };
readonly processNotStarted?: boolean;
readonly exitCode?: number;
},
) {
super(
failure.reason,
message,
options === undefined
? undefined
: {
...(options.cause === undefined ? {} : { cause: options.cause }),
...(options.cancellation === undefined
? {}
: { cancellation: options.cancellation }),
},
);
this.processNotStarted = options?.processNotStarted ?? false;
this.verifyDiagnostic = failure.verifyDiagnostic;
this.unrecognizedImage = failure.unrecognizedImage ?? false;
this.exitCode = options?.exitCode ?? null;
}
}
const createSystemHost = (
environment: Readonly<NodeJS.ProcessEnv>,
): NativeDmgHost => ({
delay,
async run(arguments_, signal, options) {
try {
const { stdout, stderr } = await execFileOutput(
"/usr/bin/hdiutil",
[...arguments_],
{
env: environment,
stopSignal: "SIGTERM",
...(options === undefined ? {} : { timeout: options.timeoutMs }),
...(signal === undefined ? {} : { signal }),
},
);
return { stdout, stderr, exitCode: 0 };
} catch (cause: unknown) {
const output = execFileOutputFailure(cause);
if (cause instanceof ExecFileOutputCancelledError)
throw new NativeDmgCommandFailure(
{ reason: "cancelled" },
"DMG operation cancelled",
output?.stdout ?? "",
{ cause, cancellation: { reason: cause.cause } },
);
const exitCode =
typeof output?.code === "number" ? output.code : processExitCode(cause);
if (exitCode !== undefined)
return {
stdout: output?.stdout ?? processOutput(cause, "stdout"),
stderr: output?.stderr ?? processOutput(cause, "stderr"),
exitCode,
cause,
};
throw new NativeDmgCommandFailure(
classifyCommandFailure(cause, arguments_[0]),
`hdiutil ${arguments_[0] ?? "operation"} failed: ${describeCommandFailure(cause, arguments_)}`,
output?.stdout ?? "",
{ cause, processNotStarted: processNotStarted(cause) },
);
}
},
});
/** Read-only macOS DMG adapter that owns attachment and reverse-order detach. */
export class NativeDmgArtifactReader implements ArtifactReader {
readonly format = "file" as const;
private readonly host: NativeDmgHost;
readonly #provenance: ArtifactCommand[] = [];
#initialization: Promise<void> | undefined;
#directory: DirectoryArtifactReader | undefined;
#devices: string[] = [];
#inventoryConfirmedDevices = new Set<string>();
#mountRoot: string | undefined;
#baselineDevices = new Set<string>();
#attachOutput: NativeDmgAttachOutput | undefined;
#attachOutputReturned = false;
#attachMayHaveMounted = false;
#ownershipUncertainty: string | undefined;
#closed = false;
#closePromise: Promise<void> | undefined;
readonly #activeDirectoryOperations = new Set<Promise<unknown>>();
#verificationProbeHandle: OwnedFileHandle | undefined;
constructor(
private readonly path: string,
environment: Readonly<NodeJS.ProcessEnv>,
host?: NativeDmgHost,
) {
if (process.platform !== "darwin" && host === undefined)
throw new ArtifactReaderFailure(
"unavailable",
"Native DMG traversal is available only on macOS",
);
this.host = host ?? createSystemHost(environment);
}
async *entries(
signal?: AbortSignal,
options?: ArtifactTraversalOptions,
): AsyncIterable<ArtifactEntry> {
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, {
onOmission: (omission) =>
options?.onOmission?.({
...omission,
path: `${prefix}/${omission.path}`,
}),
})
[Symbol.asyncIterator]();
try {
for (;;) {
if (this.#closed) return;
const next = await this.#trackDirectoryOperation(() => entries.next());
if (this.#closed) return;
if (next.done) return;
yield { ...next.value, path: `${prefix}/${next.value.path}` };
}
} finally {
await entries.return?.();
}
}
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
await this.#ensureAttached(signal);
const directory = this.#directory;
if (directory === undefined)
throw new ArtifactReaderFailure("unavailable", "DMG is not attached");
return this.#trackDirectoryOperation(() => directory.open(entry, signal));
}
provenance(): readonly ArtifactCommand[] {
return structuredClone(this.#provenance);
}
close(): Promise<void> {
this.#closed = true;
this.#closePromise ??= this.#closeOwnedResources().catch(
(cause: unknown) => {
this.#closePromise = undefined;
throw cause;
},
);
return this.#closePromise;
}
async #closeOwnedResources(): Promise<void> {
await this.#initialization?.catch(() => undefined);
await Promise.allSettled(this.#activeDirectoryOperations);
await this.#closeVerificationProbeHandle();
await this.#closeMountedDirectory();
let detachFailure = await this.#detachOwnedDevices();
if (
this.#devices.length === 0 &&
this.#mountRoot !== undefined &&
!this.#attachMayHaveMounted
)
await rm(this.#mountRoot, { recursive: true, force: true }).then(
() => {
this.#mountRoot = undefined;
this.#directory = undefined;
},
(cause: unknown) => {
detachFailure ??= cause;
},
);
if (this.#attachMayHaveMounted) {
detachFailure ??= new Error(
this.#ownershipUncertainty ??
"hdiutil attachment ownership remains unknown",
);
}
if (detachFailure !== undefined)
throw new ArtifactReaderFailure(
"unavailable",
`DMG detach or mount-root cleanup failed for ${JSON.stringify(this.path)}: ${failureMessage(detachFailure)}`,
{
cause: detachFailure,
cleanup: {
reason: failureMessage(detachFailure),
resources: [
...this.#devices.map((device) => `DMG device ${device}`),
...(this.#attachMayHaveMounted
? [
`DMG attachment ownership unknown at mount root ${this.#mountRoot ?? this.path}`,
]
: []),
...(this.#mountRoot === undefined
? []
: [`DMG mount root ${this.#mountRoot}`]),
],
},
},
);
}
async #closeVerificationProbeHandle(): Promise<void> {
const owner = this.#verificationProbeHandle;
if (owner === undefined) return;
try {
await owner.close();
this.#verificationProbeHandle = undefined;
} catch (cause: unknown) {
throw ArtifactReaderFailure.withCleanup(
cause,
ArtifactReaderFailure.cleanupObservation(
cause,
`DMG verification target descriptor ${this.path}`,
),
);
}
}
async #detachOwnedDevices(): Promise<unknown> {
let detachFailure = await this.#reconcileAttachState();
let remainingDevices: string[] = [];
for (const device of [...this.#devices].reverse()) {
const failure = await this.#detach(device);
if (failure !== undefined) {
remainingDevices.push(device);
detachFailure ??= failure;
}
}
// APFS container detach may briefly leave its backing image listed.
for (const delayMs of DETACH_SETTLE_DELAYS_MS) {
if (remainingDevices.length === 0) break;
await (this.host.delay ?? delay)(delayMs);
const stillAttached: string[] = [];
for (const device of remainingDevices) {
if (!(await this.#isAttached(device))) continue;
const failure = await this.#detach(device);
if (failure !== undefined) {
stillAttached.push(device);
detachFailure = failure;
}
}
remainingDevices = stillAttached;
}
if (remainingDevices.length === 0) detachFailure = undefined;
this.#devices = remainingDevices.reverse();
return detachFailure;
}
async #closeMountedDirectory(): Promise<void> {
if (this.#directory !== undefined) {
try {
await this.#directory.close();
this.#directory = undefined;
} catch (cause: unknown) {
const mountRoot = this.#mountRoot ?? this.path;
throw ArtifactReaderFailure.withCleanup(cause, {
reason: `DMG mounted directory remains owned: ${failureMessage(cause)}`,
resources: [
`DMG mounted directory at ${mountRoot}`,
`DMG mount root ${mountRoot}`,
...this.#devices.map((device) => `DMG device ${device}`),
],
});
}
}
}
/** Detach one device; return the failure only while it remains attached. */
async #detach(device: string): Promise<unknown> {
try {
await runChecked(this.host, ["detach", device], undefined, {
timeoutMs: HDIUTIL_COMMAND_TIMEOUT_MS,
});
this.#provenance.push(command(["detach", device], ["mount"]));
return undefined;
} catch (cause: unknown) {
return (await this.#isAttached(device)) ? cause : undefined;
}
}
async #reconcileAttachState(): Promise<unknown> {
if (!this.#attachMayHaveMounted) return undefined;
try {
await this.#resolveAttachOwnership();
return undefined;
} catch (cause: unknown) {
this.#ownershipUncertainty = failureMessage(cause);
return cause;
}
}
/** Treat unknown hdiutil state as attached during cleanup. */
async #isAttached(device: string): Promise<boolean> {
try {
const parsed = await this.#readInfo();
const isListed = parsed.images.some((image) =>
image["system-entities"].some(
(entity) => entity["dev-entry"] === device,
),
);
if (isListed) {
this.#inventoryConfirmedDevices.add(device);
return true;
}
return !this.#inventoryConfirmedDevices.has(device);
} catch {
return true;
}
}
async #ensureAttached(signal?: AbortSignal): Promise<void> {
if (this.#closed)
throw new ArtifactReaderFailure("unavailable", "DMG reader is closed");
this.#initialization ??= this.#attach(signal);
await this.#initialization;
if (this.#closed)
throw new ArtifactReaderFailure("unavailable", "DMG reader is closed");
}
async #trackDirectoryOperation<Result>(
operation: () => Promise<Result>,
): Promise<Result> {
if (this.#closed)
throw new ArtifactReaderFailure("unavailable", "DMG reader is closed");
const running = Promise.resolve().then(operation);
this.#activeDirectoryOperations.add(running);
try {
return await running;
} finally {
this.#activeDirectoryOperations.delete(running);
}
}
async #attach(signal?: AbortSignal): Promise<void> {
await this.#verify(signal);
const baseline = await this.#readInfo();
this.#baselineDevices = new Set(
baseline.images.flatMap((image) =>
image["system-entities"].map(({ "dev-entry": device }) => device),
),
);
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, 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) {
throw new ArtifactReaderFailure(
"format",
"hdiutil returned a malformed attachment plist",
{ cause },
);
}
if (parsed["system-entities"].length === 0)
throw new ArtifactReaderFailure(
"format",
"hdiutil returned no attached devices",
);
this.#attachOutput = parsed;
const ownership = await this.#resolveAttachOwnership();
if (ownership !== "owned")
throw new ArtifactReaderFailure(
ownership === "unknown" ? "unavailable" : "path",
ownership === "unknown"
? `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.#directory = new DirectoryArtifactReader(mountRoot);
} catch (cause: unknown) {
if (cause instanceof NativeDmgCommandFailure)
this.#provenance.push(
command(arguments_, ["read", "mount"], cause.exitCode),
);
if (cause instanceof NativeDmgCommandFailure && cause.stdout !== "") {
try {
this.#attachOutput = parseNativeDmgAttachOutput(cause.stdout);
this.#attachOutputReturned = true;
} catch {
// The original command failure remains primary; fresh info still recovers mounts.
}
}
if (cause instanceof NativeDmgCommandFailure && cause.processNotStarted) {
this.#attachMayHaveMounted = false;
this.#ownershipUncertainty = undefined;
}
throw cause;
}
}
async #verify(signal?: AbortSignal): Promise<void> {
const arguments_ = ["verify", this.path];
const deadline = performance.now() + HDIUTIL_COMMAND_TIMEOUT_MS;
for (let attempt = 0; ; attempt += 1) {
this.#throwIfVerificationCancelled(signal);
try {
await runChecked(this.host, arguments_, signal, {
timeoutMs: Math.max(1, Math.ceil(deadline - performance.now())),
});
this.#provenance.push(command(arguments_, ["read"]));
return;
} catch (cause: unknown) {
if (!(cause instanceof NativeDmgCommandFailure)) throw cause;
this.#provenance.push(command(arguments_, ["read"], cause.exitCode));
if (cause.unrecognizedImage) {
const unreadable = await this.#isUnreadableTarget(signal, cause);
this.#throwIfVerificationCancelled(signal);
if (unreadable)
throw new NativeDmgCommandFailure(
{ reason: "io" },
cause.message,
cause.stdout,
{
cause: cause.cause,
...(cause.exitCode === null
? {}
: { exitCode: cause.exitCode }),
},
);
}
const delayMs = VERIFY_SETTLE_DELAYS_MS[attempt];
// Retry only the producer's explicit transient diagnostic, or a localized
// refusal of a readable target that may be the same, before any mount.
if (
(cause.verifyDiagnostic !== "Resource temporarily unavailable" &&
!cause.unrecognizedImage) ||
delayMs === undefined ||
deadline - performance.now() <= delayMs
)
throw cause;
await (this.host.delay ?? delay)(delayMs);
if (performance.now() >= deadline) throw cause;
}
}
}
async #readInfo(): Promise<NativeDmgInfo> {
const info = await runChecked(this.host, ["info", "-plist"], undefined, {
timeoutMs: HDIUTIL_COMMAND_TIMEOUT_MS,
});
try {
return parseNativeDmgInfo(info.stdout);
} catch (cause: unknown) {
throw new ArtifactReaderFailure(
"format",
"hdiutil info -plist returned malformed image inventory",
{ cause },
);
}
}
async #resolveAttachOwnership(): Promise<"owned" | "none" | "unknown"> {
const mountRoot = this.#mountRoot;
if (mountRoot === undefined)
throw new Error("DMG mount root is unavailable for ownership discovery");
const ownership = resolveNativeDmgOwnership({
info: await this.#readInfo(),
...(this.#attachOutput === undefined
? {}
: { attachOutput: this.#attachOutput }),
attachOutputReturned: this.#attachOutputReturned,
mountRoot,
baselineDevices: this.#baselineDevices,
});
if (ownership.status === "owned") {
this.#devices = [...ownership.devices];
if (ownership.source === "info")
for (const device of ownership.devices)
this.#inventoryConfirmedDevices.add(device);
this.#attachMayHaveMounted = false;
this.#ownershipUncertainty = undefined;
return "owned";
}
if (ownership.status === "unknown") {
this.#ownershipUncertainty = ownership.reason;
return "unknown";
}
this.#attachMayHaveMounted = false;
this.#ownershipUncertainty = undefined;
this.#devices = [];
return "none";
}
#cancelledVerification(cause: unknown): NativeDmgCommandFailure {
return new NativeDmgCommandFailure(
{ reason: "cancelled" },
"DMG operation cancelled",
"",
{ cause },
);
}
#throwIfVerificationCancelled(signal: AbortSignal | undefined): void {
if (signal?.aborted === true)
throw this.#cancelledVerification(signal.reason);
}
async #isUnreadableTarget(
signal: AbortSignal | undefined,
primaryFailure: NativeDmgCommandFailure,
): Promise<boolean> {
let handle: FileHandle;
try {
handle = await openRegularFile(this.path, {
symlinks: "follow",
...(signal === undefined ? {} : { signal }),
});
} catch (cause: unknown) {
if (cause instanceof RegularFileAdmissionFailure) {
this.#verificationProbeHandle = cause.owner;
const cancellation = regularFileCancellation(cause.cause);
const failure =
cancellation === undefined
? primaryFailure
: this.#cancelledVerification(cancellation.reason);
throw ArtifactReaderFailure.withCleanup(
failure,
ArtifactReaderFailure.cleanupObservation(
cause.cleanupCause,
`DMG verification target descriptor ${this.path}`,
),
);
}
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined)
throw this.#cancelledVerification(cancellation.reason);
if (cause instanceof NonRegularFileReadError) return false;
return classifyCommandFailure(cause, undefined).reason === "io";
}
const owner = new OwnedFileHandle(handle);
this.#verificationProbeHandle = owner;
try {
await owner.close();
this.#verificationProbeHandle = undefined;
} catch (cause: unknown) {
throw ArtifactReaderFailure.withCleanup(
signal?.aborted === true
? this.#cancelledVerification(signal.reason)
: primaryFailure,
ArtifactReaderFailure.cleanupObservation(
cause,
`DMG verification target descriptor ${this.path}`,
),
);
}
if (signal?.aborted === true)
throw this.#cancelledVerification(signal.reason);
return false;
}
}
const failureMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
const processNotStarted = (cause: unknown): boolean => {
if (!(cause instanceof Error)) return false;
const syscall = Reflect.get(cause, "syscall");
const code = Reflect.get(cause, "code");
const errno = Reflect.get(cause, "errno");
return (
typeof syscall === "string" &&
syscall.startsWith("spawn ") &&
(typeof code === "string" ||
typeof code === "number" ||
typeof errno === "number")
);
};
const runChecked = async (
host: NativeDmgHost,
arguments_: readonly string[],
signal?: AbortSignal,
options?: { readonly timeoutMs: number },
): Promise<{ readonly stdout: string; readonly exitCode: 0 }> => {
let result: Awaited<ReturnType<NativeDmgHost["run"]>>;
try {
result = await host.run(arguments_, signal, options);
} catch (cause: unknown) {
if (cause instanceof ArtifactReaderFailure) throw cause;
if (cause instanceof ExecFileOutputCancelledError)
throw new NativeDmgCommandFailure(
{ reason: "cancelled" },
"DMG operation cancelled",
execFileOutputFailure(cause)?.stdout ?? "",
{ cause, cancellation: { reason: cause.cause } },
);
const output = execFileOutputFailure(cause);
throw new NativeDmgCommandFailure(
classifyCommandFailure(cause, arguments_[0]),
`hdiutil ${arguments_[0] ?? "operation"} failed: ${describeCommandFailure(
cause,
arguments_,
)}`,
output?.stdout ?? "",
{ cause, processNotStarted: processNotStarted(cause) },
);
}
if (result.exitCode !== 0) {
const failure = {
command: "/usr/bin/hdiutil",
exitCode: result.exitCode,
...(result.cause === undefined
? {}
: { code: processExitCode(result.cause) }),
stdout: result.stdout,
...(result.stderr === undefined ? {} : { stderr: result.stderr }),
};
const classification = classifyCommandFailure(failure, arguments_[0]);
const details = describeCommandFailure(failure, arguments_);
const unknownVerifyFailure =
arguments_[0] === "verify" && classification.reason === "unavailable";
throw new NativeDmgCommandFailure(
classification,
unknownVerifyFailure
? `hdiutil verify failed; captured diagnostics do not establish the failure cause: ${details}`
: `hdiutil ${arguments_[0] ?? "operation"} failed: ${details}`,
result.stdout,
{ cause: result.cause ?? failure, exitCode: result.exitCode },
);
}
return { stdout: result.stdout, exitCode: 0 };
};
const describeCommandFailure = (
cause: unknown,
arguments_: readonly string[],
): string => {
const fields: Record<string, string | number | readonly string[]> = {
command: "/usr/bin/hdiutil",
arguments: [...arguments_],
};
if (typeof cause === "object" && cause !== null) {
for (const key of [
"code",
"exitCode",
"errno",
"syscall",
"signal",
"stdout",
"stderr",
] as const) {
const value = Reflect.get(cause, key);
if (typeof value === "string" || typeof value === "number")
fields[key] = value;
}
const code = Reflect.get(cause, "code");
if (typeof code === "number") fields.exitCode = code;
}
if (cause instanceof ArtifactReaderFailure) fields.message = cause.message;
else if (cause instanceof Error) fields.message = cause.message;
return JSON.stringify(fields);
};
const processExitCode = (cause: unknown): number | undefined => {
if (typeof cause !== "object" || cause === null) return undefined;
const exitCode = Reflect.get(cause, "exitCode");
if (typeof exitCode === "number") return exitCode;
const code = Reflect.get(cause, "code");
return typeof code === "number" ? code : undefined;
};
const processOutput = (cause: unknown, field: "stdout" | "stderr"): string => {
if (typeof cause !== "object" || cause === null) return "";
const value = Reflect.get(cause, field);
return typeof value === "string" ? value : "";
};
const classifyCommandFailure = (
cause: unknown,
operation: string | undefined,
): NativeDmgFailureClassification => {
if (typeof cause !== "object" || cause === null)
return { reason: "unavailable" };
const code = Reflect.get(cause, "code");
const exitCode = Reflect.get(cause, "exitCode");
if (typeof code === "number" || typeof exitCode === "number") {
if (operation !== "verify") return { reason: "unavailable" };
return classifyVerifyFailure(
processOutput(cause, "stdout"),
processOutput(cause, "stderr"),
);
}
if (code === "ENOENT") {
const syscall = Reflect.get(cause, "syscall");
return {
reason:
typeof syscall === "string" && syscall.startsWith("spawn")
? "unavailable"
: "io",
};
}
if (
code === "EACCES" ||
code === "EPERM" ||
code === "EIO" ||
code === "ENOTDIR" ||
code === "EISDIR" ||
code === "ENODEV" ||
code === "EROFS" ||
code === "EMFILE" ||
code === "ENFILE"
)
return { reason: "io" };
return { reason: "unavailable" };
};
const classifyVerifyFailure = (
stdout: string,
stderr: string,
): NativeDmgFailureClassification => {
const diagnostic = verifyFailureDiagnostic(stdout, stderr);
const reason = verifyFailureReason(diagnostic);
const untranslated =
reason === undefined
? untranslatedVerifyFailure(stdout, stderr)
: undefined;
return {
...untranslated,
reason: reason ?? untranslated?.reason ?? "unavailable",
...(diagnostic === undefined ? {} : { verifyDiagnostic: diagnostic }),
};
};
const verifyFailureReason = (
diagnostic: string | undefined,
): ArtifactReaderFailure["reason"] | undefined => {
switch (diagnostic) {
// hdiutil follows AppleLanguages, so en-GB and en-AU spell it "recognised".
case "image not recognized":
case "image not recognised":
return "format";
case "invalid checksum":
case "image data corrupted":
return "integrity";
case "No such file or directory":
case "Permission denied":
case "Input/output error":
return "io";
case "Resource temporarily unavailable":
return "unavailable";
default:
return undefined;
}
};
const verifyFailureDiagnostic = (
stdout: string,
stderr: string,
): string | undefined => {
const prefix = "hdiutil: verify failed - ";
return outputLines(stdout, stderr)
.find((line) => line.startsWith(prefix))
?.slice(prefix.length);
};
// hdiutil localizes the trailing failure reason but not its own status lines.
const INVALID_CHECKSUM_LINE = /^hdiutil: verify: checksum of ".*" is INVALID$/u;
const UNRECOGNIZED_IMAGE_LINE =
/^hdiutil: verify: unable to recognize ".*" as a disk image\./u;
const untranslatedVerifyFailure = (
stdout: string,
stderr: string,
): NativeDmgFailureClassification | undefined => {
const lines = outputLines(stdout, stderr);
if (lines.some((line) => INVALID_CHECKSUM_LINE.test(line)))
return { reason: "integrity" };
// Malformed, unreadable and transiently busy targets all produce this line;
// only the localized reason distinguishes them, so the cause stays unknown.
if (lines.some((line) => UNRECOGNIZED_IMAGE_LINE.test(line)))
return { reason: "unavailable", unrecognizedImage: true };
return undefined;
};
const outputLines = (stdout: string, stderr: string): string[] =>
`${stdout}\n${stderr}`.split(/\r?\n/u).map((line) => line.trim());
const command = (
arguments_: readonly string[],
effects: ArtifactCommand["effects"],
exitCode: number | null = 0,
): ArtifactCommand => ({
tool: "/usr/bin/hdiutil",
arguments: [...arguments_],
tool_version: null,
executable_sha256: null,
exit_code: exitCode,
effects,
});