Files
reverse-engineering-anything/scripts/verify/firmware/esp-image-tools.mjs
T
Entrpi a81b901871 test(artifacts): run ESP lane commands as owned process trees
The lane ran its tools through execFile. A timeout killed only the direct
child, so a linker or assembler that Clang or GCC had started could keep
running while the lane removed its workspace. Run every command through
createVerifierCommands, check the tools inside withVerifierCleanup, and
remove the workspace only after commands.close() has stopped every tree;
a failed close keeps the workspace.

Only a nonzero exit now counts as a refusal or an expected esptool
failure; timeouts and spawn failures propagate. Supervising each command
roughly doubles the lane's run time.
2026-10-28 01:14:22 +11:00

309 lines
10 KiB
JavaScript

import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { createVerifierCommands } from "../../lib/verify-commands.mjs";
// Optional verification tools only; the runtime reader starts no tool.
const compiler = process.env.REA_RISCV_CLANG ?? "clang";
const linker = process.env.REA_RISCV_LLD ?? "ld.lld";
const python = process.env.REA_ESPTOOL_PYTHON ?? "python3";
// Optional: an Xtensa ESP32 build when Espressif's GCC is supplied.
export const xtensa = process.env.REA_XTENSA_ESP32_GCC;
/**
* Every tool runs as an owned process tree, so a timeout or close also stops
* the children a compiler driver starts. Close it before removing outputs.
*/
export const commands = await createVerifierCommands();
export const options = { maxBuffer: 16 * 1024 * 1024, timeout: 120_000 };
export const execute = (command, args, settings = options) =>
commands.exec(command, args, settings);
/** The output of a command that exited nonzero; other failures propagate. */
export const exitOutput = (failure) => {
if (typeof failure?.code !== "number" || failure.code === 0) throw failure;
return `${failure.snapshot.stdout.text}${failure.snapshot.stderr.text}`;
};
export const source = (name) =>
fileURLToPath(
new URL(`../../../tests/conformance/esp/${name}`, import.meta.url),
);
const entrypoint = fileURLToPath(new URL("../../rea.mjs", import.meta.url));
/** The esptool release whose sources the reader's layout facts cite. */
const PINNED_ESPTOOL = "v5.6.0";
/** Run esptool or espsecure through the caller-supplied Python. */
export const tool = async (module, args) => {
const { stdout, stderr } = await execute(
python,
["-m", module, ...args],
options,
);
return `${stdout}${stderr}`;
};
/** Check one tool before any work; a failure names the variable to set. */
const prerequisite = async (needed, variable, check) => {
try {
return await check();
} catch (failure) {
throw new Error(
`verify:esp-images needs ${needed}; set ${variable}. ${failure.message}`,
{ cause: failure },
);
}
};
const firstLine = ({ stdout }) => stdout.split("\n")[0];
/** Set by checkTools: esptool v5 spells commands and options with hyphens. */
let v5;
export const spell = (v4, later) => {
assert.notEqual(v5, undefined, "checkTools must run first");
return v5 ? later : v4;
};
/** Check every tool before any work and report their versions. */
export const checkTools = async () => {
const compilerVersion = await prerequisite(
`a Clang with the RISC-V target (tried ${compiler})`,
"REA_RISCV_CLANG",
async () => {
const targets = await execute(compiler, ["--print-targets"], options);
assert.match(targets.stdout, /^\s*riscv32\b/mu, "No riscv32 target.");
return firstLine(await execute(compiler, ["--version"], options));
},
);
await prerequisite(`ld.lld (tried ${linker})`, "REA_RISCV_LLD", () =>
execute(linker, ["--version"], options),
);
const versionText = await prerequisite(
`a Python that runs esptool (tried ${python} -m esptool)`,
"REA_ESPTOOL_PYTHON",
() => tool("esptool", ["version"]),
);
const version = /\bv(\d+)\.(\d+)\.(\d+)\b/u.exec(versionText);
assert.ok(version, `Unrecognized esptool version: ${versionText}`);
v5 = Number(version[1]) >= 5;
await prerequisite(
`espsecure in the same Python (tried ${python} -m espsecure)`,
"REA_ESPTOOL_PYTHON",
async () => {
const help = await tool("espsecure", ["--help"]);
for (const command of [
spell("sign_data", "sign-data"),
spell("signature_info_v2", "signature-info-v2"),
])
assert.ok(help.includes(command), `espsecure has no ${command}.`);
},
);
if (version[0] !== PINNED_ESPTOOL)
console.warn(
`verify:esp-images: esptool ${version[0]} is not the pinned ${PINNED_ESPTOOL}, whose sources the reader's layout facts cite.`,
);
const xtensaVersion =
xtensa === undefined
? undefined
: await prerequisite(
`xtensa-esp32-elf-gcc (tried ${xtensa})`,
"REA_XTENSA_ESP32_GCC to Espressif's compiler, or unset it to skip that image",
async () => firstLine(await execute(xtensa, ["--version"], options)),
);
return { compilerVersion, esptoolVersion: version[0], xtensaVersion };
};
/** Build a RISC-V ELF from a conformance source; it is never run. */
export const buildElf = async (program, script, output) => {
await execute(
compiler,
[
"--target=riscv32-unknown-elf",
"-march=rv32imc",
"-mabi=ilp32",
`--ld-path=${linker}`,
"-nostdlib",
"-static",
"-fno-pic",
"-Os",
`-Wl,-T,${source(script)}`,
"-Wl,--build-id=none",
source(program),
"-o",
output,
],
options,
);
return output;
};
export const elf2image = (chip, elf, output, extra = []) =>
tool("esptool", [
"--chip",
chip,
"elf2image",
spell("--flash_mode", "--flash-mode"),
"dio",
spell("--flash_size", "--flash-size"),
"4MB",
...extra,
"-o",
output,
elf,
]);
/** Facts esptool image-info prints, read from its documented line formats. */
export const imageInfo = async (file, chip) => {
const text = await tool("esptool", [
...(chip === undefined ? [] : ["--chip", chip]),
...spell(["image_info", "--version", "2", file], ["image-info", file]),
]);
const field = (label) =>
new RegExp(`^${label}: (.+)$`, "mu").exec(text)?.[1]?.trim();
const checksum =
/^Checksum: 0x([0-9a-f]+) \((valid|invalid - calculated (?:0x)?([0-9a-f]+))\)$/mu.exec(
text,
);
const hash = /^Validation hash: ([0-9a-f]{64}) \((valid|invalid)\)$/mu.exec(
text,
);
return {
detected: field("Detected image type"),
entry: Number(field("Entry point")),
chipId: Number(field("Chip ID")?.split(" ")[0]),
segments: [
...text.matchAll(
/^\s*(\d+)\s+0x([0-9a-f]+)\s+0x([0-9a-f]+)\s+0x([0-9a-f]+)\s+\S.*$/gmu,
),
].map((row) => ({
index: Number(row[1]),
length: Number.parseInt(row[2], 16),
address: Number.parseInt(row[3], 16),
headerOffset: Number.parseInt(row[4], 16),
})),
checksum:
checksum === null
? undefined
: {
stored: Number.parseInt(checksum[1], 16),
valid: checksum[2] === "valid",
computed: Number.parseInt(checksum[3] ?? checksum[1], 16),
},
hash:
hash === null
? undefined
: { computed: hash[1], valid: hash[2] === "valid" },
project: field("Project name"),
appVersion: field("App version"),
elfSha256: field("ELF file SHA256"),
idfVer: field("ESP-IDF"),
secureVersion: field("Secure version"),
bootloaderVersion: field("Bootloader version"),
compileTime: field("Compile time"),
};
};
/** REA's complete inline inventory for one file through the built CLI. */
export const inspect = async (file) => {
const result = await execute(
process.execPath,
[entrypoint, "inspect-artifact", file, "--json"],
{ ...options, env: { ...process.env, REA_LOG_LEVEL: "silent" } },
).then(
({ stdout }) => ({ ok: true, stdout }),
(failure) => ({ ok: false, stdout: exitOutput(failure) }),
);
if (!result.ok) return { refused: result.stdout };
const inventory = JSON.parse(result.stdout).normalized_result.substeps[0]
.evidence.normalized_result;
const rows = inventory.occurrences.filter((row) => row.logical_path !== ".");
return {
inventory,
root: inventory.occurrences.find((row) => row.logical_path === ".")
.format_metadata,
segments: rows.filter(
(row) => row.format_metadata?.kind === "esp-image-segment",
),
signatures: rows.filter(
(row) => row.format_metadata?.kind === "esp-image-signature-block",
),
};
};
const oracleDescriptorKind = (oracle) => {
if (oracle.project !== undefined) return "app";
if (oracle.bootloaderVersion !== undefined) return "bootloader";
return null;
};
/** Extract every entry through the built CLI into a new directory in `root`. */
export const extract = async (file, root) => {
const { stdout } = await execute(
process.execPath,
[entrypoint, "extract-artifact", file, "--json"],
{
...options,
env: {
...process.env,
REA_LOG_LEVEL: "silent",
TMPDIR: root,
TMP: root,
TEMP: root,
},
},
);
return JSON.parse(stdout).normalized_result;
};
const assertDescriptorAgrees = (name, oracle, descriptor) => {
// Both directions, so a descriptor esptool does not print fails the lane.
assert.equal(
descriptor?.kind ?? null,
oracleDescriptorKind(oracle),
`${name} descriptor`,
);
if (oracle.project !== undefined) {
assert.equal(descriptor.project_name, oracle.project, name);
assert.equal(descriptor.version, oracle.appVersion, name);
assert.equal(descriptor.app_elf_sha256, oracle.elfSha256, name);
assert.equal(descriptor.idf_ver, oracle.idfVer, name);
assert.equal(String(descriptor.secure_version), oracle.secureVersion, name);
assert.equal(
`${descriptor.date} ${descriptor.time}`,
oracle.compileTime,
name,
);
}
if (oracle.bootloaderVersion !== undefined) {
assert.equal(String(descriptor.version), oracle.bootloaderVersion, name);
assert.equal(descriptor.idf_ver, oracle.idfVer, name);
assert.equal(descriptor.date_time, oracle.compileTime, name);
}
};
/** Every fact both tools report for an image esptool can load. */
export const assertAgrees = (name, oracle, rea) => {
assert.equal(rea.inventory.manifest.root_format, "esp-image", name);
assert.equal(rea.root.header.entry_addr, oracle.entry, `${name} entry`);
assert.equal(rea.root.header.chip_id, oracle.chipId, `${name} chip_id`);
assert.deepEqual(
rea.segments.map(({ format_metadata: segment }) => ({
index: segment.index,
length: segment.data_len,
address: segment.load_addr,
headerOffset: segment.header_offset,
})),
oracle.segments,
`${name} segments`,
);
const { checksum, appended_sha256: digest } = rea.root;
assert.equal(checksum.stored, oracle.checksum.stored, `${name} checksum`);
assert.equal(checksum.computed, oracle.checksum.computed, name);
assert.equal(checksum.status === "valid", oracle.checksum.valid, name);
if (oracle.hash === undefined)
assert.equal(digest.status, "not-appended", `${name} digest`);
else {
assert.equal(digest.computed, oracle.hash.computed, `${name} digest`);
assert.equal(digest.status === "valid", oracle.hash.valid, name);
}
assertDescriptorAgrees(name, oracle, rea.root.descriptor);
};