fix(artifacts): preserve reader failure diagnostics (#987)

* fix(artifacts): preserve filesystem and DMG diagnostics

* fix(artifacts): classify DMG verification failures

* fix(artifacts): distinguish inaccessible disk images

* fix(artifacts): classify damaged image checksums
This commit is contained in:
morluto 2026-10-07 23:58:07 +08:00 committed by GitHub
parent abd7a5ed09
commit fc4a9a0592
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 537 additions and 20 deletions

View File

@ -13,34 +13,48 @@ import {
export class DirectoryArtifactReader implements ArtifactReader {
readonly format = "directory" as const;
readonly #rootPromise: Promise<string>;
readonly #rootPath: string;
constructor(root: string) {
this.#rootPath = root;
this.#rootPromise = realpath(root);
}
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
const root = await this.#rootPromise;
const root = await this.#rootPromise.catch((cause: unknown) => {
throw directoryIoFailure("resolve artifact root", this.#rootPath, cause);
});
const pending = [root];
while (pending.length > 0) {
abortIfNeeded(signal);
const directory = pending.pop();
if (directory === undefined) break;
await assertContainedDirectory(root, directory);
await withDirectoryIoContext("inspect directory", directory, () =>
assertContainedDirectory(root, directory),
);
const children: string[] = [];
const handle = await opendir(directory);
const handle = await opendir(directory).catch((cause: unknown) => {
throw directoryIoFailure("open directory", directory, cause);
});
try {
for await (const child of handle) children.push(child.name);
} catch (cause: unknown) {
throw directoryIoFailure("read directory", directory, cause);
} finally {
// best-effort cleanup: directory-handle close must not mask traversal.
await handle.close().catch(() => undefined);
}
await assertContainedDirectory(root, directory);
await withDirectoryIoContext("inspect directory", directory, () =>
assertContainedDirectory(root, directory),
);
children.sort((left, right) => left.localeCompare(right, "en"));
const directories: string[] = [];
for (const name of children) {
abortIfNeeded(signal);
const absolute = join(directory, name);
const metadata = await lstat(absolute);
const metadata = await lstat(absolute).catch((cause: unknown) => {
throw directoryIoFailure("inspect entry", absolute, cause);
});
const path = relative(root, absolute).split(sep).join("/");
const kind = metadata.isSymbolicLink()
? "symlink"
@ -86,9 +100,17 @@ export class DirectoryArtifactReader implements ArtifactReader {
const handle = await open(
entry.adapterKey,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
).catch((cause: unknown) => {
throw directoryIoFailure("open entry", entry.adapterKey, cause);
});
try {
const observed = await handle.stat();
const observed = await handle.stat().catch((cause: unknown) => {
throw directoryIoFailure(
"inspect opened entry",
entry.adapterKey,
cause,
);
});
if (
!observed.isFile() ||
entry.sourceIdentity === undefined ||
@ -117,6 +139,38 @@ export class DirectoryArtifactReader implements ArtifactReader {
}
}
const directoryIoFailure = (
operation: string,
path: string,
cause: unknown,
): ArtifactReaderFailure => {
if (cause instanceof ArtifactReaderFailure) return cause;
const code =
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
typeof cause.code === "string"
? ` (${cause.code})`
: "";
return new ArtifactReaderFailure(
"io",
`Could not ${operation} at ${path}${code}`,
{ cause },
);
};
const withDirectoryIoContext = async <T>(
operation: string,
path: string,
action: () => Promise<T>,
): Promise<T> => {
try {
return await action();
} catch (cause: unknown) {
throw directoryIoFailure(operation, path, cause);
}
};
const abortIfNeeded = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(

View File

@ -37,13 +37,18 @@ export interface NativeDmgHost {
arguments_: readonly string[],
signal?: AbortSignal,
options?: { readonly timeoutMs: number },
): Promise<{ readonly stdout: string; readonly exitCode: number }>;
): Promise<{
readonly stdout: string;
readonly stderr?: string;
readonly exitCode: number;
readonly cause?: unknown;
}>;
}
const systemHost: NativeDmgHost = {
async run(arguments_, signal, options) {
try {
const { stdout } = await execFileOutput(
const { stdout, stderr } = await execFileOutput(
"/usr/bin/hdiutil",
[...arguments_],
{
@ -51,13 +56,23 @@ const systemHost: NativeDmgHost = {
...(signal === undefined ? {} : { signal }),
},
);
return { stdout, exitCode: 0 };
return { stdout, stderr, exitCode: 0 };
} catch (cause: unknown) {
if (cause instanceof Error && cause.name === "AbortError")
throw new ArtifactReaderFailure("cancelled", "DMG operation cancelled");
throw new ArtifactReaderFailure("format", "hdiutil rejected the DMG", {
cause,
});
const exitCode = processExitCode(cause);
if (exitCode !== undefined)
return {
stdout: processOutput(cause, "stdout"),
stderr: processOutput(cause, "stderr"),
exitCode,
cause,
};
throw new ArtifactReaderFailure(
commandFailureReason(cause, arguments_[0]),
`hdiutil ${arguments_[0] ?? "operation"} failed: ${describeCommandFailure(cause, arguments_)}`,
{ cause },
);
}
},
};
@ -250,15 +265,157 @@ const runChecked = async (
signal?: AbortSignal,
options?: { readonly timeoutMs: number },
): Promise<{ readonly stdout: string; readonly exitCode: 0 }> => {
const result = await host.run(arguments_, signal, options);
if (result.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 Error && cause.name === "AbortError")
throw new ArtifactReaderFailure("cancelled", "DMG operation cancelled", {
cause,
});
throw new ArtifactReaderFailure(
"unavailable",
`hdiutil ${arguments_[0] ?? "operation"} failed with a non-zero exit code`,
commandFailureReason(cause, arguments_[0]),
`hdiutil ${arguments_[0] ?? "operation"} failed: ${describeCommandFailure(
cause,
arguments_,
)}`,
{ 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 reason = commandFailureReason(failure, arguments_[0]);
const details = describeCommandFailure(failure, arguments_);
const unknownVerifyFailure =
arguments_[0] === "verify" && reason === "unavailable";
throw new ArtifactReaderFailure(
reason,
unknownVerifyFailure
? `hdiutil verify failed; captured diagnostics do not establish the failure cause: ${details}`
: `hdiutil ${arguments_[0] ?? "operation"} failed: ${details}`,
result.cause === undefined ? undefined : { cause: result.cause },
);
}
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 commandFailureReason = (
cause: unknown,
operation: string | undefined,
): ArtifactReaderFailure["reason"] => {
if (typeof cause !== "object" || cause === null) return "unavailable";
const code = Reflect.get(cause, "code");
const exitCode = Reflect.get(cause, "exitCode");
if (typeof code === "number" || typeof exitCode === "number")
return operation === "verify" ? verifyFailureReason(cause) : "unavailable";
if (code === "ENOENT") {
const syscall = Reflect.get(cause, "syscall");
return 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 "io";
return "unavailable";
};
const verifyFailureReason = (
cause: unknown,
): ArtifactReaderFailure["reason"] => {
const diagnostic = verifyFailureDiagnostic(
processOutput(cause, "stdout"),
processOutput(cause, "stderr"),
);
switch (diagnostic) {
case "image not recognized":
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";
default:
return "unavailable";
}
};
const verifyFailureDiagnostic = (
stdout: string,
stderr: string,
): string | undefined => {
const prefix = "hdiutil: verify failed - ";
return `${stdout}\n${stderr}`
.split(/\r?\n/u)
.map((line) => line.trim())
.find((line) => line.startsWith(prefix))
?.slice(prefix.length);
};
const command = (
arguments_: readonly string[],
effects: ArtifactCommand["effects"],

View File

@ -23,4 +23,13 @@ describe("execFileOutput", () => {
),
).rejects.toMatchObject({ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" });
});
it("retains both captured streams on a failed child process", async () => {
await expect(
execFileOutput(process.execPath, [
"-e",
'process.stdout.write("out"); process.stderr.write("err"); process.exitCode = 7;',
]),
).rejects.toMatchObject({ code: 7, stdout: "out", stderr: "err" });
});
});

View File

@ -25,6 +25,8 @@ export const execFileOutput = (
},
(error, stdout, stderr) => {
if (error !== null) {
Reflect.set(error, "stdout", stdout);
Reflect.set(error, "stderr", stderr);
reject(error);
return;
}

View File

@ -0,0 +1,57 @@
import { chmod, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
describe("artifact directory diagnostics", () => {
it.skipIf(process.platform === "win32")(
"preserves the inaccessible member path and errno through provider projection",
async () => {
const root = await createTestTempDirectory("rea-directory-eacces-");
const appPath = join(root, "Denied.app");
const contentsPath = join(appPath, "Contents");
const executablePath = join(contentsPath, "MacOS", "Fixture");
const blockedPath = join(contentsPath, "blocked.txt");
await mkdir(join(contentsPath, "MacOS"), { recursive: true });
await writeFile(
join(contentsPath, "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>",
);
const machHeader = Buffer.alloc(8);
machHeader.writeUInt32BE(0xfeedfacf, 0);
machHeader.writeUInt32BE(0x0100000c, 4);
await writeFile(executablePath, machHeader);
await writeFile(blockedPath, "member contents");
await chmod(blockedPath, 0);
try {
const target = await parseBinaryTarget(appPath);
expect(target.ok).toBe(true);
if (!target.ok) return;
const result = await new ArtifactProvider()
.createClient(target.value)
.execute("inventory_artifact", {});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "artifact_operation_failed",
details: {
operation: "inventory_artifact",
reason: "io",
},
});
const projected = projectAnalysisError(result.error);
const detail = projected.details?.detail;
expect(typeof detail).toBe("string");
expect(detail).toContain("Denied.app/Contents/blocked.txt");
expect(detail).toContain("EACCES");
} finally {
await chmod(blockedPath, 0o600);
}
},
);
});

View File

@ -1,9 +1,12 @@
import { mkdir, realpath, writeFile } from "node:fs/promises";
import { chmod, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { build } from "plist";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { execFileOutput } from "../../../../src/process/ExecFileOutput.js";
import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js";
import {
NativeDmgArtifactReader,
type NativeDmgHost,
@ -53,9 +56,19 @@ describe("native DMG artifact reader", () => {
it("rejects non-zero results and surfaces detach failure during attach cleanup", async () => {
await expect(
NativeDmgArtifactReader.create("/tmp/image.dmg", undefined, {
run: () => Promise.resolve({ stdout: "", exitCode: 1 }),
run: () =>
Promise.resolve({
stdout: "verify output",
stderr: "hdiutil: verify failed - image not recognized",
exitCode: 1,
}),
}),
).rejects.toThrow("non-zero exit code");
).rejects.toMatchObject({
reason: "format",
message: expect.stringMatching(
/"arguments":\["verify","\/tmp\/image\.dmg"\].*"exitCode":1.*"stderr":"hdiutil: verify failed - image not recognized"/u,
),
});
const host: NativeDmgHost = {
run(arguments_) {
@ -91,6 +104,231 @@ describe("native DMG artifact reader", () => {
});
});
describe("native DMG command failure diagnostics", () => {
it("keeps unknown attach failures unavailable with command evidence", async () => {
await expect(
NativeDmgArtifactReader.create("/tmp/image.dmg", undefined, {
run(arguments_) {
if (arguments_[0] === "verify")
return Promise.resolve({ stdout: "", exitCode: 0 });
return Promise.resolve({
stdout: "attach output",
stderr: "attach failed for an unknown reason",
exitCode: 2,
});
},
}),
).rejects.toMatchObject({
reason: "unavailable",
message: expect.stringMatching(
/"arguments":\["attach".*"exitCode":2.*"stderr":"attach failed for an unknown reason"/u,
),
});
});
it("keeps unrecognized numeric verify failures unknown and maps the observed I/O diagnostic", async () => {
const unknown = await NativeDmgArtifactReader.create(
"/tmp/image.dmg",
undefined,
{
run: () =>
Promise.resolve({
stdout: "verify stdout",
stderr: "hdiutil: verify failed - an unrecognized system failure",
exitCode: 1,
}),
},
).catch((cause: unknown) => cause);
expect(unknown).toBeInstanceOf(ArtifactReaderFailure);
if (!(unknown instanceof ArtifactReaderFailure)) return;
expect(unknown.reason).toBe("unavailable");
expect(unknown.message).toContain("do not establish the failure cause");
expect(unknown.message).toContain('"exitCode":1');
expect(unknown.message).toContain('"stdout":"verify stdout"');
expect(unknown.message).toContain("an unrecognized system failure");
await expect(
NativeDmgArtifactReader.create("/tmp/image.dmg", undefined, {
run: () =>
Promise.resolve({
stdout: "",
stderr: "hdiutil: verify failed - Input/output error\n",
exitCode: 1,
}),
}),
).rejects.toMatchObject({
reason: "io",
message: expect.stringContaining("Input/output error"),
});
await expect(
NativeDmgArtifactReader.create("/tmp/image.dmg", undefined, {
run: () =>
Promise.resolve({
stdout: "checksum output",
stderr: "hdiutil: verify failed - image data corrupted\n",
exitCode: 1,
}),
}),
).rejects.toMatchObject({
reason: "integrity",
message: expect.stringContaining("image data corrupted"),
});
});
it("distinguishes child launch and host permission failures", async () => {
await expect(
NativeDmgArtifactReader.create("/tmp/image.dmg", undefined, {
run: () =>
Promise.reject(
Object.assign(new Error("could not launch hdiutil"), {
code: "ENOENT",
syscall: "spawn /usr/bin/hdiutil",
}),
),
}),
).rejects.toMatchObject({
reason: "unavailable",
message: expect.stringContaining('"code":"ENOENT"'),
});
await expect(
NativeDmgArtifactReader.create("/tmp/image.dmg", undefined, {
run: () =>
Promise.reject(
Object.assign(new Error("permission denied"), {
code: "EACCES",
syscall: "spawn /usr/bin/hdiutil",
}),
),
}),
).rejects.toMatchObject({
reason: "io",
message: expect.stringContaining('"code":"EACCES"'),
});
});
});
describe("native DMG real verification diagnostics", () => {
it.skipIf(process.platform !== "darwin")(
"classifies a real hdiutil verify refusal as a format failure with captured output",
async () => {
const root = await createTestTempDirectory("rea-invalid-dmg-");
const path = join(root, "invalid.dmg");
await writeFile(path, "This is not a disk image.\n");
const failure = await NativeDmgArtifactReader.create(path).catch(
(cause: unknown) => cause,
);
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
if (!(failure instanceof ArtifactReaderFailure)) return;
expect(failure.reason).toBe("format");
expect(failure.message).toContain('"command":"/usr/bin/hdiutil"');
expect(failure.message).toContain('"arguments":["verify"');
expect(failure.message).toContain('"exitCode":1');
expect(failure.message).toContain('"stdout":""');
expect(failure.message).toContain('"stderr":"');
},
);
it.skipIf(process.platform !== "darwin")(
"classifies real hdiutil target access failures as I/O, separately from malformed images",
async () => {
const root = await createTestTempDirectory("rea-dmg-target-errors-");
const missingPath = join(root, "missing.dmg");
const unreadablePath = join(root, "unreadable.dmg");
await writeFile(unreadablePath, "not a disk image\n");
await chmod(unreadablePath, 0);
try {
for (const [path, expectedDiagnostic] of [
[missingPath, "No such file or directory"],
[unreadablePath, "Permission denied"],
] as const) {
const failure = await NativeDmgArtifactReader.create(path).catch(
(cause: unknown) => cause,
);
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
if (!(failure instanceof ArtifactReaderFailure)) continue;
expect(failure.reason).toBe("io");
expect(failure.message).toContain('"command":"/usr/bin/hdiutil"');
expect(failure.message).toContain('"arguments":["verify"');
expect(failure.message).toContain('"exitCode":1');
expect(failure.message).toContain('"stdout":""');
expect(failure.message).toContain(expectedDiagnostic);
expect(failure.cause).toBeInstanceOf(Error);
if (!(failure.cause instanceof Error)) continue;
expect(Reflect.get(failure.cause, "code")).toBe(1);
expect(Reflect.get(failure.cause, "stderr")).toContain(
expectedDiagnostic,
);
}
} finally {
await chmod(unreadablePath, 0o600);
}
},
);
it.skipIf(process.platform !== "darwin")(
"classifies a checksum mismatch in an hdiutil-created DMG as an integrity failure",
async () => {
const root = await createTestTempDirectory(
"rea-dmg-checksum-corruption-",
);
const source = join(root, "source");
await mkdir(source);
await writeFile(join(source, "payload.bin"), Buffer.alloc(65_536, 0x5a));
const imagePath = join(root, "fixture.dmg");
await execFileOutput("/usr/bin/hdiutil", [
"create",
"-quiet",
"-srcfolder",
source,
"-volname",
"Fixture",
"-format",
"UDRO",
imagePath,
]);
const valid = await execFileOutput("/usr/bin/hdiutil", [
"verify",
imagePath,
]);
expect(valid.stderr).toContain("is VALID");
const image = await readFile(imagePath);
const footer = image.subarray(-512);
expect(footer.subarray(0, 4).toString("ascii")).toBe("koly");
const dataForkOffset = Number(footer.readBigUInt64BE(24));
const dataForkLength = Number(footer.readBigUInt64BE(32));
expect(dataForkLength).toBeGreaterThan(4096);
const corruptionOffset = dataForkOffset + 4096;
expect(corruptionOffset).toBeLessThan(dataForkOffset + dataForkLength);
const originalByte = image[corruptionOffset];
if (originalByte === undefined)
throw new Error("expected a byte in the generated image data fork");
image[corruptionOffset] = originalByte ^ 0x01;
await writeFile(imagePath, image);
const failure = await NativeDmgArtifactReader.create(imagePath).catch(
(cause: unknown) => cause,
);
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
if (!(failure instanceof ArtifactReaderFailure)) return;
expect(failure.reason).toBe("integrity");
expect(failure.message).toContain('"command":"/usr/bin/hdiutil"');
expect(failure.message).toContain('"arguments":["verify"');
expect(failure.message).toContain('"exitCode":1');
expect(failure.message).toContain("calculated CRC32");
expect(failure.message).toContain("expected CRC32");
expect(failure.message).toContain("verify failed - invalid checksum");
expect(failure.cause).toBeInstanceOf(Error);
if (!(failure.cause instanceof Error)) return;
expect(Reflect.get(failure.cause, "code")).toBe(1);
expect(Reflect.get(failure.cause, "stderr")).toContain("is INVALID");
},
60_000,
);
});
it("owns the canonical mount root and detaches each observed whole image once", async () => {
const calls: string[][] = [];
const host: NativeDmgHost = {