diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 62c274b2c3..56f9e1652b 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -662,3 +662,10 @@ Virtual member offsets are not physical carrier locations. Format 6–8 readers cache one decoded block and support retained filtered/paged views. No decryption or SerializedFile object decoding is provided. See [UnityFS](https://github.com/morluto/rea/blob/main/docs/unityfs.md). + +U-Boot legacy uImages use the same inspection/extraction workflows. Prefer a +retained summary and selected inventory views for header facts or component +IDs. Header addresses/CPU/OS are declarations; payloads remain as stored. +Multi-file components retain their aligned physical ranges; the full payload +is also materialized. Unknown name text retains its original 32 bytes. See +[U-Boot uImage](https://github.com/morluto/rea/blob/main/docs/uimage.md). diff --git a/docs/cli.md b/docs/cli.md index 3f054ca456..ee2bb4e9f9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -281,6 +281,16 @@ rea extract-artifact ./firmware.hex --json See [Intel HEX and S-record images](hex-records.md) for recognition, region names, metadata and limits. +Inspect and extract stored U-Boot legacy uImage payloads and components: + +```sh +rea inspect-artifact ./firmware.bin --detail summary --evidence_output firmware.json --json +rea extract-artifact ./firmware.bin --json +``` + +See [U-Boot uImage](uimage.md) for verified CRCs, physical component ranges, +unknown names, resource limits and retained views. Compressed bytes stay stored. + Inspect local model declarations without loading weights: ```bash diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index 7307826049..d6465c42ff 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -300,3 +300,17 @@ file for its address regions, declared start address and record facts, then `extract-artifact` / `extract_artifact` for one raw file per region. Decoding is portable and requires no external tool. See [Intel HEX and S-record images](hex-records.md). + +## Inspect U-Boot legacy uImages + +`inspect-artifact` / `extract-artifact` recognize a legacy uImage by its +`0x27051956` magic and inventory it without running or decompressing it. +The root facts record the 64-byte header (architecture, OS, type, and +compression as raw values and names, load and entry addresses, image name, +and timestamp) with the header and data CRC32 verified; the stored payload +is extractable as `payload.bin`, and multi-file images additionally expose +each sub-image as `image-.bin` from the stored size table. Declared +addresses are facts from the header, not proven entry points, and compressed +payloads stay as stored. Intel HEX/S-record and FIT images are separate +readers and are not covered here. See [U-Boot legacy uImage](uimage.md) for +component padding, preserved names, resource policies and verification scope. diff --git a/docs/index.md b/docs/index.md index 79f58fa933..8cf971e01f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -47,20 +47,20 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md). ## Choose a guide -| Task | Guides | -| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) | -| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Linux kernel modules](kernel-modules.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | -| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | -| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [GameCube DOL](ghidra-dol.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) | -| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) | -| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) | -| Inspect assemblies, packages, or firmware | [Godot PCK](godot-pck.md), [UnityFS](unityfs.md), [.NET](managed-code-analysis.md), [Python packages](python-packaged-applications.md), [Android](android-analysis.md), [APK signatures](apk-signature.md), [Apktool resources](apktool-resource-analysis.md), [ADB acquisition](adb-device-analysis.md), [Apple](apple-application-analysis.md), [Harmony](harmony-analysis.md), [firmware](firmware-analysis.md), [WASM](wasm-artifacts.md), [EVM](evm-bytecode.md) | -| Observe or compare a browser scenario | [CDP observation](browser-observation.md), [scenarios](browser-scenario-contract.md), [runtime evidence](web-runtime.md), [historical captures](web-network-captures.md) | -| Serve recorded behavior as an agent environment | [Agent environments](agent-environments.md) | -| Trace captured website source | [Source locations](web-source-location.md), [module traces](website-module-trace.md), [script export](website-script-export.md) | -| Capture and compare a command | [Process capture](process-capture.md) | -| Use CLI/MCP and retain evidence | [CLI](cli.md), [MCP contracts](mcp-contracts.md), [agent prompts](mcp-prompts.md), [obligation ledgers](reconstruction-obligation-ledgers.md) | +| Task | Guides | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) | +| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Linux kernel modules](kernel-modules.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | +| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | +| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [GameCube DOL](ghidra-dol.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) | +| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) | +| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) | +| Inspect assemblies, packages, or firmware | [Godot PCK](godot-pck.md), [UnityFS](unityfs.md), [U-Boot uImage](uimage.md), [.NET](managed-code-analysis.md), [Python packages](python-packaged-applications.md), [Android](android-analysis.md), [APK signatures](apk-signature.md), [Apktool resources](apktool-resource-analysis.md), [ADB acquisition](adb-device-analysis.md), [Apple](apple-application-analysis.md), [Harmony](harmony-analysis.md), [firmware](firmware-analysis.md), [WASM](wasm-artifacts.md), [EVM](evm-bytecode.md) | +| Observe or compare a browser scenario | [CDP observation](browser-observation.md), [scenarios](browser-scenario-contract.md), [runtime evidence](web-runtime.md), [historical captures](web-network-captures.md) | +| Serve recorded behavior as an agent environment | [Agent environments](agent-environments.md) | +| Trace captured website source | [Source locations](web-source-location.md), [module traces](website-module-trace.md), [script export](website-script-export.md) | +| Capture and compare a command | [Process capture](process-capture.md) | +| Use CLI/MCP and retain evidence | [CLI](cli.md), [MCP contracts](mcp-contracts.md), [agent prompts](mcp-prompts.md), [obligation ledgers](reconstruction-obligation-ledgers.md) | For development, use [testing](testing.md), [client verification](client-verification.md), [tool design](tool-design.md), [admission limits and recovery](design/limits-and-recovery.md), diff --git a/docs/uimage.md b/docs/uimage.md new file mode 100644 index 0000000000..4e4278dd7d --- /dev/null +++ b/docs/uimage.md @@ -0,0 +1,72 @@ +# U-Boot legacy uImage containers + +`inspect-artifact` and `extract-artifact` recognize the big-endian +`0x27051956` legacy uImage signature regardless of filename. They read local +stored bytes without booting, executing, decompressing or modifying the image. +FIT is a different format; its analysis is outside this reader. + +The same domain header/table facts and artifact reader serve CLI and MCP. +The root is an archive with unknown executable architecture. The header's CPU, +OS, type, compression, load address and entry address are declarations, not proof of +compatible or executable payloads. Raw numeric identifiers remain available +alongside names; unrecognized identifiers have the name `unknown`. + +The header's CRC32 and the declared payload's CRC32 must verify before a +successful inventory. `payload.bin` contains the complete stored payload. +Multi-file images also expose `image-0.bin`, `image-1.bin`, and so on. These +component names are generated from table order. Components follow the zero-terminated big-endian size table, with four-byte +padding after every nonfinal component. Their physical source ranges and +content hashes retain precise provenance. Extraction writes the complete +payload and every component, so multi-file output can approach twice the input +size. Compressed payloads remain compressed. + +The fixed 32-byte name field is retained as `name_bytes_hex`. `name` contains +the UTF-8 bytes before the first NUL, or null when those bytes cannot be decoded +exactly. For example, a producer can truncate a multibyte character at the +32-byte boundary; replacement characters do not establish a source name. + +## Bounded work and retained facts + +The reader admits at most 4,096 components, a resource policy rather than a +format maximum. It reads at most 16,388 bytes for the size table and refuses an +excess count as a capacity limit before its remaining payload CRC scan. +Shared artifact identity hashing has separate cost. The +same prefix participates in the native incremental CRC32 calculation; no +custom bytewise CRC table or full-payload buffer is retained. Remaining CRC +reads and member streams use 64 KiB chunks. Parsed facts are cached for one reader. Carrier state is checked during +directory preparation, at member open and after stream completion; metadata +getters return the retained facts. Close joins active member +pipelines and final validation before releasing the descriptor; failed cleanup +remains owned for retry. + +Prefer a retained summary when only a few facts are needed: + +```sh +rea inspect-artifact firmware.bin --detail summary \ + --evidence_output firmware.evidence.json --json +rea extract-artifact firmware.bin --json +``` + +With MCP, `inspect_artifact` with `detail: "summary"` retains the complete +Evidence. Use `inspect_analysis_view` and its `parent_evidence_id` for exact +logical paths, batches of occurrence/content IDs, selected fields and pages. +Coverage and limitations remain attached. Retained views are historical +observations tied to the saved Evidence. Complete Evidence remains available; +these views do not narrow the examined inventory. + +## Verification scope + +The public OpenWrt 24.10.0 GL-AR150 initramfs kernel is checked against its +[published digest](https://downloads.openwrt.org/releases/24.10.0/targets/ath79/generic/sha256sums): +`027c60a7061462d8145b11efa23ab02b334acb9a23a215f9095860b632ace4ff`. +Independent `mkimage`/`dumpimage` 2023.01 provide producer and extraction +oracles. Generated containers use real host ELF files, system metadata and +stored gzip bytes; their declared ARM64, VxWorks or RISC-V labels do not prove +boot/runtime compatibility. U-Boot's identifier and alignment definitions are +pinned to commit `ece349ade2973e220f524ce59e59711cc919263f`. + +Source and compiled CLI/full/compact MCP tests cover aligned component ranges, +unknown identifiers/text, malformed/truncated inputs, CRC failures, capacity, +source changes, cancellation, pending reads, final validation and cleanup +retry. Real producer results, complete-task cost measurements and limitations +are reported in the PR. No target runtime or decompressor behavior is verified. diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 160ad515d3..f8ef0c4932 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -75,6 +75,7 @@ import { } from "../domain/apple/appleArchiveFormat.js"; import { hasDiskImageSignature } from "../domain/apple/diskImageFormat.js"; import { hasGodotPckHeader } from "../domain/godotPckFormat.js"; +import { hasUImageMagic } from "../domain/uimageFormat.js"; import { hasUnityFsHeader } from "../domain/unityfsFormat.js"; import { detectHexRecordEncoding } from "../domain/firmware/hexRecords.js"; import { @@ -668,6 +669,7 @@ const detectArtifactFormat = async ( if (hasZipSignature(magic)) return zipRootPackageFormat(lower); if (hasGodotPckHeader(magic)) return "godot-pck"; if (hasUnityFsHeader(magic)) return "unityfs"; + if (hasUImageMagic(magic)) return "uimage"; const hexRecords = detectHexRecordEncoding(magic); if (hexRecords !== undefined) return hexRecords; // Binary plists, including keyed archives, often use other extensions. @@ -737,9 +739,16 @@ const isArchiveFormat = ( >, ): format is Extract["format"] => isZipPackageFormat(format) || - ["asar", "godot-pck", "unityfs", "dmg", "pkg", "intel-hex", "srec"].includes( - format, - ); + [ + "asar", + "godot-pck", + "unityfs", + "uimage", + "dmg", + "pkg", + "intel-hex", + "srec", + ].includes(format); /** Whether the leading bytes begin any executable header family, valid or not. */ const claimsLeadingExecutableHeader = async ( diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index c8551e220c..ace8eff0d5 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -178,6 +178,7 @@ export interface ArtifactReader { | "asar" | "godot-pck" | "unityfs" + | "uimage" | "pyinstaller" | "node-sea" | "intel-hex" diff --git a/src/artifacts/UImageArtifactReader.boundaries.test.ts b/src/artifacts/UImageArtifactReader.boundaries.test.ts new file mode 100644 index 0000000000..4ce08a8fed --- /dev/null +++ b/src/artifacts/UImageArtifactReader.boundaries.test.ts @@ -0,0 +1,363 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { buffer } from "node:stream/consumers"; +import { finished } from "node:stream/promises"; +import { setImmediate } from "node:timers/promises"; +import { expect, it, vi } from "vitest"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { createDeferred } from "../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; +import { openRegularFile } from "../filesystem/RegularFile.js"; +import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { UImageArtifactReader } from "./UImageArtifactReader.js"; +import { buildUImage, buildMultiPayload } from "../../tests/fixtures/uimage.js"; +import { + parseUImageHeaderFields, + parseMultiImageTable, +} from "../domain/uimageFormat.js"; + +const fixture = async (bytes: Buffer): Promise => { + const path = join( + await createTestTempDirectory("rea-uimage-boundary-"), + "image", + ); + await writeFile(path, bytes); + return path; +}; +const entries = async (reader: UImageArtifactReader) => { + const result = []; + for await (const entry of reader.entries()) result.push(entry); + return result; +}; + +it.each([ + [13, "nios", 13, "lynxos"], + [14, "microblaze", 14, "vxworks"], + [21, "openrisc", 16, "qnx"], + [22, "arm64", 17, "u-boot"], + [26, "riscv", 29, "elf"], +] as const)( + "retains official architecture %s and OS identifiers", + (arch, archName, os, osName) => { + const result = parseUImageHeaderFields(buildUImage({ arch, os })); + expect(result).toMatchObject({ + ok: true, + value: { arch, archName, os, osName }, + }); + }, +); + +it("retains raw name bytes when mkimage's fixed field truncates UTF-8", () => { + const bytes = buildUImage({ name: "测".repeat(11) }); + const result = parseUImageHeaderFields(bytes); + expect(result).toMatchObject({ + ok: true, + value: { name: null, nameBytesHex: bytes.subarray(32, 64).toString("hex") }, + }); +}); + +it("aligns large component extents without signed 32-bit coercion or allocation", () => { + const table = Buffer.alloc(12); + table.writeUInt32BE(0x8000_0001, 0); + table.writeUInt32BE(1, 4); + expect(parseMultiImageTable(table, 12 + 0x8000_0004 + 1)).toEqual({ + ok: true, + value: [ + { index: 0, byteOffset: 76, declaredSize: 0x8000_0001 }, + { index: 1, byteOffset: 76 + 0x8000_0004, declaredSize: 1 }, + ], + }); +}); + +it("preserves cancellation during payload CRC admission", async () => { + const path = await fixture( + buildUImage({ payload: Buffer.alloc(128 * 1024, 0x61) }), + ); + const admitted = await openRegularFile(path, { symlinks: "reject" }); + const reader = new UImageArtifactReader(path, admitted); + const controller = new AbortController(), + reason = new Error("cancel CRC verification"); + const nativeRead = admitted.handle.read.bind(admitted.handle); + const reads = vi.spyOn(admitted.handle, "read").mockImplementation((async ( + ...args: unknown[] + ) => { + const result = await Reflect.apply(nativeRead, admitted.handle, args); + if (args[3] === 64) controller.abort(reason); + return result; + }) as typeof admitted.handle.read); + try { + await expect( + (async () => { + for await (const _entry of reader.entries(controller.signal)) { + /* Exhaust the admitted operation. */ + } + })(), + ).rejects.toMatchObject({ reason: "cancelled", cause: reason }); + } finally { + await reader.close(); + reads.mockRestore(); + await admitted.handle.close(); + } +}); + +it("admits a multi-image count before scanning the remaining payload", async () => { + const payload = Buffer.concat([ + buildMultiPayload(Array.from({ length: 4097 }, () => Buffer.from("x"))), + Buffer.alloc(1024 * 1024), + ]); + const path = await fixture(buildUImage({ type: 4, payload })); + const admitted = await openRegularFile(path, { symlinks: "reject" }); + const reader = new UImageArtifactReader(path, admitted); + const reads = vi.spyOn(admitted.handle, "read"); + try { + await expect(entries(reader)).rejects.toMatchObject({ + reason: "limit", + boundary: "capacity", + }); + expect(reads).toHaveBeenCalledTimes(2); // header and bounded table, no payload scan + } finally { + await reader.close(); + reads.mockRestore(); + await admitted.handle.close(); + } +}); + +it("does not complete paused inventory after its reader closes", async () => { + const reader = new UImageArtifactReader( + await fixture( + buildUImage({ + type: 4, + payload: buildMultiPayload([Buffer.from("a"), Buffer.from("b")]), + }), + ), + ); + const iterator = reader.entries()[Symbol.asyncIterator](); + try { + expect((await iterator.next()).done).toBe(false); + await reader.close(); + await expect(iterator.next()).rejects.toMatchObject({ + reason: "unavailable", + }); + } finally { + await reader.close(); + } +}); + +it.each(["cancel", "close"] as const)( + "retires fully buffered, unconsumed member bytes on %s", + async (action) => { + const reader = new UImageArtifactReader(await fixture(buildUImage())); + const controller = new AbortController(); + const reason = new Error("cancel buffered member"); + try { + const [entry] = await entries(reader); + const stream = await reader.open(entry!, controller.signal); + await finished(stream, { readable: false, cleanup: true }); + await setImmediate(); + expect(stream.readableLength).toBeGreaterThan(0); + if (action === "cancel") controller.abort(reason); + else await reader.close(); + await expect(buffer(stream)).rejects.toMatchObject( + action === "cancel" + ? { reason: "cancelled", cause: reason } + : { reason: "unavailable" }, + ); + expect(stream.destroyed).toBe(true); + } finally { + await reader.close(); + } + }, +); + +it("joins a member's outstanding positional read before descriptor closure", async () => { + const path = await fixture(buildUImage()); + const admitted = await openRegularFile(path, { symlinks: "reject" }); + const reader = new UImageArtifactReader(path, admitted); + const reached = createDeferred(), + release = createDeferred(); + const nativeRead = admitted.handle.read.bind(admitted.handle); + const reads = vi.spyOn(admitted.handle, "read"); + let outcome: Promise | undefined; + let closing: Promise | undefined; + try { + const [entry] = await entries(reader); + reads.mockImplementation((async (...args: unknown[]) => { + reached.resolve(); + await release.promise; + return Reflect.apply(nativeRead, admitted.handle, args); + }) as typeof admitted.handle.read); + const stream = await reader.open(entry!); + outcome = buffer(stream).then( + () => ({ ok: true }), + (cause: unknown) => ({ ok: false, cause }), + ); + await awaitAcknowledgement( + reached.promise, + outcome, + "member positional read", + ); + let closed = false; + closing = reader.close().then(() => { + closed = true; + }); + await setImmediate(); + expect(closed).toBe(false); + release.resolve(); + await closing; + expect(stream.closed).toBe(true); + } finally { + release.resolve(); + await outcome; + await closing; + await reader.close(); + reads.mockRestore(); + await admitted.handle.close(); + } +}); + +it("retains failed descriptor cleanup for retry", async () => { + const reader = new UImageArtifactReader(await fixture(buildUImage())); + const [entry] = await entries(reader); + const stream = await reader.open(entry!); + const failure = new Error("uImage close denied"); + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockRejectedValueOnce(failure); + try { + await expect(reader.close()).rejects.toBe(failure); + await expect(reader.open(entry!)).rejects.toMatchObject({ + reason: "unavailable", + }); + expect(stream.closed).toBe(true); + await reader.close(); + expect(close).toHaveBeenCalledTimes(2); + } finally { + close.mockRestore(); + await reader.close(); + } +}); + +it.each(["cancel", "cancel-with-validation-error", "close"] as const)( + "joins final freshness validation during %s", + async (action) => { + const path = await fixture(buildUImage()); + const admitted = await openRegularFile(path, { symlinks: "reject" }); + const reader = new UImageArtifactReader(path, admitted); + const reached = createDeferred(), + release = createDeferred(); + const nativeStat = admitted.handle.stat.bind(admitted.handle); + const nativeRead = admitted.handle.read.bind(admitted.handle); + const reads = vi.spyOn(admitted.handle, "read"); + const stats = vi.spyOn(admitted.handle, "stat"); + const controller = new AbortController(), + reason = new Error("cancel final validation"); + const validationFailure = new Error("final descriptor stat failed"); + let outcome: Promise | undefined; + let closing: Promise | undefined; + try { + const [entry] = await entries(reader); + const memberEnd = BigInt(entry!.byteOffset! + entry!.declaredSize!); + let memberReadComplete = false; + reads.mockImplementation((async (...args: unknown[]) => { + const result = (await Reflect.apply( + nativeRead, + admitted.handle, + args, + )) as { + bytesRead: number; + buffer: Buffer; + }; + if (typeof args[3] === "bigint") + memberReadComplete = args[3] + BigInt(result.bytesRead) === memberEnd; + return result; + }) as typeof admitted.handle.read); + stats.mockImplementation((async (...args: unknown[]) => { + const result = await Reflect.apply(nativeStat, admitted.handle, args); + if (memberReadComplete) { + memberReadComplete = false; + reached.resolve(); + await release.promise; + if (action === "cancel-with-validation-error") + throw validationFailure; + } + return result; + }) as typeof admitted.handle.stat); + const stream = await reader.open(entry!, controller.signal); + outcome = buffer(stream).then( + () => ({ ok: true }) as const, + (cause: unknown) => ({ ok: false, cause }) as const, + ); + await awaitAcknowledgement( + reached.promise, + outcome, + "post-read freshness validation", + ); + let closed = false; + if (action !== "close") controller.abort(reason); + else + closing = reader.close().then(() => { + closed = true; + }); + await setImmediate(); + expect(closed).toBe(false); + release.resolve(); + expect(await outcome).toMatchObject({ + ok: false, + cause: + action === "cancel-with-validation-error" + ? validationFailure + : action === "cancel" + ? { reason: "cancelled", cause: reason } + : { reason: "unavailable" }, + }); + await closing; + } finally { + release.resolve(); + await outcome; + await closing; + await reader.close(); + stats.mockRestore(); + reads.mockRestore(); + await admitted.handle.close(); + } + }, +); + +it("rejects a source changed after its header and CRC were prepared", async () => { + const bytes = buildUImage(); + const path = await fixture(bytes), + reader = new UImageArtifactReader(path); + try { + const [entry] = await entries(reader); + await writeFile(path, Buffer.concat([bytes, Buffer.from([0])])); + await expect(reader.open(entry!)).rejects.toMatchObject({ + reason: "integrity", + }); + } finally { + await reader.close(); + } +}); + +it("checks a multi-chunk stored payload incrementally and preserves read cancellation", async () => { + const bytes = buildUImage({ payload: Buffer.alloc(2 * 1024 * 1024, 0x61) }); + const path = await fixture(bytes); + const reader = new UImageArtifactReader(path); + try { + const [entry] = await entries(reader); + const controller = new AbortController(), + reason = new Error("cancel member bytes"); + const stream = await reader.open(entry!, controller.signal); + const iterator = stream[Symbol.asyncIterator](); + await iterator.next(); + controller.abort(reason); + await expect(iterator.next()).rejects.toMatchObject({ + reason: "cancelled", + cause: reason, + }); + expect( + (await buffer(await reader.open(entry!))).equals(bytes.subarray(64)), + ).toBe(true); + } finally { + await reader.close(); + } +}); diff --git a/src/artifacts/UImageArtifactReader.test.ts b/src/artifacts/UImageArtifactReader.test.ts new file mode 100644 index 0000000000..abf0b6c921 --- /dev/null +++ b/src/artifacts/UImageArtifactReader.test.ts @@ -0,0 +1,313 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { Readable } from "node:stream"; + +import { expect, it } from "vitest"; + +import { ArtifactReaderFailure } from "./ArtifactReader.js"; +import { UImageArtifactReader } from "./UImageArtifactReader.js"; +import { buildMultiPayload, buildUImage } from "../../tests/fixtures/uimage.js"; +import { + parseMultiImageTable, + parseUImageHeaderFields, + UIMAGE_HEADER_SIZE, + UIMAGE_MAGIC, +} from "../domain/uimageFormat.js"; +import { classifyArtifactBytesAtPath } from "../../tests/fixtures/artifactClassification.js"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { scanCanonicalArtifactInventory } from "../../tests/fixtures/artifactInventory.js"; + +const collect = async (stream: Readable): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + return Buffer.concat(chunks); +}; + +const writeCarrier = async (name: string, bytes: Buffer): Promise => { + const directory = await createTestTempDirectory("rea-uimage-"); + const path = join(directory, name); + await writeFile(path, bytes); + return path; +}; + +it("parses header facts with raw and named fields", () => { + const bytes = buildUImage({ + arch: 2, + os: 5, + type: 2, + compression: 0, + loadAddress: 0x8000, + entryPoint: 0x8000, + timestamp: 1700000000, + name: "test-kernel", + payload: Buffer.from("kernel-bytes"), + }); + const parsed = parseUImageHeaderFields(bytes.subarray(0, UIMAGE_HEADER_SIZE)); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.value).toMatchObject({ + magic: UIMAGE_MAGIC, + arch: 2, + archName: "arm", + os: 5, + osName: "linux", + type: 2, + typeName: "kernel", + compression: 0, + compressionName: "none", + loadAddress: 0x8000, + entryPoint: 0x8000, + timestamp: 1700000000, + timestampIso: "2023-11-14T22:13:20.000Z", + payloadSize: "kernel-bytes".length, + name: "test-kernel", + }); +}); + +it("names unknown enum values as unknown instead of failing", () => { + const bytes = buildUImage({ + arch: 250, + os: 250, + type: 250, + compression: 250, + }); + const parsed = parseUImageHeaderFields(bytes.subarray(0, UIMAGE_HEADER_SIZE)); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.value).toMatchObject({ + archName: "unknown", + osName: "unknown", + typeName: "unknown", + compressionName: "unknown", + }); +}); + +it("rejects a bad magic and a bad header CRC", () => { + const good = buildUImage(); + const badMagic = Buffer.from(good); + badMagic.writeUInt32BE(0xdeadbeef, 0); + expect( + parseUImageHeaderFields(badMagic.subarray(0, UIMAGE_HEADER_SIZE)).ok, + ).toBe(false); + const badCrc = buildUImage({ corruptHeaderCrc: true }); + const parsed = parseUImageHeaderFields( + badCrc.subarray(0, UIMAGE_HEADER_SIZE), + ); + expect(parsed.ok).toBe(false); + if (!parsed.ok) + expect(parsed.error.message).toContain("header CRC32 mismatch"); +}); + +it("splits a multi-image size table and rejects malformed tables", () => { + const parts = [Buffer.from("aaa"), Buffer.from("bbbbb")]; + const payload = buildMultiPayload(parts); + const parsed = parseMultiImageTable(payload, payload.length); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.value).toEqual([ + { + index: 0, + byteOffset: UIMAGE_HEADER_SIZE + 12, + declaredSize: 3, + }, + { + index: 1, + byteOffset: UIMAGE_HEADER_SIZE + 16, + declaredSize: 5, + }, + ]); + const noTerminator = Buffer.from([0, 0, 0, 3, 0x61, 0x62, 0x63]); + expect(parseMultiImageTable(noTerminator, noTerminator.length).ok).toBe( + false, + ); + const overrun = Buffer.concat([ + Buffer.from([0, 0, 0, 99, 0, 0, 0, 0]), + Buffer.from("short"), + ]); + expect(parseMultiImageTable(overrun, overrun.length).ok).toBe(false); +}); + +it("classifies uImage bytes as a container by content, not by suffix", () => { + const bytes = buildUImage(); + expect(classifyArtifactBytesAtPath("firmware.bin", bytes)).toEqual({ + kind: "container", + format: "uimage", + }); + expect( + classifyArtifactBytesAtPath("firmware.bin", Buffer.from("nope")), + ).toEqual({ + kind: "resource", + format: "file", + }); +}); + +it("inventories a kernel image with its stored payload", async () => { + const payload = Buffer.from("kernel-bytes"); + const path = await writeCarrier( + "uimage-kernel", + buildUImage({ + arch: 22, + os: 5, + type: 2, + compression: 0, + loadAddress: 0x40080000, + entryPoint: 0x40080000, + name: "arm64-kernel", + payload, + }), + ); + const snapshot = await scanCanonicalArtifactInventory(path); + expect(snapshot.manifest.root_format).toBe("uimage"); + expect(snapshot.coverage.status).toBe("complete"); + expect(snapshot.limitations.join(" ")).toContain("not decompressed"); + const root = snapshot.occurrences.find((row) => row.logical_path === "."); + expect(root).toMatchObject({ artifact_kind: "container" }); + expect(root?.format_metadata).toMatchObject({ + kind: "uimage", + magic: UIMAGE_MAGIC, + arch: 22, + arch_name: "arm64", + os_name: "linux", + type_name: "kernel", + compression_name: "none", + load_address: 0x40080000, + entry_point: 0x40080000, + name: "arm64-kernel", + crc_verified: true, + sub_image_count: 0, + }); + const entry = snapshot.occurrences.find( + (row) => row.logical_path === "payload.bin", + ); + expect(entry).toMatchObject({ + declared_size: payload.length, + artifact_format: "file", + }); + const reader = new UImageArtifactReader(path); + try { + const entries = []; + for await (const item of reader.entries()) entries.push(item); + expect(entries).toHaveLength(1); + expect( + (await collect(await reader.open(entries[0]!))).equals(payload), + ).toBe(true); + } finally { + await reader.close(); + } +}); + +it("splits multi images and keeps compressed payloads as stored", async () => { + const partA = Buffer.from("first-part"); + const partB = Buffer.from("second-part-longer"); + const multi = await writeCarrier( + "uimage-multi", + buildUImage({ + type: 4, + name: "multi", + payload: buildMultiPayload([partA, partB]), + }), + ); + const snapshot = await scanCanonicalArtifactInventory(multi); + expect(snapshot.manifest.root_format).toBe("uimage"); + const root = snapshot.occurrences.find((row) => row.logical_path === "."); + expect(root?.format_metadata).toMatchObject({ + type_name: "multi", + sub_image_count: 2, + }); + expect(snapshot.limitations.join(" ")).toContain("size table"); + const reader = new UImageArtifactReader(multi); + try { + const entries = []; + for await (const item of reader.entries()) entries.push(item); + expect(entries.map((item) => item.path).sort()).toEqual([ + "image-0.bin", + "image-1.bin", + "payload.bin", + ]); + const byPath = new Map(entries.map((item) => [item.path, item])); + expect( + (await collect(await reader.open(byPath.get("image-0.bin")!))).equals( + partA, + ), + ).toBe(true); + expect( + (await collect(await reader.open(byPath.get("image-1.bin")!))).equals( + partB, + ), + ).toBe(true); + expect( + (await collect(await reader.open(byPath.get("payload.bin")!))).equals( + buildMultiPayload([partA, partB]), + ), + ).toBe(true); + } finally { + await reader.close(); + } + + const gzipPayload = Buffer.from([0x1f, 0x8b, 0x08, 0, 0, 0, 0, 0, 0, 3]); + const gzip = await writeCarrier( + "uimage-gzip", + buildUImage({ type: 2, compression: 1, payload: gzipPayload }), + ); + const gzipSnapshot = await scanCanonicalArtifactInventory(gzip); + expect( + gzipSnapshot.occurrences.find((row) => row.logical_path === ".") + ?.format_metadata, + ).toMatchObject({ compression_name: "gzip" }); + const gzipReader = new UImageArtifactReader(gzip); + try { + const entries = []; + for await (const item of gzipReader.entries()) entries.push(item); + expect( + (await collect(await gzipReader.open(entries[0]!))).equals(gzipPayload), + ).toBe(true); + } finally { + await gzipReader.close(); + } +}); + +it("fails closed on corrupt CRCs, truncation, and bad multi tables", async () => { + const cases: [string, Buffer, RegExp][] = [ + ["data CRC", buildUImage({ corruptDataCrc: true }), /data CRC32 mismatch/u], + [ + "header CRC", + buildUImage({ corruptHeaderCrc: true }), + /header CRC32 mismatch/u, + ], + [ + "truncated payload", + buildUImage({ payload: Buffer.from("twelve-bytes") }).subarray(0, 70), + /truncated/u, + ], + [ + "multi table without terminator", + buildUImage({ + type: 4, + payload: Buffer.from([0, 0, 0, 3, 0x61, 0x62, 0x63]), + }), + /no zero terminator/u, + ], + ]; + for (const [label, bytes, pattern] of cases) { + const path = await writeCarrier( + `uimage-bad-${label.replaceAll(" ", "-")}`, + bytes, + ); + await expect(scanCanonicalArtifactInventory(path)).rejects.toThrow(pattern); + } +}); + +it("rejects an entry that does not match its owning inventory", async () => { + const path = await writeCarrier("uimage-mismatch", buildUImage()); + const reader = new UImageArtifactReader(path); + try { + const entries = []; + for await (const item of reader.entries()) entries.push(item); + const forged = { ...entries[0]!, declaredSize: 999 }; + await expect(reader.open(forged)).rejects.toBeInstanceOf( + ArtifactReaderFailure, + ); + } finally { + await reader.close(); + } +}); diff --git a/src/artifacts/UImageArtifactReader.ts b/src/artifacts/UImageArtifactReader.ts new file mode 100644 index 0000000000..9019bea234 --- /dev/null +++ b/src/artifacts/UImageArtifactReader.ts @@ -0,0 +1,358 @@ +import { Readable, Transform, pipeline } from "node:stream"; +import { crc32 } from "node:zlib"; +import { readArtifactFileBytes } from "./ArtifactFileRange.js"; +import { setImmediate } from "node:timers/promises"; + +import type { + UImageEntryMetadata, + UImageMetadata, +} from "../domain/artifactFormatMetadata.js"; +import { ArtifactFileSource } from "./ArtifactFileSource.js"; +import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; +import { + ArtifactReaderFailure, + copyArtifactEntry, + sameArtifactEntry, + type ArtifactEntry, + type ArtifactReader, + type ArtifactTraversalOptions, +} from "./ArtifactReader.js"; +import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; +import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; +import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; +import { + parseMultiImageTable, + parseUImageHeaderFields, + UIMAGE_HEADER_SIZE, + UIMAGE_MAGIC, + UIMAGE_MULTI_TABLE_WINDOW, + type UImageHeaderFields, + type UImageParseError, + type UImageSubImage, +} from "../domain/uimageFormat.js"; + +const PAYLOAD_PATH = "payload.bin"; +const READ_CHUNK_BYTES = 64 * 1024; +type UImageEntry = ArtifactEntry & { + readonly byteOffset: number; + readonly declaredSize: number; + readonly formatMetadata: UImageEntryMetadata; +}; + +/** Read a U-Boot legacy uImage without running, decompressing, or altering it. */ +export class UImageArtifactReader implements ArtifactReader { + readonly format = "uimage" as const; + readonly #scope = new ArtifactResourceScope(); + readonly #entries = new Map(); + readonly #streams = new Set(); + readonly #source: ArtifactFileSource; + #prepared: Promise | undefined; + #header: UImageMetadata | undefined; + #closing = false; + + constructor(path: string, admitted?: StableRegularFileDescriptor) { + this.#source = new ArtifactFileSource(path, "U-Boot uImage", admitted); + } + + formatMetadata(): UImageMetadata | undefined { + return this.#header === undefined ? undefined : { ...this.#header }; + } + + async prepareContainer( + expectedSha256: string, + signal?: AbortSignal, + ): Promise { + await this.#scope.run(async () => { + this.#assertOpen(signal); + await this.#source.bindSha256(expectedSha256, signal); + }); + } + + async *entries( + signal?: AbortSignal, + options?: ArtifactTraversalOptions, + ): AsyncIterable { + await this.#scope.run(async () => { + this.#assertOpen(signal); + this.#prepared ??= this.#prepare(signal); + await this.#prepared; + await this.#source.read(signal); + }); + this.#assertOpen(signal); + options?.onCoverage?.({ + status: "complete", + limitations: + this.#header?.type_name === "multi" + ? [ + "Multi-image sub-images are split from the stored size table; the payload entry retains the full stored bytes.", + ] + : [], + }); + let index = 0; + for (const entry of this.#entries.values()) { + if (index % 256 === 0) await setImmediate(); + index += 1; + this.#assertOpen(signal); + yield copyArtifactEntry(entry); + } + this.#assertOpen(signal); + } + + async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { + return this.#scope.run(async () => { + this.#assertOpen(signal); + const stored = this.#entries.get(entry.adapterKey); + if (stored === undefined || !sameArtifactEntry(entry, stored)) + throw new ArtifactReaderFailure( + "integrity", + "uImage entry does not match its owning inventory", + ); + const { handle } = await this.#source.read(signal); + this.#assertOpen(signal); + const source = + stored.declaredSize === 0 + ? Readable.from([]) + : readFileHandleChunks(handle, { + start: stored.byteOffset, + end: stored.byteOffset + stored.declaredSize - 1, + signal, + cancellationFailure: (reason) => + new ArtifactReaderFailure( + "cancelled", + "uImage read cancelled", + { + cause: reason, + }, + ), + }); + const output = this.#memberStream(stored, signal); + output.on("error", () => undefined); + this.#streams.add(output); + let producerComplete = false; + const onAbort = (): void => { + // Active reads/validation own failure precedence. After they complete, + // cancellation must still retire any unconsumed readable bytes. + if (!producerComplete || output.destroyed) return; + output.destroy( + new ArtifactReaderFailure("cancelled", "uImage read cancelled", { + cause: signal?.reason, + }), + ); + }; + const retired = new Promise((resolve) => { + output.once("close", () => { + this.#streams.delete(output); + signal?.removeEventListener("abort", onAbort); + resolve(); + }); + }); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); + void this.#scope + .run(async () => { + // Writable completion can leave readable bytes buffered. Retain both + // producer I/O and the caller's stream until consumption or closure. + await Promise.all([ + new Promise((resolve) => + pipeline(source, output, () => { + producerComplete = true; + if (signal?.aborted) onAbort(); + resolve(); + }), + ), + retired, + ]); + }) + .catch((cause: unknown) => + output.destroy( + cause instanceof Error + ? cause + : new Error("uImage stream ownership failed", { cause }), + ), + ); + return output; + }); + } + + async close(): Promise { + this.#closing = true; + for (const stream of this.#streams) + stream.destroy( + new ArtifactReaderFailure("unavailable", "uImage reader closed"), + ); + await this.#scope.close(); + await this.#source.close(); + this.#entries.clear(); + } + + provenance(): readonly [] { + return []; + } + + #assertOpen(signal?: AbortSignal): void { + assertArtifactReadActive(signal); + if (this.#closing) + throw new ArtifactReaderFailure("unavailable", "uImage reader closed"); + } + + async #prepare(signal?: AbortSignal): Promise { + const { handle, initial } = await this.#source.read(signal); + const headerBytes = await readArtifactFileBytes( + handle, + 0, + Math.min(initial.size, UIMAGE_HEADER_SIZE), + initial.size, + signal, + ); + const fields = parseUImageHeaderFields(headerBytes); + if (!fields.ok) throw parseFailure(fields.error); + const header = fields.value; + if (initial.size < UIMAGE_HEADER_SIZE + header.payloadSize) + throw formatFailure( + `uImage payload is truncated: header declares ${header.payloadSize} payload bytes but only ${initial.size - UIMAGE_HEADER_SIZE} follow the header`, + ); + // Admit the bounded size table first, then include those same bytes in the + // native CRC stream. No payload byte needs to be read twice for verification. + let dataCrc32 = 0; + let read = 0; + let subImages: readonly UImageSubImage[] = []; + if (header.type === 4) { + const tablePrefix = await readArtifactFileBytes( + handle, + UIMAGE_HEADER_SIZE, + Math.min(header.payloadSize, UIMAGE_MULTI_TABLE_WINDOW), + initial.size, + signal, + ); + const parsed = parseMultiImageTable(tablePrefix, header.payloadSize); + if (!parsed.ok) throw parseFailure(parsed.error); + subImages = parsed.value; + dataCrc32 = crc32(tablePrefix); + read = tablePrefix.length; + } + while (read < header.payloadSize) { + this.#assertOpen(signal); + const chunk = await readArtifactFileBytes( + handle, + UIMAGE_HEADER_SIZE + read, + Math.min(READ_CHUNK_BYTES, header.payloadSize - read), + initial.size, + signal, + ); + dataCrc32 = crc32(chunk, dataCrc32); + read += chunk.length; + } + if (dataCrc32 !== header.dataCrc32) + throw formatFailure( + `uImage data CRC32 mismatch: declared 0x${header.dataCrc32.toString(16)}, computed 0x${dataCrc32.toString(16)}`, + ); + this.#header = toMetadata(header, subImages.length); + const payload = entryFor("payload", 0, { + byteOffset: UIMAGE_HEADER_SIZE, + declaredSize: header.payloadSize, + }); + this.#entries.set(payload.adapterKey, payload); + for (const sub of subImages) { + const entry = entryFor("sub-image", sub.index, sub); + this.#entries.set(entry.adapterKey, entry); + } + } + + #memberStream(entry: UImageEntry, signal?: AbortSignal): Transform { + let bytes = 0; + const check = async (): Promise => { + if (bytes !== entry.declaredSize) + throw formatFailure(`member ended early: ${entry.path}`); + await this.#source.read(signal); + }; + return new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytes += chunk.length; + callback(null, chunk); + }, + flush(callback) { + void check().then( + () => callback(), + (cause: unknown) => + callback( + cause instanceof Error + ? cause + : new Error("uImage read failed", { cause }), + ), + ); + }, + }); + } +} + +const formatFailure = ( + detail: string, + options?: ErrorOptions, +): ArtifactReaderFailure => + new ArtifactReaderFailure( + "format", + `uImage is malformed: ${detail}`, + options, + ); + +const parseFailure = (failure: UImageParseError): ArtifactReaderFailure => + failure._tag === "UImageCapacityFailure" + ? new ArtifactReaderFailure("limit", failure.message, { + boundary: "capacity", + cause: failure, + }) + : formatFailure(failure.message, { cause: failure }); + +const entryFor = ( + role: "payload" | "sub-image", + index: number, + range: Pick, +): UImageEntry => { + const payload = role === "payload"; + const path = payload ? PAYLOAD_PATH : `image-${String(index)}.bin`; + const adapterKey = payload + ? "uimage:payload" + : `uimage:image-${String(index)}`; + return { + path, + kind: "file", + declaredSize: range.declaredSize, + compressedSize: null, + executable: false, + encrypted: false, + byteOffset: range.byteOffset, + declaredIntegrity: null, + unpacked: false, + limitations: [], + adapterKey, + formatMetadata: { kind: "uimage-entry", role, index }, + sourcePath: path, + }; +}; + +const toMetadata = ( + facts: UImageHeaderFields, + subImageCount: number, +): UImageMetadata => ({ + kind: "uimage", + magic: UIMAGE_MAGIC, + timestamp: facts.timestamp, + timestamp_iso: facts.timestampIso, + payload_size: facts.payloadSize, + load_address: facts.loadAddress, + entry_point: facts.entryPoint, + header_crc32: facts.headerCrc32, + data_crc32: facts.dataCrc32, + crc_verified: true, + os: facts.os, + os_name: facts.osName, + arch: facts.arch, + arch_name: facts.archName, + type: facts.type, + type_name: facts.typeName, + compression: facts.compression, + compression_name: facts.compressionName, + name: facts.name, + name_bytes_hex: facts.nameBytesHex, + sub_image_count: subImageCount, +}); diff --git a/src/artifacts/createArtifactReader.ts b/src/artifacts/createArtifactReader.ts index 471246ba34..44e97a6193 100644 --- a/src/artifacts/createArtifactReader.ts +++ b/src/artifacts/createArtifactReader.ts @@ -11,6 +11,7 @@ import { MachOSliceArtifactReader } from "./MachOSliceArtifactReader.js"; import { NativeDmgArtifactReader } from "./NativeDmgArtifactReader.js"; import { NodeSeaArtifactReader } from "./NodeSeaArtifactReader.js"; import { PyInstallerArtifactReader } from "./PyInstallerArtifactReader.js"; +import { UImageArtifactReader } from "./UImageArtifactReader.js"; import { UnityFsArtifactReader } from "./UnityFsArtifactReader.js"; import { ZipArtifactReader } from "./ZipArtifactReader.js"; import type { ArtifactOccurrence } from "../domain/artifactGraph.js"; @@ -28,6 +29,7 @@ export type ArtifactReaderFormat = | "node-sea" | "godot-pck" | "unityfs" + | "uimage" | "intel-hex" | "srec" | "mach-o-universal" @@ -110,6 +112,8 @@ export function createArtifactReader( ? admission : rootSource, ); + case "uimage": + return new UImageArtifactReader(path, rootSource); case "unityfs": return new UnityFsArtifactReader(path, rootSource); case "intel-hex": diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 296fb875d6..9caa6883fe 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -587,6 +587,7 @@ const requireExtractableFormat = async ( format !== "asar" && format !== "godot-pck" && format !== "unityfs" && + format !== "uimage" && format !== "intel-hex" && format !== "srec" && format !== "mach-o-universal" && diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 4f533f1205..1be664a8a8 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -1,4 +1,5 @@ import { hasGodotPckHeader } from "../../domain/godotPckFormat.js"; +import { hasUImageMagic } from "../../domain/uimageFormat.js"; import { hasUnityFsHeader } from "../../domain/unityfsFormat.js"; import { detectHexRecordEncoding } from "../../domain/firmware/hexRecords.js"; import type { ArtifactClassificationEvidence } from "../ArtifactClassificationProbe.js"; @@ -281,6 +282,7 @@ export const rootOccurrenceFor = ( "asar", "godot-pck", "unityfs", + "uimage", "dmg", "pkg", "pyinstaller", @@ -581,6 +583,7 @@ const artifactRoleForFormat = ( if ( format === "godot-pck" || format === "unityfs" || + format === "uimage" || format === "intel-hex" || format === "srec" ) @@ -602,6 +605,7 @@ export const classifyArtifactBytes = ( if (hasZipSignature(prefix)) return "zip"; if (hasGodotPckHeader(prefix)) return "godot-pck"; if (hasUnityFsHeader(prefix)) return "unityfs"; + if (hasUImageMagic(prefix)) return "uimage"; const hexRecords = detectHexRecordEncoding(prefix); if (hexRecords !== undefined) return hexRecords; if (hasBinaryPlistSignature(prefix)) return "plist"; diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 94398932d6..5a47196c97 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -166,6 +166,7 @@ export const classifyAndHashRootForInventory = async ( format === "mach-o-universal" || format === "godot-pck" || format === "unityfs" || + format === "uimage" || format === "pyinstaller" || format === "node-sea" || format === "intel-hex" || diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index 9a3aceca43..ec312fb73c 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -79,6 +79,11 @@ export const inventoryCoverage = ( "Intel HEX/S-record inventory decodes data records into address-contiguous regions. It does not establish CPU, byte order, memory map, or that a declared start address is executable; gaps are unwritten, not zero.", ] : []), + ...(reader.format === "uimage" + ? [ + "uImage inventory establishes the 64-byte legacy header facts and stored payload bytes. Compressed payloads are not decompressed, and the image is not executed.", + ] + : []), ...traversal.limitations, ], }; @@ -103,6 +108,7 @@ export const inventoryCoverage = ( format === "asar" || format === "godot-pck" || format === "unityfs" || + format === "uimage" || format === "node-sea" || format === "intel-hex" || format === "srec" diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index 96ba2cc6c4..c805677ad5 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -82,6 +82,7 @@ const formatSchema = z.enum([ "plist", "javascript", "source-map", + "uimage", ]); const kindSchema = z.enum(["executable", "database", "archive", "artifact"]); const subjectBaseSchema = z.object({ diff --git a/src/domain/artifactFormatMetadata.ts b/src/domain/artifactFormatMetadata.ts index 87b7badeb8..7bb991391f 100644 --- a/src/domain/artifactFormatMetadata.ts +++ b/src/domain/artifactFormatMetadata.ts @@ -215,6 +215,43 @@ export const pyInstallerEntryDeclaration = ( type_code: metadata.type_code, }); +/** + * U-Boot legacy uImage header facts. Declared addresses are facts from the + * header, not proven entry points; CRCs are verified before inventory. + */ +export const uImageMetadataSchema = z.strictObject({ + kind: z.literal("uimage"), + magic: z.literal(0x27051956), + timestamp: uint32, + timestamp_iso: z.string(), + payload_size: uint32, + load_address: uint32, + entry_point: uint32, + header_crc32: uint32, + data_crc32: uint32, + crc_verified: z.literal(true), + os: z.number().int().min(0).max(255), + os_name: z.string(), + arch: z.number().int().min(0).max(255), + arch_name: z.string(), + type: z.number().int().min(0).max(255), + type_name: z.string(), + compression: z.number().int().min(0).max(255), + compression_name: z.string(), + name: z.string().nullable(), + name_bytes_hex: z.string().regex(/^[0-9a-f]{64}$/u), + sub_image_count: uint32, +}); +export type UImageMetadata = z.infer; + +/** One stored payload, or one multi-image sub-image, as stored bytes. */ +export const uImageEntryMetadataSchema = z.strictObject({ + kind: z.literal("uimage-entry"), + role: z.enum(["payload", "sub-image"]), + index: uint32, +}); +export type UImageEntryMetadata = z.infer; + export const artifactFormatMetadataSchema = z.discriminatedUnion("kind", [ godotPckMetadataSchema, godotPckEntryMetadataSchema, @@ -226,6 +263,8 @@ export const artifactFormatMetadataSchema = z.discriminatedUnion("kind", [ nodeSeaEntryMetadataSchema, hexRecordsMetadataSchema, hexRecordRegionMetadataSchema, + uImageMetadataSchema, + uImageEntryMetadataSchema, ]); export type ArtifactFormatMetadata = z.infer< typeof artifactFormatMetadataSchema diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index 4515aae529..5faa354671 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -62,6 +62,7 @@ const artifactFormatSchema = z.enum([ "asar", "godot-pck", "unityfs", + "uimage", "javascript-bundle", "source-map", "mach-o-universal", @@ -97,6 +98,7 @@ const artifactContentFormatSchema = artifactFormatSchema.extract([ "zip", "godot-pck", "unityfs", + "uimage", "intel-hex", "srec", "mach-o-universal", diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 060bfdeb6d..d1fbfe594a 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -259,6 +259,7 @@ export type BinaryTarget = | "asar" | "godot-pck" | "unityfs" + | "uimage" | "dmg" | "pkg" | "intel-hex" diff --git a/src/domain/evidenceSubject.ts b/src/domain/evidenceSubject.ts index 4b655201e7..facc57c0e3 100644 --- a/src/domain/evidenceSubject.ts +++ b/src/domain/evidenceSubject.ts @@ -26,6 +26,7 @@ export const evidenceSubjectFormatSchema = z.enum([ "asar", "godot-pck", "unityfs", + "uimage", "dmg", "pkg", "plist", diff --git a/src/domain/liveBinaryTargetSummary.ts b/src/domain/liveBinaryTargetSummary.ts index 419efedcea..23185fbe4d 100644 --- a/src/domain/liveBinaryTargetSummary.ts +++ b/src/domain/liveBinaryTargetSummary.ts @@ -72,6 +72,7 @@ export const liveBinaryTargetSummaryVariants = [ "asar", "godot-pck", "unityfs", + "uimage", "dmg", "pkg", "intel-hex", diff --git a/src/domain/uimageFormat.ts b/src/domain/uimageFormat.ts new file mode 100644 index 0000000000..a6af964d2a --- /dev/null +++ b/src/domain/uimageFormat.ts @@ -0,0 +1,269 @@ +import { crc32 } from "node:zlib"; + +import { err, ok, type Result } from "./result.js"; + +/** + * U-Boot legacy uImage header (U-Boot `include/image.h`, all fields + * big-endian). Only the 64-byte header is interpreted here; payload bytes + * are never decompressed, executed, or otherwise transformed. + */ +export const UIMAGE_MAGIC = 0x27051956; +export const UIMAGE_HEADER_SIZE = 64; +export const UIMAGE_NAME_LENGTH = 32; + +/** Cap on multi-image sub-images: one inventory entry per sub-image. */ +export const UIMAGE_MAX_SUB_IMAGES = 4096; + +/** Bytes of payload prefix that can hold a maximal multi-image size table. */ +export const UIMAGE_MULTI_TABLE_WINDOW = (UIMAGE_MAX_SUB_IMAGES + 1) * 4; + +export interface UImageSubImage { + readonly index: number; + /** Byte offset of the sub-image payload relative to the carrier start. */ + readonly byteOffset: number; + readonly declaredSize: number; +} + +export interface UImageHeaderFields { + readonly magic: number; + readonly timestamp: number; + readonly timestampIso: string; + readonly payloadSize: number; + readonly loadAddress: number; + readonly entryPoint: number; + readonly headerCrc32: number; + readonly dataCrc32: number; + readonly os: number; + readonly osName: string; + readonly arch: number; + readonly archName: string; + readonly type: number; + readonly typeName: string; + readonly compression: number; + readonly compressionName: string; + readonly name: string | null; + readonly nameBytesHex: string; +} + +// IDs are append-only in U-Boot include/image.h, pinned ece349ade2973e220f524ce59e59711cc919263f. +const ARCH_NAMES: Readonly> = { + 0: "invalid", + 1: "alpha", + 2: "arm", + 3: "x86", + 4: "ia64", + 5: "mips", + 6: "mips64", + 7: "ppc", + 8: "s390", + 9: "sh", + 10: "sparc", + 11: "sparc64", + 12: "m68k", + 13: "nios", + 14: "microblaze", + 15: "nios2", + 16: "blackfin", + 17: "avr32", + 18: "st200", + 19: "sandbox", + 20: "nds32", + 21: "openrisc", + 22: "arm64", + 23: "arc", + 24: "x86_64", + 25: "xtensa", + 26: "riscv", +}; + +const OS_NAMES: Readonly> = { + 0: "invalid", + 1: "openbsd", + 2: "netbsd", + 3: "freebsd", + 4: "4_4bsd", + 5: "linux", + 6: "svr4", + 7: "esix", + 8: "solaris", + 9: "irix", + 10: "sco", + 11: "dell", + 12: "ncr", + 13: "lynxos", + 14: "vxworks", + 15: "psos", + 16: "qnx", + 17: "u-boot", + 18: "rtems", + 19: "artos", + 20: "unity", + 21: "integrity", + 22: "ose", + 23: "plan9", + 24: "openrtos", + 25: "arm-trusted-firmware", + 26: "tee", + 27: "opensbi", + 28: "efi", + 29: "elf", +}; + +const TYPE_NAMES: Readonly> = { + 0: "invalid", + 1: "standalone", + 2: "kernel", + 3: "ramdisk", + 4: "multi", + 5: "firmware", + 6: "script", + 7: "filesystem", + 8: "flat_dt", +}; + +const COMPRESSION_NAMES: Readonly> = { + 0: "none", + 1: "gzip", + 2: "bzip2", + 3: "lzma", + 4: "lzo", + 5: "lz4", + 6: "zstd", +}; + +const named = ( + table: Readonly>, + value: number, +): string => table[value] ?? "unknown"; + +export type UImageParseError = + | { readonly _tag: "UImageFormatFailure"; readonly message: string } + | { readonly _tag: "UImageCapacityFailure"; readonly message: string }; +const malformed = (message: string): UImageParseError => ({ + _tag: "UImageFormatFailure", + message, +}); +const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +/** True when the first four bytes are the U-Boot legacy magic. */ +export const hasUImageMagic = (prefix: Buffer): boolean => + prefix.length >= 4 && prefix.readUInt32BE(0) === UIMAGE_MAGIC; + +/** + * Parse the 64-byte header and verify its CRC32 (computed over the header + * with the ih_hcrc field zeroed, per `image_check_hcrc`). Fail-closed with + * a named reason; the payload is not touched here. + */ +export const parseUImageHeaderFields = ( + header: Buffer, +): Result => { + if (header.length < UIMAGE_HEADER_SIZE) + return err( + malformed( + `uImage header is truncated: ${header.length} bytes, expected ${UIMAGE_HEADER_SIZE}`, + ), + ); + if (header.readUInt32BE(0) !== UIMAGE_MAGIC) + return err( + malformed( + `uImage magic mismatch: expected 0x${UIMAGE_MAGIC.toString(16)}, found 0x${header.readUInt32BE(0).toString(16)}`, + ), + ); + const declaredHeaderCrc = header.readUInt32BE(4); + const zeroed = Buffer.from(header.subarray(0, UIMAGE_HEADER_SIZE)); + zeroed.writeUInt32BE(0, 4); + const computedHeaderCrc = crc32(zeroed); + if (computedHeaderCrc !== declaredHeaderCrc) + return err( + malformed( + `uImage header CRC32 mismatch: declared 0x${declaredHeaderCrc.toString(16)}, computed 0x${computedHeaderCrc.toString(16)}`, + ), + ); + const timestamp = header.readUInt32BE(8); + const nameBytes = header.subarray(32, 32 + UIMAGE_NAME_LENGTH); + const nul = nameBytes.indexOf(0); + let name: string | null; + try { + name = UTF8.decode( + nameBytes.subarray(0, nul === -1 ? UIMAGE_NAME_LENGTH : nul), + ); + } catch { + name = null; + } + const os = header[28] ?? 0; + const arch = header[29] ?? 0; + const type = header[30] ?? 0; + const compression = header[31] ?? 0; + return ok({ + magic: UIMAGE_MAGIC, + timestamp, + timestampIso: new Date(timestamp * 1000).toISOString(), + payloadSize: header.readUInt32BE(12), + loadAddress: header.readUInt32BE(16), + entryPoint: header.readUInt32BE(20), + headerCrc32: declaredHeaderCrc, + dataCrc32: header.readUInt32BE(24), + os, + osName: named(OS_NAMES, os), + arch, + archName: named(ARCH_NAMES, arch), + type, + typeName: named(TYPE_NAMES, type), + compression, + compressionName: named(COMPRESSION_NAMES, compression), + name, + nameBytesHex: nameBytes.toString("hex"), + }); +}; + +/** + * Multi-file payload layout (U-Boot `image_multi_getimg`): the payload + * begins with a table of big-endian sub-image sizes terminated by a zero + * entry; the sub-image bytes follow the table in order, with nonfinal components + * padded to a four-byte boundary. `prefix` must hold + * the table's opening bytes (up to UIMAGE_MULTI_TABLE_WINDOW); the table + * must terminate inside the payload and the sub-images must fit within + * `payloadSize`. Trailing slack after the last sub-image is tolerated + * because it stays inside the stored payload entry. + */ +export const parseMultiImageTable = ( + prefix: Buffer, + payloadSize: number, +): Result => { + if (!Number.isSafeInteger(payloadSize) || payloadSize < 0) + return err( + malformed("uImage payload size is not an exact nonnegative extent"), + ); + const sizes: number[] = []; + let cursor = 0; + for (;;) { + if (cursor + 4 > prefix.length || cursor + 4 > payloadSize) + return err( + malformed( + "uImage multi-image size table has no zero terminator inside the payload", + ), + ); + const size = prefix.readUInt32BE(cursor); + cursor += 4; + if (size === 0) break; + sizes.push(size); + if (sizes.length > UIMAGE_MAX_SUB_IMAGES) + return err({ + _tag: "UImageCapacityFailure", + message: `uImage multi-image table declares more than ${UIMAGE_MAX_SUB_IMAGES} sub-images`, + }); + } + const subImages: UImageSubImage[] = []; + let offset = UIMAGE_HEADER_SIZE + cursor; + for (const [index, size] of sizes.entries()) { + if (offset + size > UIMAGE_HEADER_SIZE + payloadSize) + return err( + malformed( + `uImage multi-image sub-image ${index} of ${size} bytes overruns the ${payloadSize}-byte payload`, + ), + ); + subImages.push({ index, byteOffset: offset, declaredSize: size }); + offset += index + 1 < sizes.length ? Math.ceil(size / 4) * 4 : size; + } + return ok(subImages); +}; diff --git a/tests/acceptance/uImageCli.test.ts b/tests/acceptance/uImageCli.test.ts new file mode 100644 index 0000000000..027844dda5 --- /dev/null +++ b/tests/acceptance/uImageCli.test.ts @@ -0,0 +1,84 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { cliTest } from "../support/cli/cliFixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { buildMultiPayload, buildUImage } from "../fixtures/uimage.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; +import { + artifactExtractionResultSchema, + artifactInventoryResultSchema, +} from "../../src/domain/artifactGraph.js"; + +cliTest( + "inspects and extracts a uImage through the compiled CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-uimage-cli-"); + const partA = Buffer.from("first-part"); + const partB = Buffer.from("second-part"); + const source = join(root, "firmware.bin"); + await writeFile( + source, + buildUImage({ + arch: 22, + os: 14, + type: 4, + compression: 0, + loadAddress: 0x8000, + entryPoint: 0x8000, + name: "multi-test", + payload: buildMultiPayload([partA, partB]), + }), + ); + const inspected = await cli.run({ + arguments: ["inspect-artifact", source, "--json"], + }); + expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(0); + const inspection = artifactInspectionResultSchema.parse( + parseEvidence(inspected.json).normalized_result, + ); + const graph = artifactInventoryResultSchema.parse( + inspection.substeps[0]?.evidence.normalized_result, + ); + expect(graph.manifest.root_format).toBe("uimage"); + expect(graph.coverage.status).toBe("complete"); + expect( + graph.occurrences.find((row) => row.logical_path === ".") + ?.format_metadata, + ).toMatchObject({ + kind: "uimage", + arch: 22, + arch_name: "arm64", + os: 14, + os_name: "vxworks", + type_name: "multi", + compression_name: "none", + name: "multi-test", + crc_verified: true, + sub_image_count: 2, + }); + const extracted = await cli.run({ + arguments: ["extract-artifact", source, "--json"], + environment: { TMPDIR: root, TMP: root, TEMP: root }, + }); + expect(extracted.exitCode, extracted.stdout + extracted.stderr).toBe(0); + const result = artifactExtractionResultSchema.parse( + parseEvidence(extracted.json).normalized_result, + ); + expect(result.containment_verified).toBe(true); + expect( + (await readFile(join(result.output_root, "payload.bin"))).equals( + buildMultiPayload([partA, partB]), + ), + ).toBe(true); + expect( + (await readFile(join(result.output_root, "image-0.bin"))).equals(partA), + ).toBe(true); + expect( + (await readFile(join(result.output_root, "image-1.bin"))).equals(partB), + ).toBe(true); + }, +); diff --git a/tests/boundary/mcp/uImageMcp.test.ts b/tests/boundary/mcp/uImageMcp.test.ts new file mode 100644 index 0000000000..a8715ff805 --- /dev/null +++ b/tests/boundary/mcp/uImageMcp.test.ts @@ -0,0 +1,95 @@ +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; +import { artifactExtractionResultSchema } from "../../../src/domain/artifactGraph.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { buildUImage, buildMultiPayload } from "../../fixtures/uimage.js"; + +it.each(["full", "compact"] as const)( + "retains uImage physical facts and unknown text through compiled MCP (%s)", + async (profile) => { + const root = await createTestTempDirectory("rea-uimage-mcp-"); + const path = join(root, "firmware"); + const parts = [Buffer.from("abc"), Buffer.from("second part")]; + const bytes = buildUImage({ + arch: 22, + os: 14, + type: 4, + name: "测".repeat(11), + payload: buildMultiPayload(parts), + }); + await writeFile(path, bytes); + const { call } = await connectLocalToolsMcp({ + REA_MCP_INPUT_SCHEMA_PROFILE: profile, + }); + let outputRoot: string | undefined; + try { + const opened = await call("open_binary", { path }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + expect(opened.structuredContent).toMatchObject({ + result: { kind: "archive", format: "uimage", architecture: null }, + }); + const reply = await call("inspect_artifact", {}); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const inspection = artifactInspectionResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + const inventory = inspection.substeps[0]!.evidence.normalized_result; + expect(inventory.coverage.status).toBe("complete"); + expect(inventory.occurrences).toContainEqual( + expect.objectContaining({ + logical_path: ".", + format_metadata: expect.objectContaining({ + kind: "uimage", + arch: 22, + arch_name: "arm64", + os: 14, + os_name: "vxworks", + name: null, + name_bytes_hex: bytes.subarray(32, 64).toString("hex"), + crc_verified: true, + }), + }), + ); + expect(inventory.occurrences).toContainEqual( + expect.objectContaining({ + logical_path: "image-1.bin", + source_location: { offset: 80, length: parts[1]!.length }, + }), + ); + for (const part of parts) + expect(inventory.nodes).toContainEqual( + expect.objectContaining({ + sha256: createHash("sha256").update(part).digest("hex"), + }), + ); + const extracted = await call("extract_artifact", {}); + expect(extracted.isError, JSON.stringify(extracted)).not.toBe(true); + const result = artifactExtractionResultSchema.parse( + parseEvidence(extracted.structuredContent).normalized_result, + ); + outputRoot = result.output_root; + expect(result.containment_verified).toBe(true); + expect(result.cleanup).toMatchObject({ + verified: true, + residual_paths: [], + }); + for (const [index, part] of parts.entries()) + expect( + ( + await readFile(join(outputRoot, `image-${String(index)}.bin`)) + ).equals(part), + ).toBe(true); + expect((await readFile(path)).equals(bytes)).toBe(true); + await call("close_binary", {}); + } finally { + if (outputRoot !== undefined) + await rm(outputRoot, { recursive: true, force: true }); + } + }, + 60_000, +); diff --git a/tests/fixtures/uimage.ts b/tests/fixtures/uimage.ts new file mode 100644 index 0000000000..5c4f8ec8d0 --- /dev/null +++ b/tests/fixtures/uimage.ts @@ -0,0 +1,71 @@ +import { crc32 } from "node:zlib"; + +import { + UIMAGE_HEADER_SIZE, + UIMAGE_MAGIC, + UIMAGE_NAME_LENGTH, +} from "../../src/domain/uimageFormat.js"; + +export interface UImageFixtureOptions { + readonly arch?: number; + readonly os?: number; + readonly type?: number; + readonly compression?: number; + readonly loadAddress?: number; + readonly entryPoint?: number; + readonly timestamp?: number; + readonly name?: string; + /** Raw stored payload bytes; for multi images pass the size table + parts. */ + readonly payload?: Buffer; + /** Corrupt the header CRC after computing it (keeps a valid magic). */ + readonly corruptHeaderCrc?: boolean; + /** Corrupt the data CRC after computing it. */ + readonly corruptDataCrc?: boolean; +} + +/** Build a synthetic legacy uImage with correctly computed CRCs. */ +export const buildUImage = (options: UImageFixtureOptions = {}): Buffer => { + const payload = options.payload ?? Buffer.from("uimage-payload"); + const header = Buffer.alloc(UIMAGE_HEADER_SIZE); + header.writeUInt32BE(UIMAGE_MAGIC, 0); + header.writeUInt32BE(options.timestamp ?? 1700000000, 8); + header.writeUInt32BE(payload.length, 12); + header.writeUInt32BE(options.loadAddress ?? 0x8000, 16); + header.writeUInt32BE(options.entryPoint ?? 0x8000, 20); + // Data CRC first: corrupting it must keep the header CRC valid, so the + // header CRC is computed over the final header bytes below. + const dataCrc = + (crc32(payload) >>> 0) ^ (options.corruptDataCrc === true ? 0xff : 0); + header.writeUInt32BE(dataCrc >>> 0, 24); + header[28] = options.os ?? 5; + header[29] = options.arch ?? 2; + header[30] = options.type ?? 2; + header[31] = options.compression ?? 0; + const name = Buffer.from(options.name ?? "test-kernel", "utf8"); + name.copy(header, 32, 0, Math.min(name.length, UIMAGE_NAME_LENGTH)); + const headerCrc = crc32(header) >>> 0; + header.writeUInt32BE( + options.corruptHeaderCrc === true ? (headerCrc ^ 0xff) >>> 0 : headerCrc, + 4, + ); + return Buffer.concat([header, payload]); +}; + +/** Size table + parts layout for a multi-file image payload. */ +export const buildMultiPayload = (parts: readonly Buffer[]): Buffer => { + const table = Buffer.alloc((parts.length + 1) * 4); + parts.forEach((part, index) => { + table.writeUInt32BE(part.length, index * 4); + }); + const stored: Buffer[] = [table]; + parts.forEach((part, index) => { + if (part.length === 0) + throw new Error( + "A zero size terminates the uImage table; it cannot describe a component", + ); + stored.push(part); + if (index + 1 < parts.length) + stored.push(Buffer.alloc((4 - (part.length % 4)) % 4)); + }); + return Buffer.concat(stored); +};