Files
reverse-engineering-anything/scripts/verify/firmware/uf2-esptool.mjs
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

285 lines
8.8 KiB
JavaScript

#!/usr/bin/env node
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { createVerifierCommands } from "../../lib/verify-commands.mjs";
import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs";
// Optional verification tool only; the UF2 reader starts no tool.
const python = process.env.REA_ESPTOOL_PYTHON ?? "python3";
const entrypoint = fileURLToPath(new URL("../../rea.mjs", import.meta.url));
const options = { maxBuffer: 16 * 1024 * 1024, timeout: 120_000 };
/** Every tool runs as an owned process tree that close() stops. */
const commands = await createVerifierCommands();
let workspace;
const path = (name) => join(workspace, name);
/** Deterministic input bytes; lengths include one that is not word-aligned. */
const input = (length, seed) =>
Buffer.from(Array.from({ length }, (_, index) => (index * 13 + seed) & 0xff));
// `otadata` matches the table's block count, so esptool's per-input numbering
// from 0 puts both inputs in one numbering group.
const FILES = {
bootloader: input(1000, 1),
table: input(3072, 2),
otadata: input(3072, 4),
app: input(501, 3),
};
const CASES = [
{
name: "esp32s3.uf2",
chip: "esp32s3",
layout: [
[0x0, "bootloader"],
[0x8000, "table"],
[0xd000, "otadata"],
[0x10000, "app"],
],
extra: [],
md5: true,
payload: 452,
},
{
name: "esp32s2-tinyuf2.uf2",
chip: "esp32s2",
layout: [
[0x1000, "bootloader"],
[0x8000, "table"],
],
extra: ["--chunk-size", "256", "--md5-disable"],
md5: false,
payload: 256,
},
];
const tool = async (args) => {
const { stdout, stderr } = await commands.exec(
python,
["-m", "esptool", ...args],
options,
);
return `${stdout}${stderr}`;
};
/** esptool's own family ID for a chip: the oracle REA's table must match. */
const familyOf = async (chip) => {
const { stdout } = await commands.exec(
python,
[
"-c",
"import sys, esptool.targets as t; print(t.CHIP_DEFS[sys.argv[1]].UF2_FAMILY_ID)",
chip,
],
options,
);
return Number(stdout.trim());
};
const rea = async (command, file) => {
const { stdout } = await commands.exec(
process.execPath,
[entrypoint, command, file, "--json"],
{
...options,
env: {
...process.env,
REA_LOG_LEVEL: "silent",
TMPDIR: workspace,
TMP: workspace,
TEMP: workspace,
},
},
);
return JSON.parse(stdout).normalized_result;
};
const familyDirectory = (familyId) =>
`family-0x${familyId.toString(16).padStart(8, "0")}`;
const hex32 = (value) => `0x${value.toString(16).padStart(8, "0")}`;
const blocksOf = (entry, name) => Math.ceil(FILES[name].length / entry.payload);
const inventoryOf = async (file) =>
(await rea("inspect-artifact", path(file))).substeps[0].evidence
.normalized_result;
const rootOf = (inventory) =>
inventory.occurrences.find((row) => row.logical_path === ".").format_metadata;
/** Write the inputs, merge each case with esptool, and concatenate the cases. */
const produce = async (v5) => {
for (const [name, bytes] of Object.entries(FILES))
await writeFile(path(`${name}.bin`), bytes);
for (const entry of CASES) {
await tool([
"--chip",
entry.chip,
v5 ? "merge-bin" : "merge_bin",
"--format",
"uf2",
...entry.extra,
"-o",
path(entry.name),
...entry.layout.flatMap(([address, name]) => [
hex32(address),
path(`${name}.bin`),
]),
]);
entry.familyId = await familyOf(entry.chip);
}
// The UF2 specification makes a multi-family file by concatenation.
await writeFile(
path("concatenated.uf2"),
Buffer.concat(
await Promise.all(CASES.map((entry) => readFile(path(entry.name)))),
),
);
};
/** Root facts a complete file from these cases must report. */
const expectedRoot = (included) => {
const blocks = included.flatMap((entry) =>
entry.layout.map(([, name]) => ({
group: `${entry.chip}:${String(blocksOf(entry, name))}`,
count: blocksOf(entry, name),
md5: entry.md5,
})),
);
const total = blocks.reduce((sum, { count }) => sum + count, 0);
// Inputs of one chip with equal block counts form one numbering group,
// and every input after the first in a group repeats its numbers.
const groups = new Map();
for (const { group, count } of blocks)
groups.set(group, [...(groups.get(group) ?? []), count]);
return {
block_count: total,
invalid_block_count: 0,
flash_block_count: total,
md5_block_count: blocks
.filter(({ md5 }) => md5)
.reduce((sum, { count }) => sum + count, 0),
family_count: included.length,
numbering_group_count: groups.size,
missing_block_count: 0,
ambiguous_numbering_group_count: 0,
repeated_block_count: [...groups.values()].reduce(
(sum, counts) => sum + (counts.length - 1) * (counts[0] ?? 0),
0,
),
duplicate_bytes: 0,
};
};
/** Every input must come back at its address under esptool's familyID. */
const check = async (file, included) => {
const inventory = await inventoryOf(file);
assert.equal(inventory.manifest.root_format, "uf2", file);
assert.equal(inventory.coverage.status, "complete", file);
const root = rootOf(inventory);
const expected = expectedRoot(included);
assert.deepEqual(
Object.fromEntries(Object.keys(expected).map((key) => [key, root[key]])),
expected,
file,
);
const inputs = included.flatMap((entry) =>
entry.layout.map(([address, name]) => ({
path: `${familyDirectory(entry.familyId)}/${hex32(address)}-${hex32(address + FILES[name].length - 1)}.bin`,
familyId: entry.familyId,
bytes: FILES[name],
})),
);
const regions = inventory.occurrences.filter(
(row) => row.format_metadata?.kind === "uf2-region",
);
assert.deepEqual(
regions.map((row) => row.logical_path).sort(),
inputs.map((region) => region.path).sort(),
file,
);
for (const region of regions)
assert.equal(
region.format_metadata.family_id,
inputs.find((row) => row.path === region.logical_path).familyId,
`${file} ${region.logical_path}`,
);
const extracted = await rea("extract-artifact", path(file));
assert.equal(extracted.containment_verified, true, file);
for (const region of inputs)
assert.equal(
Buffer.compare(
await readFile(join(extracted.output_root, region.path)),
region.bytes,
),
0,
`${file} ${region.path}`,
);
return {
file,
families: included.map((entry) => ({
chip: entry.chip,
family_id: hex32(entry.familyId),
family_name: regions.find(
(row) => row.format_metadata.family_id === entry.familyId,
).format_metadata.family_name,
})),
blocks: root.block_count,
regions: regions.length,
md5_blocks: root.md5_block_count,
};
};
/**
* Losing one block of one of two equal-count inputs must not read as
* complete: the other input still carries every block number.
*/
const checkLostBlock = async () => {
const [s3] = CASES;
const position = s3.layout.findIndex(([, name]) => name === "otadata");
const lostIndex =
s3.layout
.slice(0, position)
.reduce((sum, [, name]) => sum + blocksOf(s3, name), 0) + 1;
const whole = await readFile(path(s3.name));
await writeFile(
path("lost-block.uf2"),
Buffer.concat([
whole.subarray(0, lostIndex * 512),
whole.subarray((lostIndex + 1) * 512),
]),
);
const inventory = await inventoryOf("lost-block.uf2");
const root = rootOf(inventory);
assert.equal(inventory.coverage.status, "unknown", "lost-block.uf2");
assert.equal(root.ambiguous_numbering_group_count, 1, "lost-block.uf2");
assert.equal(root.missing_block_count, 0, "lost-block.uf2");
return {
file: "lost-block.uf2",
removed_block: lostIndex,
coverage: inventory.coverage.status,
ambiguous_numbering_groups: root.ambiguous_numbering_group_count,
};
};
await withVerifierCleanup(async () => {
const versionText = await tool(["version"]);
const version = /\bv?(\d+)\.(\d+)\.(\d+)\b/u.exec(versionText);
assert.ok(version, `Unrecognized esptool version: ${versionText}`);
workspace = await mkdtemp(join(tmpdir(), "rea-uf2-esptool-"));
await produce(Number(version[1]) >= 5);
const report = { esptool: version[0], cases: [] };
for (const entry of CASES)
report.cases.push(await check(entry.name, [entry]));
report.cases.push(await check("concatenated.uf2", CASES));
report.cases.push(await checkLostBlock());
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
}, [
() => commands.close(),
() =>
workspace === undefined
? undefined
: rm(workspace, { recursive: true, force: true }),
]);