diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 4825d972c0..bd190428d6 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -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 => { 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. */ diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts index 28a378f5ba..287d08a9da 100644 --- a/src/artifacts/NativeDmgArtifactReader.ts +++ b/src/artifacts/NativeDmgArtifactReader.ts @@ -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 | undefined; readonly #activeDirectoryOperations = new Set>(); #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))) diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 466ada17b2..9e396ed8d4 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -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 => { + 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 => { - 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")); -}; diff --git a/src/domain/apple/diskImageFormat.ts b/src/domain/apple/diskImageFormat.ts new file mode 100644 index 0000000000..373b0b66dd --- /dev/null +++ b/src/domain/apple/diskImageFormat.ts @@ -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 => { + 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) + ); +}; diff --git a/tests/boundary/cli/rawDiskImageCli.test.ts b/tests/boundary/cli/rawDiskImageCli.test.ts new file mode 100644 index 0000000000..307c00326f --- /dev/null +++ b/tests/boundary/cli/rawDiskImageCli.test.ts @@ -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" }, + }); + }, + ); + }, +); diff --git a/tests/boundary/filesystem/artifactInventoryDescriptorCleanup.test.ts b/tests/boundary/filesystem/artifactInventoryDescriptorCleanup.test.ts index 1826083ad3..11fb3d1c76 100644 --- a/tests/boundary/filesystem/artifactInventoryDescriptorCleanup.test.ts +++ b/tests/boundary/filesystem/artifactInventoryDescriptorCleanup.test.ts @@ -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); } }); diff --git a/tests/boundary/filesystem/zipDetection.test.ts b/tests/boundary/filesystem/zipDetection.test.ts index 75ad709349..d2fb104427 100644 --- a/tests/boundary/filesystem/zipDetection.test.ts +++ b/tests/boundary/filesystem/zipDetection.test.ts @@ -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 => { + 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); + }); +}); diff --git a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts index ecc87ff129..2af75bb630 100644 --- a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts +++ b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts @@ -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",