mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(artifacts): admit raw UDRW disk images (#7106)
* fix(artifacts): admit raw UDRW disk images Raw (UDRW, UDTO) disk images have no UDIF koly trailer, so target admission and root inventory both refused them as unsupported binaries. Recognize disk images in one domain owner (koly trailer, GPT, Apple partition map, MBR, or an unpartitioned HFS+/HFSX volume header) that both boundaries consume. hdiutil reports "has no checksum" for these images; the native reader now treats that status line as an explicit absent checksum, records the nonzero verify command, attaches read-only, and reports that integrity was not verified. Checksum mismatches and unrecognized images are still refused. Positional range reads used by these probes now fill until end of file so short host reads cannot hide a signature. Fixes #5687 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(artifacts): read disk image signature windows at the recognizer Target admission passed its first unfilled 4 KiB read to the raw-image checks, so a short host read could hide a GPT, APM, MBR or HFS+ signature that inventory (which fills its prefix) still observed. The recognizer now reads its own leading window through the caller's filling positional read, and target admission fills its format-probe prefix as well. The controlled short-read regression now covers target admission alongside inventory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a8e5eaf1aa
commit
0eb3919d69
8 files changed
+496
-64
No files matched your search
@@ -66,6 +66,7 @@ import {
|
||||
hasBinaryPlistSignature,
|
||||
MAX_XML_PLIST_PROBE_BYTES,
|
||||
} from "../domain/apple/appleArchiveFormat.js";
|
||||
import { hasDiskImageSignature } from "../domain/apple/diskImageFormat.js";
|
||||
import { hasGodotPckHeader } from "../domain/godotPckFormat.js";
|
||||
import {
|
||||
hasZipSignature,
|
||||
@@ -638,11 +639,10 @@ const detectArtifactFormat = async (
|
||||
const { handle, initial } = source;
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const lower = path.toLowerCase();
|
||||
const magic = Buffer.alloc(4096);
|
||||
const observed = await handle.read(magic, 0, magic.length, 0);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
// Fill the prefix so a short host read cannot hide a signature.
|
||||
const magic = Buffer.from(await readHandleRange(handle, 0, 4096, signal));
|
||||
if (
|
||||
observed.bytesRead >= 8 &&
|
||||
magic.length >= 8 &&
|
||||
magic.subarray(0, 8).equals(Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]))
|
||||
)
|
||||
throw new BinaryTargetError(
|
||||
@@ -650,39 +650,32 @@ const detectArtifactFormat = async (
|
||||
"WebAssembly v1 artifacts are not supported by the native opening route",
|
||||
{ constraint: "wasm_requires_static_inspection" },
|
||||
);
|
||||
if (hasZipSignature(magic.subarray(0, observed.bytesRead)))
|
||||
return zipRootPackageFormat(lower);
|
||||
if (hasGodotPckHeader(magic.subarray(0, observed.bytesRead)))
|
||||
return "godot-pck";
|
||||
if (hasZipSignature(magic)) return zipRootPackageFormat(lower);
|
||||
if (hasGodotPckHeader(magic)) return "godot-pck";
|
||||
// Binary plists, including keyed archives, often use other extensions.
|
||||
if (hasBinaryPlistSignature(magic.subarray(0, observed.bytesRead)))
|
||||
return "plist";
|
||||
if (hasBinaryPlistSignature(magic)) return "plist";
|
||||
const named = namedArtifactFormat(lower);
|
||||
if (named !== undefined) return named;
|
||||
if (
|
||||
lower.endsWith(".pkg") &&
|
||||
observed.bytesRead >= 4 &&
|
||||
magic.length >= 4 &&
|
||||
magic.subarray(0, 4).equals(Buffer.from("xar!"))
|
||||
)
|
||||
return "pkg";
|
||||
if (lower.endsWith(".dmg")) {
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const size = initial.size;
|
||||
if (size >= 512) {
|
||||
const trailer = Buffer.alloc(4);
|
||||
const read = await handle.read(trailer, 0, trailer.length, size - 512);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
if (read.bytesRead === 4 && trailer.equals(Buffer.from("koly")))
|
||||
return "dmg";
|
||||
}
|
||||
}
|
||||
if (
|
||||
lower.endsWith(".dmg") &&
|
||||
(await hasDiskImageSignature(initial.size, (position, length) =>
|
||||
readHandleRange(handle, position, length, signal),
|
||||
))
|
||||
)
|
||||
return "dmg";
|
||||
// XML property lists can use archive-specific suffixes such as .archive.
|
||||
// The named-format check above keeps .js, .map, and .asar classifications.
|
||||
const xmlProbe = createXmlPlistSignatureProbe();
|
||||
let xmlState = xmlProbe.write(magic.subarray(0, observed.bytesRead));
|
||||
let xmlState = xmlProbe.write(magic);
|
||||
if (xmlState === "matched") return "plist";
|
||||
const chunk = Buffer.alloc(XML_PLIST_PROBE_CHUNK_BYTES);
|
||||
let bytesRead = observed.bytesRead;
|
||||
let bytesRead = magic.length;
|
||||
while (xmlState === "pending" && bytesRead < MAX_XML_PLIST_PROBE_BYTES) {
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const read = await handle.read(
|
||||
@@ -747,7 +740,7 @@ const claimsLeadingExecutableHeader = async (
|
||||
size,
|
||||
);
|
||||
|
||||
/** Read one bounded range, honoring cancellation around the host read. */
|
||||
/** Fill one bounded range, honoring cancellation; a short result means end of file. */
|
||||
const readHandleRange = async (
|
||||
handle: FileHandle,
|
||||
position: number,
|
||||
@@ -756,9 +749,20 @@ const readHandleRange = async (
|
||||
): Promise<Uint8Array> => {
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const bytes = Buffer.alloc(length);
|
||||
const observed = await handle.read(bytes, 0, length, position);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
return bytes.subarray(0, observed.bytesRead);
|
||||
let filled = 0;
|
||||
while (filled < length) {
|
||||
if (filled > 0) throwIfTargetResolutionCancelled(signal);
|
||||
const observed = await handle.read(
|
||||
bytes,
|
||||
filled,
|
||||
length - filled,
|
||||
position + filled,
|
||||
);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
if (observed.bytesRead === 0) break;
|
||||
filled += observed.bytesRead;
|
||||
}
|
||||
return bytes.subarray(0, filled);
|
||||
};
|
||||
|
||||
/** Read exactly `size` bytes by offset; a short read means the file changed or ended early. */
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
type NativeDmgInfo,
|
||||
} from "./NativeDmgOwnership.js";
|
||||
const HDIUTIL_COMMAND_TIMEOUT_MS = 120_000;
|
||||
const DMG_CHECKSUM_ABSENT_LIMITATION =
|
||||
"hdiutil verify found no stored checksum in this disk image, so its integrity was not verified; the root SHA-256 identifies the inventoried bytes.";
|
||||
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;
|
||||
@@ -60,12 +62,15 @@ interface NativeDmgFailureClassification {
|
||||
* localized text; the cause stays unknown unless the target cannot be opened.
|
||||
*/
|
||||
readonly unrecognizedImage?: boolean;
|
||||
/** hdiutil found no stored checksum, so verification neither passed nor failed. */
|
||||
readonly checksumAbsent?: boolean;
|
||||
}
|
||||
|
||||
class NativeDmgCommandFailure extends ArtifactReaderFailure {
|
||||
readonly processNotStarted: boolean;
|
||||
readonly verifyDiagnostic: string | undefined;
|
||||
readonly unrecognizedImage: boolean;
|
||||
readonly checksumAbsent: boolean;
|
||||
readonly exitCode: number | null;
|
||||
|
||||
constructor(
|
||||
@@ -94,6 +99,7 @@ class NativeDmgCommandFailure extends ArtifactReaderFailure {
|
||||
this.processNotStarted = options?.processNotStarted ?? false;
|
||||
this.verifyDiagnostic = failure.verifyDiagnostic;
|
||||
this.unrecognizedImage = failure.unrecognizedImage ?? false;
|
||||
this.checksumAbsent = failure.checksumAbsent ?? false;
|
||||
this.exitCode = options?.exitCode ?? null;
|
||||
}
|
||||
}
|
||||
@@ -162,6 +168,7 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
#closePromise: Promise<void> | undefined;
|
||||
readonly #activeDirectoryOperations = new Set<Promise<unknown>>();
|
||||
#verificationProbeHandle: OwnedFileHandle | undefined;
|
||||
#checksumAbsent = false;
|
||||
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
@@ -184,6 +191,11 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
const directory = this.#directory;
|
||||
if (directory === undefined)
|
||||
throw new ArtifactReaderFailure("unavailable", "DMG is not attached");
|
||||
if (this.#checksumAbsent)
|
||||
options?.onCoverage?.({
|
||||
status: "complete",
|
||||
limitations: [DMG_CHECKSUM_ABSENT_LIMITATION],
|
||||
});
|
||||
const prefix = basename(this.path);
|
||||
const entries = directory
|
||||
.entries(signal, {
|
||||
@@ -498,6 +510,11 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof NativeDmgCommandFailure)) throw cause;
|
||||
this.#provenance.push(command(arguments_, ["read"], cause.exitCode));
|
||||
// No stored checksum (e.g. raw UDRW): attach with integrity unverified.
|
||||
if (cause.checksumAbsent) {
|
||||
this.#checksumAbsent = true;
|
||||
return;
|
||||
}
|
||||
if (cause.unrecognizedImage) {
|
||||
const unreadable = await this.#isUnreadableTarget(signal, cause);
|
||||
this.#throwIfVerificationCancelled(signal);
|
||||
@@ -852,6 +869,7 @@ const verifyFailureDiagnostic = (
|
||||
|
||||
// hdiutil localizes the trailing failure reason but not its own status lines.
|
||||
const INVALID_CHECKSUM_LINE = /^hdiutil: verify: checksum of ".*" is INVALID$/u;
|
||||
const NO_CHECKSUM_LINE = /^hdiutil: verify: ".*" has no checksum\.$/u;
|
||||
const UNRECOGNIZED_IMAGE_LINE =
|
||||
/^hdiutil: verify: unable to recognize ".*" as a disk image\./u;
|
||||
|
||||
@@ -862,6 +880,8 @@ const untranslatedVerifyFailure = (
|
||||
const lines = outputLines(stdout, stderr);
|
||||
if (lines.some((line) => INVALID_CHECKSUM_LINE.test(line)))
|
||||
return { reason: "integrity" };
|
||||
if (lines.some((line) => NO_CHECKSUM_LINE.test(line)))
|
||||
return { reason: "unavailable", checksumAbsent: true };
|
||||
// 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)))
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "./hashStableRootArtifact.js";
|
||||
import type { ArtifactInventoryOptions } from "./types.js";
|
||||
import { readMachoHeaderPrefixFacts } from "../../domain/apple/machoContainer.js";
|
||||
import { hasDiskImageSignature } from "../../domain/apple/diskImageFormat.js";
|
||||
import {
|
||||
observeMzWindowsHeader,
|
||||
type MzWindowsHeaderSignatureEvidence,
|
||||
@@ -152,9 +153,27 @@ const classifyRootFormat = async (
|
||||
evidence.prefix.subarray(0, 4).equals(Buffer.from("xar!"))
|
||||
)
|
||||
return "pkg";
|
||||
// Fill each range: a short result is end of file, not a partial host read.
|
||||
const read = async (position: number, length: number): Promise<Buffer> => {
|
||||
const bytes = Buffer.alloc(length);
|
||||
let filled = 0;
|
||||
while (filled < length) {
|
||||
abortIfNeeded(signal);
|
||||
const observed = await handle.read(
|
||||
bytes,
|
||||
filled,
|
||||
length - filled,
|
||||
position + filled,
|
||||
);
|
||||
abortIfNeeded(signal);
|
||||
if (observed.bytesRead === 0) break;
|
||||
filled += observed.bytesRead;
|
||||
}
|
||||
return bytes.subarray(0, filled);
|
||||
};
|
||||
if (
|
||||
lower.endsWith(".dmg") &&
|
||||
(await hasKolyTrailer(handle, archiveBytes, signal))
|
||||
(await hasDiskImageSignature(archiveBytes, read))
|
||||
)
|
||||
return "dmg";
|
||||
if (await locateGodotPck(handle, archiveBytes, signal, evidence.prefix))
|
||||
@@ -180,13 +199,7 @@ const classifyRootFormat = async (
|
||||
if (
|
||||
(format === "file" || format === "unknown") &&
|
||||
!claimsExecutableHeader(evidence.prefix, archiveBytes) &&
|
||||
(await locateTrailingZipArchive(archiveBytes, async (position, length) => {
|
||||
abortIfNeeded(signal);
|
||||
const bytes = Buffer.alloc(length);
|
||||
const read = await handle.read(bytes, 0, length, position);
|
||||
abortIfNeeded(signal);
|
||||
return bytes.subarray(0, read.bytesRead);
|
||||
})) !== undefined
|
||||
(await locateTrailingZipArchive(archiveBytes, read)) !== undefined
|
||||
)
|
||||
return zipRootPackageFormat(lower);
|
||||
if (["elf", "pe", "mach-o", "mach-o-universal"].includes(format)) {
|
||||
@@ -305,26 +318,3 @@ const readRootMzWindowsHeaderSignature = async (
|
||||
if (available === 0) return undefined;
|
||||
return { offset, bytes: window.subarray(0, available) };
|
||||
};
|
||||
|
||||
const hasKolyTrailer = async (
|
||||
handle: FileHandle,
|
||||
size: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
if (size < 512) return false;
|
||||
const trailer = Buffer.alloc(4);
|
||||
let bytes = 0;
|
||||
while (bytes < trailer.length) {
|
||||
abortIfNeeded(signal);
|
||||
const read = await handle.read(
|
||||
trailer,
|
||||
bytes,
|
||||
trailer.length - bytes,
|
||||
size - 512 + bytes,
|
||||
);
|
||||
abortIfNeeded(signal);
|
||||
if (read.bytesRead === 0) return false;
|
||||
bytes += read.bytesRead;
|
||||
}
|
||||
return trailer.equals(Buffer.from("koly"));
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { PositionalRead } from "../zipPackageFormat.js";
|
||||
|
||||
const UDIF_TRAILER_BYTES = 512;
|
||||
const UDIF_TRAILER_SIGNATURE = Buffer.from("koly");
|
||||
const GPT_HEADER_SIGNATURE = Buffer.from("EFI PART");
|
||||
const GPT_REVISION_1_0 = 0x0001_0000;
|
||||
const HFS_PLUS_HEADER_END = 1028;
|
||||
/** Leading bytes covering every raw layout signature below. */
|
||||
const RAW_SIGNATURE_PREFIX_BYTES = HFS_PLUS_HEADER_END;
|
||||
|
||||
/**
|
||||
* Recognize Apple disk images by structure, not by name or mountability.
|
||||
*
|
||||
* UDIF images carry a `koly` trailer. Raw images (UDRW, UDTO and other
|
||||
* `CRawDiskImage` formats) are plain device bytes, so they are recognized by
|
||||
* the partition map or the HFS+ volume header that `hdiutil` writes. This
|
||||
* establishes neither integrity nor that the image will mount. `read` must
|
||||
* fill each range; a short result means end of file.
|
||||
*/
|
||||
export const hasDiskImageSignature = async (
|
||||
size: number,
|
||||
read: PositionalRead,
|
||||
): Promise<boolean> => {
|
||||
if (!Number.isSafeInteger(size) || size <= 0) return false;
|
||||
if (size >= UDIF_TRAILER_BYTES) {
|
||||
const trailer = await read(
|
||||
size - UDIF_TRAILER_BYTES,
|
||||
UDIF_TRAILER_SIGNATURE.length,
|
||||
);
|
||||
if (Buffer.from(trailer).equals(UDIF_TRAILER_SIGNATURE)) return true;
|
||||
}
|
||||
// Read the exact window here so every caller observes the same bytes.
|
||||
return hasRawDiskImageSignature(
|
||||
Buffer.from(await read(0, Math.min(size, RAW_SIGNATURE_PREFIX_BYTES))),
|
||||
);
|
||||
};
|
||||
|
||||
const hasRawDiskImageSignature = (prefix: Buffer): boolean =>
|
||||
hasGptHeader(prefix) ||
|
||||
hasApplePartitionMap(prefix) ||
|
||||
hasMbrPartitionTable(prefix) ||
|
||||
hasHfsPlusVolumeHeader(prefix);
|
||||
|
||||
/** GUID partition table header at LBA 1 of a 512-byte-sector device. */
|
||||
const hasGptHeader = (prefix: Buffer): boolean => {
|
||||
if (prefix.length < 528) return false;
|
||||
if (!prefix.subarray(512, 520).equals(GPT_HEADER_SIGNATURE)) return false;
|
||||
const headerSize = prefix.readUInt32LE(524);
|
||||
return (
|
||||
prefix.readUInt32LE(520) === GPT_REVISION_1_0 &&
|
||||
headerSize >= 92 &&
|
||||
headerSize <= 512
|
||||
);
|
||||
};
|
||||
|
||||
/** Driver descriptor block (`ER`) followed by a partition map entry (`PM`). */
|
||||
const hasApplePartitionMap = (prefix: Buffer): boolean =>
|
||||
prefix.length >= 514 &&
|
||||
prefix[0] === 0x45 &&
|
||||
prefix[1] === 0x52 &&
|
||||
prefix[512] === 0x50 &&
|
||||
prefix[513] === 0x4d;
|
||||
|
||||
/** Boot signature plus a well-formed table with at least one used entry. */
|
||||
const hasMbrPartitionTable = (prefix: Buffer): boolean => {
|
||||
if (prefix.length < 512 || prefix[510] !== 0x55 || prefix[511] !== 0xaa)
|
||||
return false;
|
||||
let used = false;
|
||||
for (let entry = 446; entry < 510; entry += 16) {
|
||||
const status = prefix[entry];
|
||||
if (status !== 0x00 && status !== 0x80) return false;
|
||||
used ||= prefix[entry + 4] !== 0 && prefix.readUInt32LE(entry + 12) !== 0;
|
||||
}
|
||||
return used;
|
||||
};
|
||||
|
||||
/** Unpartitioned HFS+ (`H+`, version 4) or HFSX (`HX`, version 5) volume. */
|
||||
const hasHfsPlusVolumeHeader = (prefix: Buffer): boolean => {
|
||||
if (prefix.length < HFS_PLUS_HEADER_END || prefix[1024] !== 0x48)
|
||||
return false;
|
||||
const version = prefix.readUInt16BE(1026);
|
||||
return (
|
||||
(prefix[1025] === 0x2b && version === 4) ||
|
||||
(prefix[1025] === 0x58 && version === 5)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import { mkdir, realpath, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { execFileOutput } from "../../../src/process/ExecFileOutput.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const envelope = z.object({ ok: z.literal(true), data: z.unknown() });
|
||||
|
||||
const hdiutil = (...arguments_: string[]) =>
|
||||
execFileOutput("/usr/bin/hdiutil", arguments_);
|
||||
|
||||
describe.skipIf(process.platform !== "darwin")(
|
||||
"raw disk image CLI with system hdiutil",
|
||||
() => {
|
||||
cliTest(
|
||||
"inventories a detached checksum-free UDRW image and its UDZO conversion",
|
||||
async ({ cli }) => {
|
||||
const root = await realpath(
|
||||
await createTestTempDirectory("rea-raw-dmg-cli-"),
|
||||
);
|
||||
const source = join(root, "source");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "hello.txt"), "raw verifier fixture\n");
|
||||
const raw = join(root, "raw.dmg");
|
||||
const compressed = join(root, "compressed.dmg");
|
||||
await hdiutil(
|
||||
"create",
|
||||
"-quiet",
|
||||
"-volname",
|
||||
"RawFixture",
|
||||
"-srcfolder",
|
||||
source,
|
||||
"-format",
|
||||
"UDRW",
|
||||
"-fs",
|
||||
"HFS+",
|
||||
raw,
|
||||
);
|
||||
await hdiutil(
|
||||
"convert",
|
||||
"-quiet",
|
||||
raw,
|
||||
"-format",
|
||||
"UDZO",
|
||||
"-o",
|
||||
compressed,
|
||||
);
|
||||
|
||||
const inspect = async (path: string) => {
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-artifact", path, "--json", "--full-output"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(result.exitCode, result.stdout + result.stderr).toBe(0);
|
||||
const inspected = artifactInspectionResultSchema.parse(
|
||||
parseEvidence(envelope.parse(result.json).data).normalized_result,
|
||||
);
|
||||
const inventory = inspected.substeps[0]?.evidence.normalized_result;
|
||||
if (inventory === undefined)
|
||||
throw new Error("Expected inventory Evidence");
|
||||
expect(inventory.manifest.root_format).toBe("dmg");
|
||||
expect(inventory.coverage).toEqual({
|
||||
scope: "container-entries",
|
||||
status: "complete",
|
||||
});
|
||||
expect(
|
||||
inventory.occurrences.map(({ logical_path }) => logical_path),
|
||||
).toContain(`${path.slice(root.length + 1)}/RawFixture/hello.txt`);
|
||||
return inventory;
|
||||
};
|
||||
|
||||
const rawInventory = await inspect(raw);
|
||||
expect(
|
||||
rawInventory.provenance.map(
|
||||
({ arguments: [operation], exit_code }) => [operation, exit_code],
|
||||
),
|
||||
).toEqual([
|
||||
["verify", 1],
|
||||
["attach", 0],
|
||||
]);
|
||||
expect(rawInventory.limitations).toContainEqual(
|
||||
expect.stringMatching(
|
||||
/no stored checksum .* integrity was not verified/u,
|
||||
),
|
||||
);
|
||||
|
||||
const compressedInventory = await inspect(compressed);
|
||||
expect(
|
||||
compressedInventory.provenance.map(
|
||||
({ arguments: [operation], exit_code }) => [operation, exit_code],
|
||||
),
|
||||
).toEqual([
|
||||
["verify", 0],
|
||||
["attach", 0],
|
||||
]);
|
||||
expect(compressedInventory.limitations).not.toContainEqual(
|
||||
expect.stringMatching(/no stored checksum/u),
|
||||
);
|
||||
|
||||
// Both owned mounts were released.
|
||||
const attached = await hdiutil("info");
|
||||
expect(attached.stdout).not.toContain(raw);
|
||||
expect(attached.stdout).not.toContain(compressed);
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"refuses a DMG-named blob without disk image structure",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-raw-dmg-cli-blob-");
|
||||
const blob = join(root, "blob.dmg");
|
||||
// hdiutil itself treats any file as a raw image with no checksum.
|
||||
await writeFile(blob, Buffer.alloc(1024 * 1024));
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-artifact", blob, "--json"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "target_unavailable",
|
||||
details: { constraint: "file_format_rejected" },
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { lstat, writeFile } from "node:fs/promises";
|
||||
import { lstat, realpath, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, onTestFinished, vi } from "vitest";
|
||||
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
|
||||
import { classifyAndHashRoot } from "../../fixtures/artifactInventory.js";
|
||||
import { asarHeaderBytes } from "../../fixtures/asarHeader.js";
|
||||
@@ -96,7 +97,7 @@ it.each([false, true])(
|
||||
},
|
||||
);
|
||||
|
||||
it("classifies root ASAR and DMG signatures across controlled short reads and closes the descriptor", async () => {
|
||||
it("classifies root ASAR, UDIF and raw DMG signatures across controlled short reads at both boundaries and closes the descriptor", async () => {
|
||||
const root = await createTestTempDirectory("rea-root-short-read-signature-");
|
||||
const compactPath = join(root, "compact.asar");
|
||||
const asarPath = join(root, "whitespace.asar");
|
||||
@@ -107,14 +108,21 @@ it("classifies root ASAR and DMG signatures across controlled short reads and cl
|
||||
const dmgBytes = Buffer.alloc(1_024);
|
||||
const compactBytes = asarHeaderBytes(JSON.stringify({ files: {} }));
|
||||
dmgBytes.write("koly", dmgBytes.length - 512);
|
||||
const rawDmgPath = join(root, "raw.dmg");
|
||||
const rawDmgBytes = Buffer.alloc(4_096);
|
||||
rawDmgBytes.write("EFI PART", 512, "ascii");
|
||||
rawDmgBytes.writeUInt32LE(0x0001_0000, 520);
|
||||
rawDmgBytes.writeUInt32LE(92, 524);
|
||||
await writeFile(rawDmgPath, rawDmgBytes);
|
||||
await writeFile(asarPath, asarBytes);
|
||||
await writeFile(compactPath, compactBytes);
|
||||
await writeFile(dmgPath, dmgBytes);
|
||||
let openedPath: string | undefined;
|
||||
let openedPaths: readonly string[] = [];
|
||||
const descriptors: FileHandle[] = [];
|
||||
let largestActualRead = 0;
|
||||
injected.opened = (candidate, handle) => {
|
||||
if (candidate !== openedPath) return;
|
||||
if (typeof candidate !== "string" || !openedPaths.includes(candidate))
|
||||
return;
|
||||
descriptors.push(handle);
|
||||
const nativeRead = handle.read.bind(handle);
|
||||
const readAtMostThreeBytes = async (
|
||||
@@ -140,8 +148,10 @@ it("classifies root ASAR and DMG signatures across controlled short reads and cl
|
||||
[compactPath, compactBytes, "asar"],
|
||||
[asarPath, asarBytes, "asar"],
|
||||
[dmgPath, dmgBytes, "dmg"],
|
||||
[rawDmgPath, rawDmgBytes, "dmg"],
|
||||
] as const) {
|
||||
openedPath = path;
|
||||
// Target admission opens the canonical path; inventory opens the input.
|
||||
openedPaths = [path, await realpath(path)];
|
||||
descriptors.length = 0;
|
||||
largestActualRead = 0;
|
||||
const result = await classifyAndHashRoot(path, false, await lstat(path));
|
||||
@@ -154,5 +164,16 @@ it("classifies root ASAR and DMG signatures across controlled short reads and cl
|
||||
expect(largestActualRead).toBeLessThanOrEqual(3);
|
||||
expect(descriptors).toHaveLength(1);
|
||||
expect(descriptors[0]?.fd).toBe(-1);
|
||||
|
||||
// Target admission must observe the same signature under short reads.
|
||||
descriptors.length = 0;
|
||||
largestActualRead = 0;
|
||||
expect(await parseBinaryTarget(path)).toMatchObject({
|
||||
ok: true,
|
||||
value: { format },
|
||||
});
|
||||
expect(descriptors.length).toBeGreaterThan(0);
|
||||
expect(largestActualRead).toBeGreaterThan(0);
|
||||
expect(largestActualRead).toBeLessThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
@@ -404,3 +404,129 @@ describe("package signature byte identity", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("raw disk image signatures", () => {
|
||||
// Raw (UDRW, UDTO) images have no UDIF trailer; hdiutil writes these
|
||||
// structures at fixed device offsets of 512-byte sectors.
|
||||
const rawImage = (write: (bytes: Buffer) => void, size = 4096): Buffer => {
|
||||
const bytes = Buffer.alloc(size);
|
||||
write(bytes);
|
||||
return bytes;
|
||||
};
|
||||
const mbrEntry = (bytes: Buffer, status: number, type: number): void => {
|
||||
bytes[446] = status;
|
||||
bytes[450] = type;
|
||||
bytes.writeUInt32LE(1, 454);
|
||||
bytes.writeUInt32LE(2048, 458);
|
||||
bytes[510] = 0x55;
|
||||
bytes[511] = 0xaa;
|
||||
};
|
||||
const gptHeader = (bytes: Buffer, revision: number, size: number): void => {
|
||||
bytes.write("EFI PART", 512, "ascii");
|
||||
bytes.writeUInt32LE(revision, 520);
|
||||
bytes.writeUInt32LE(size, 524);
|
||||
};
|
||||
const volumeHeader = (bytes: Buffer, signature: string, version: number) => {
|
||||
bytes.write(signature, 1024, "ascii");
|
||||
bytes.writeUInt16BE(version, 1026);
|
||||
};
|
||||
|
||||
const expectDmgAtBothBoundaries = async (
|
||||
path: string,
|
||||
admitted: boolean,
|
||||
): Promise<void> => {
|
||||
expect(
|
||||
(await classifyAndHashRoot(path, false, await lstat(path))).format,
|
||||
).toBe(admitted ? "dmg" : "file");
|
||||
expect(await parseBinaryTarget(path)).toMatchObject(
|
||||
admitted
|
||||
? { ok: true, value: { kind: "archive", format: "dmg" } }
|
||||
: { ok: false, error: { _tag: "BinaryTargetError" } },
|
||||
);
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
layout: "a GUID partition table without a protective MBR",
|
||||
bytes: rawImage((bytes) => gptHeader(bytes, 0x0001_0000, 92)),
|
||||
},
|
||||
{
|
||||
layout: "an Apple partition map",
|
||||
bytes: rawImage((bytes) => {
|
||||
bytes.write("ER", 0, "ascii");
|
||||
bytes.write("PM", 512, "ascii");
|
||||
}),
|
||||
},
|
||||
{
|
||||
layout: "an MBR partition table",
|
||||
bytes: rawImage((bytes) => mbrEntry(bytes, 0x80, 0x0b)),
|
||||
},
|
||||
{
|
||||
layout: "an unpartitioned HFS+ volume",
|
||||
bytes: rawImage((bytes) => volumeHeader(bytes, "H+", 4)),
|
||||
},
|
||||
{
|
||||
layout: "an unpartitioned HFSX volume",
|
||||
bytes: rawImage((bytes) => volumeHeader(bytes, "HX", 5)),
|
||||
},
|
||||
])("admits $layout as a DMG at both boundaries", async ({ bytes }) => {
|
||||
const root = await createTestTempDirectory("rea-raw-dmg-");
|
||||
const path = join(root, "image.dmg");
|
||||
await writeFile(path, bytes);
|
||||
await expectDmgAtBothBoundaries(path, true);
|
||||
// Structure alone never overrides the caller-visible suffix rule.
|
||||
const renamed = join(root, "image.img");
|
||||
await writeFile(renamed, bytes);
|
||||
expect(
|
||||
(await classifyAndHashRoot(renamed, false, await lstat(renamed))).format,
|
||||
).not.toBe("dmg");
|
||||
expect(await parseBinaryTarget(renamed)).not.toMatchObject({
|
||||
ok: true,
|
||||
value: { format: "dmg" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ blob: "zero-filled bytes", bytes: rawImage(() => {}, 1024 * 1024) },
|
||||
{
|
||||
blob: "a boot signature over an empty table",
|
||||
bytes: rawImage((bytes) => {
|
||||
bytes[510] = 0x55;
|
||||
bytes[511] = 0xaa;
|
||||
}),
|
||||
},
|
||||
{
|
||||
blob: "an MBR entry with an invalid status byte",
|
||||
bytes: rawImage((bytes) => mbrEntry(bytes, 0x01, 0x0b)),
|
||||
},
|
||||
{
|
||||
blob: "a GPT header with an unknown revision",
|
||||
bytes: rawImage((bytes) => gptHeader(bytes, 0x0002_0000, 92)),
|
||||
},
|
||||
{
|
||||
blob: "a GPT header shorter than its defined fields",
|
||||
bytes: rawImage((bytes) => gptHeader(bytes, 0x0001_0000, 91)),
|
||||
},
|
||||
{
|
||||
blob: "a driver descriptor without a partition map entry",
|
||||
bytes: rawImage((bytes) => bytes.write("ER", 0, "ascii")),
|
||||
},
|
||||
{
|
||||
blob: "an HFS+ signature with the HFSX version",
|
||||
bytes: rawImage((bytes) => volumeHeader(bytes, "H+", 5)),
|
||||
},
|
||||
{
|
||||
blob: "an HFS+ signature cut off before its version",
|
||||
bytes: rawImage((bytes) => bytes.write("H+", 1024, "ascii"), 1026),
|
||||
},
|
||||
{
|
||||
blob: "text",
|
||||
bytes: Buffer.from("This is not a disk image.\n".repeat(64)),
|
||||
},
|
||||
])("refuses $blob named as a DMG at both boundaries", async ({ bytes }) => {
|
||||
const root = await createTestTempDirectory("rea-raw-dmg-negative-");
|
||||
const path = join(root, "image.dmg");
|
||||
await writeFile(path, bytes);
|
||||
await expectDmgAtBothBoundaries(path, false);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
type ArtifactEntry,
|
||||
type ArtifactReaderTraversal,
|
||||
type ArtifactTraversalOmission,
|
||||
} from "../../../../src/artifacts/ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js";
|
||||
@@ -1557,6 +1558,57 @@ describe("native DMG localized verification cleanup", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("native DMG checksum-free images", () => {
|
||||
it("attaches after hdiutil reports no stored checksum and reports integrity as unverified", async () => {
|
||||
const { host, state } = createOmittedInventoryHost();
|
||||
state.inventoryMode = "listed";
|
||||
state.detachAttempts = 4;
|
||||
const verify = vi.fn(async () => ({
|
||||
stdout: "",
|
||||
stderr: 'hdiutil: verify: "/tmp/image.dmg" has no checksum.\n',
|
||||
exitCode: 1,
|
||||
}));
|
||||
const reader = new NativeDmgArtifactReader("/tmp/image.dmg", process.env, {
|
||||
...host,
|
||||
run: (arguments_, signal, options) =>
|
||||
arguments_[0] === "verify"
|
||||
? verify()
|
||||
: host.run(arguments_, signal, options),
|
||||
});
|
||||
onTestFinished(() => reader.close());
|
||||
const coverage: ArtifactReaderTraversal[] = [];
|
||||
const paths: string[] = [];
|
||||
for await (const entry of reader.entries(undefined, {
|
||||
onCoverage: (observed) => coverage.push(observed),
|
||||
}))
|
||||
paths.push(entry.path);
|
||||
|
||||
expect(paths).toContain("image.dmg/Fixture/hello.txt");
|
||||
// Absence is a terminal answer, not a transient refusal to retry.
|
||||
expect(verify).toHaveBeenCalledTimes(1);
|
||||
expect(coverage).toEqual([
|
||||
{
|
||||
status: "complete",
|
||||
limitations: [
|
||||
expect.stringMatching(
|
||||
/no stored checksum .* integrity was not verified/u,
|
||||
),
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
reader
|
||||
.provenance()
|
||||
.map(({ arguments: [operation], exit_code }) => [operation, exit_code]),
|
||||
).toEqual([
|
||||
["verify", 1],
|
||||
["attach", 0],
|
||||
]);
|
||||
await reader.close();
|
||||
expect(state.attached).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native DMG real verification diagnostics", () => {
|
||||
it.skipIf(process.platform !== "darwin")(
|
||||
"classifies a real hdiutil verify refusal as a format failure with captured output",
|
||||
|
||||
Reference in new issue
Block a user