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:
parent
abd7a5ed09
commit
fc4a9a0592
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue