Files
reverse-engineering-anything/src/artifacts/DecodedRegionArtifactReader.ts
T
Entrpiandmorluto b43c329df7 feat(artifacts): inventory and extract UF2 and DfuSe firmware containers (#8230)
* feat(artifacts): inventory and extract UF2 and DfuSe regions

open_binary refused UF2 and DfuSe firmware containers as an unsupported
format, so inspect_artifact and extract_artifact could not show which
bytes a drag-and-drop or DFU update writes at which addresses.

UF2 is recognized by a complete first block with all three magic
numbers. Valid main-flash payloads are joined into address-contiguous
regions per familyID, named family-0x<id>/0x<start>-0x<end>.bin, and
known IDs are named from a small table copied from Microsoft's
uf2families.json at a pinned commit. Numbering is counted per familyID
and block count: absent, repeated, out-of-order and out-of-range block
numbers are reported, and absent blocks leave coverage unknown.
Not-main-flash blocks are excluded and reported; file-container blocks
are reported and leave coverage unknown; invalid blocks and a partial
final block are excluded with their position. A rewrite of one address
with different bytes fails closed, naming both block spans.

DfuSe is recognized only by its prefix together with a DFU 1.1a suffix.
Each element becomes one region at its file range with its target's
alternate setting and name. Structural faults fail closed with a byte
offset; overlapping elements stay separate and are reported. A suffix
CRC mismatch follows integrity_policy: fail refuses with a dfu-crc32
integrity error, and record-and-continue records a root contradiction
that extraction also carries.

HEX, UF2 and DfuSe share one retained-region reader and one run
merger, so the HEX decoder and reader lose their private copies.

* docs(artifacts): document UF2 and DfuSe images and their esptool lane

Describe recognition, region names, metadata, numbering, failures, the
DfuSe suffix CRC under integrity_policy, and limits, and link the page
from the CLI and firmware guides and the agent skill reference.

npm run verify:uf2 runs esptool merge-bin --format uf2 on generated
inputs at known addresses, with and without MD5, with 256-byte
payloads, and on a concatenation of two chips' outputs. Inspection and
extraction through the built CLI must return every input at its address
under the familyID esptool itself reports.

* fix(artifacts): accept both DfuSe image size conventions

UM0391 calls DFUImageSize the file length, and dfu-util's dfuse-pack.py
writes that value, while ST's DfuFileMgr, MicroPython's dfu.py and pydfu
leave the 16-byte suffix out. The decoder accepted only the second form,
so it refused dfuse-pack.py output as malformed.

Accept either value and keep the declared one in image_size; any other
value still fails with the suffix offset and the file length. The tests
pin both producers' output for one image, CRC included, from their
build() functions transcribed and run under Python 3.

* fix(artifacts): keep reused UF2 block numbers from hiding missing blocks

esptool numbers every merged input from 0, so two inputs with the same
block count share one numbering group. A block lost from one input left
its number present in the other, and the file reported complete
coverage. Each block number now records the target addresses it was
seen at: equal address counts are complete copies, a resend to the same
address is one block, and unequal counts make coverage unknown with a
limitation and ambiguous_numbering_group_count. sequence_count becomes
numbering_group_count, and region block positions become
first_file_block and last_file_block.

Review follow-ups in the same change:
- compare_artifacts compares a DfuSe element's alternate setting and
  target name, so bytes moved to another device memory are changed;
- the shared reader keeps no region bytes outside its map after close;
- one domain type replaces four identical decoder failure types;
- the UF2 decoder returns a typed limit instead of throwing on bytes
  beyond its admitted size, and caps retained regions, not runs;
- DfuSe caps elements with bytes only, names empty elements and
  element-less targets, drops the constant bVersion and bcdDFU fields,
  and says whether the CRC matches when DFUImageSize fits neither
  convention;
- classification evidence carries a suffix only when the probe saw the
  end, and root classification reads the tail only for a DfuSe prefix;
- the UF2 domain region owns family_name, and the root records the
  family registry revision; the table's MIT notice is preserved;
- docs pin the producer revisions (dfu-util ee6b393d and f9a537c9,
  MicroPython cb99ca98), give the MD5 structure's real meaning, and
  describe container contradictions in the MCP contract guide;
- tests and the esptool lane add the equal-count inputs and a block
  lost from one of them, accepted boundaries beside each refusal, a
  DfuSe root larger than the classification prefix, and CLI refusals.

* feat(artifacts): name the UF2 families that LibreTiny builds for

LibreTiny writes UF2 for Realtek, Beken and Bouffalo Lab modules. The
Microsoft registry revision REA already cites (cf3bd7f9) lists eight of
those families: RTL8710A, RTL8710B, RTL8720C, RTL8720D, BK7231U, BK7231N,
BK7251 and BL602. Their regions now carry those names. LN882H, which
that revision omits, keeps its familyID with no name.

* fix(artifacts): count DfuSe declarations without bytes instead of listing them

Empty DfuSe elements have no entry, so DFUSE_MAX_ELEMENTS does not cap
them, and the target walk kept a description string for each one. At 8
bytes per element, a file under the 128 MiB ceiling can declare about
16.7 million of them. The walk now counts every declaration without bytes
and builds only the first NAMED_ELEMENTS descriptions, which is all that
the limitation shows. The limitation text and empty_element_count do not
change. The guide now says that both DfuSe limitations name the first four.

On this host, decoding 4,000,000 empty elements (a 32 MB file) ran out of
memory under a 128 MB V8 heap before this change, and it peaked at 1.59 GB
RSS with the default heap. It now decodes in 114 ms with a 220 MB peak.

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-31 16:27:00 +08:00

298 lines
10 KiB
TypeScript

import { Readable } from "node:stream";
import { setImmediate } from "node:timers/promises";
import type { ArtifactFormatMetadata } from "../domain/artifactFormatMetadata.js";
import type { ArtifactContainerIntegrityPair } from "../domain/artifactIntegrity.js";
import type { Result } from "../domain/result.js";
import type {
RegionDecodeFailure,
RegionDecoder,
} from "../domain/firmware/regionDecoder.js";
import {
ArtifactReaderFailure,
copyArtifactEntry,
sameArtifactEntry,
type ArtifactEntry,
type ArtifactReader,
type ArtifactReaderTraversal,
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { ArtifactFileSource } from "./ArtifactFileSource.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
/** One extractable region: its inventory facts and the decoded bytes behind them. */
export interface DecodedRegion {
readonly path: string;
/** The file offset of the bytes when they are one contiguous file range. */
readonly byteOffset: number | null;
readonly formatMetadata: ArtifactFormatMetadata;
readonly bytes: Uint8Array;
}
/** What one format's decoder established about the whole file. */
export interface DecodedRegions {
readonly metadata: ArtifactFormatMetadata;
readonly regions: readonly DecodedRegion[];
/** Omitted when every byte was examined and no limitation applies. */
readonly coverage?: ArtifactReaderTraversal;
readonly containerIntegrity?: ArtifactContainerIntegrityPair;
}
/** How one firmware format turns admitted file bytes into regions. */
export interface RegionDecoding<Image> {
readonly label: string;
readonly keyPrefix: string;
createDecoder(
fileBytes: number,
): Result<RegionDecoder<Image>, RegionDecodeFailure>;
describe(image: Image): DecodedRegions;
}
const addressText = (address: number): string =>
`0x${address.toString(16).padStart(8, "0")}`;
/** The shared region file name: inclusive start and end as eight hex digits. */
export const regionFileName = (start: number, end: number): string =>
`${addressText(start)}-${addressText(end)}.bin`;
/**
* Decode a whole firmware file once, retain its region bytes for the reader's
* lifetime, and serve each region as an inventory entry.
*/
export class DecodedRegionArtifactReader<Image> implements ArtifactReader {
readonly #scope = new ArtifactResourceScope();
readonly #entries = new Map<string, ArtifactEntry>();
readonly #regions = new Map<string, Uint8Array>();
readonly #streams = new Set<Readable>();
readonly #source: ArtifactFileSource;
readonly #decoding: RegionDecoding<Image>;
#prepared: Promise<void> | undefined;
/** Whole-file facts only; the region map is the sole owner of region bytes. */
#described: Omit<DecodedRegions, "regions"> | undefined;
#closing = false;
constructor(
path: string,
readonly format: "intel-hex" | "srec" | "uf2" | "dfuse",
decoding: RegionDecoding<Image>,
admitted?: StableRegularFileDescriptor,
) {
this.#decoding = decoding;
this.#source = new ArtifactFileSource(path, decoding.label, admitted);
}
formatMetadata(): ArtifactFormatMetadata | undefined {
const metadata = this.#described?.metadata;
return metadata === undefined ? undefined : { ...metadata };
}
containerIntegrity(): ArtifactContainerIntegrityPair | undefined {
const integrity = this.#described?.containerIntegrity;
return integrity === undefined ? undefined : { ...integrity };
}
async prepareContainer(
expectedSha256: string,
signal?: AbortSignal,
): Promise<void> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
await this.#source.bindSha256(expectedSha256, signal);
});
}
async *entries(
signal?: AbortSignal,
options?: ArtifactTraversalOptions,
): AsyncIterable<ArtifactEntry> {
await this.#scope.run(async () => {
this.#assertOpen(signal);
this.#prepared ??= this.#prepare(signal);
await this.#prepared;
await this.#source.read(signal);
});
const coverage = this.#described?.coverage;
if (coverage !== undefined)
options?.onCoverage?.({
...coverage,
limitations: [...coverage.limitations],
});
let index = 0;
for (const entry of this.#entries.values()) {
if (index % 256 === 0) await setImmediate();
index += 1;
this.#assertOpen(signal);
yield copyArtifactEntry(entry);
}
}
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
return this.#scope.run(async () => {
this.#assertOpen(signal);
const stored = this.#entries.get(entry.adapterKey);
const bytes = this.#regions.get(entry.adapterKey);
if (
stored === undefined ||
bytes === undefined ||
!sameArtifactEntry(entry, stored)
)
throw new ArtifactReaderFailure(
"integrity",
`${this.#decoding.label} entry does not match its owning inventory`,
);
// Decoded bytes are retained, so only the carrier identity is rechecked.
await this.#source.read(signal);
this.#assertOpen(signal);
const output = Readable.from(this.#regionChunks(bytes, signal), {
objectMode: false,
});
const onAbort = (): void => {
output.destroy(
new ArtifactReaderFailure(
"cancelled",
`${this.#decoding.label} region read cancelled`,
{ cause: signal?.reason },
),
);
};
signal?.addEventListener("abort", onAbort, { once: true });
output.once("close", () => signal?.removeEventListener("abort", onAbort));
output.on("error", () => undefined);
this.#streams.add(output);
output.once("close", () => this.#streams.delete(output));
// Draining the scope must wait for the readable side, including an
// abandoned consumer and an in-flight asynchronous chunk producer.
void this.#scope
.run(
() =>
new Promise<void>((resolve) => {
output.once("close", () => resolve());
}),
)
.catch((cause: unknown) =>
output.destroy(
cause instanceof Error
? cause
: new ArtifactReaderFailure(
"unavailable",
`${this.#decoding.label} stream ownership failed`,
{ cause },
),
),
);
return output;
});
}
async *#regionChunks(
bytes: Uint8Array,
signal?: AbortSignal,
): AsyncIterable<Buffer> {
// Keep cached bytes isolated from consumers without copying a whole
// region for every open. Match descriptor reads' 64 KiB chunk size.
for (let offset = 0; offset < bytes.length; offset += 64 * 1024) {
await setImmediate();
this.#assertOpen(signal);
yield Buffer.from(bytes.subarray(offset, offset + 64 * 1024));
}
}
async close(): Promise<void> {
this.#closing = true;
for (const stream of this.#streams)
stream.destroy(
new ArtifactReaderFailure(
"unavailable",
`${this.#decoding.label} reader closed`,
),
);
await this.#scope.close();
await this.#source.close();
this.#entries.clear();
this.#regions.clear();
}
provenance(): readonly [] {
return [];
}
#assertOpen(signal?: AbortSignal): void {
assertArtifactReadActive(signal);
if (this.#closing)
throw new ArtifactReaderFailure(
"unavailable",
`${this.#decoding.label} reader is closed`,
);
}
async #prepare(signal?: AbortSignal): Promise<void> {
const described = this.#decoding.describe(await this.#decode(signal));
let index = 0;
for (const region of described.regions) {
if (index % 256 === 0) await setImmediate();
this.#assertOpen(signal);
const entry = {
path: region.path,
kind: "file" as const,
declaredSize: region.bytes.length,
compressedSize: null,
executable: false,
encrypted: false,
byteOffset: region.byteOffset,
declaredIntegrity: null,
unpacked: false,
limitations: [],
adapterKey: `${this.#decoding.keyPrefix}:${String(index)}`,
formatMetadata: region.formatMetadata,
} satisfies ArtifactEntry;
this.#entries.set(entry.adapterKey, entry);
this.#regions.set(entry.adapterKey, region.bytes);
index += 1;
}
const { regions: _regions, ...facts } = described;
this.#described = facts;
await this.#source.read(signal);
}
async #decode(signal?: AbortSignal): Promise<Image> {
const { handle, initial } = await this.#source.read(signal);
const decoder = this.#decoding.createDecoder(initial.size);
if (!decoder.ok) throw this.#failure(decoder.error);
// Reading only the admitted extent keeps decoded bytes within the arena.
if (initial.size > 0)
for await (const chunk of readFileHandleChunks(handle, {
end: initial.size - 1,
signal,
cancellationFailure: (reason) =>
new ArtifactReaderFailure(
"cancelled",
`${this.#decoding.label} read cancelled`,
{ cause: reason },
),
}) as AsyncIterable<Buffer>) {
this.#assertOpen(signal);
const written = decoder.value.write(chunk);
if (!written.ok) throw this.#failure(written.error);
}
const image = decoder.value.finish();
if (!image.ok) throw this.#failure(image.error);
return image.value;
}
#failure(failure: RegionDecodeFailure): ArtifactReaderFailure {
return failure.kind === "format"
? new ArtifactReaderFailure(
"format",
`${this.#decoding.label} is malformed: ${failure.message}`,
)
: new ArtifactReaderFailure(
"limit",
`${this.#decoding.label} exceeds a fixed limit: ${failure.message}`,
failure.boundary === undefined ? {} : { boundary: failure.boundary },
);
}
}