mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 17:59:08 +00:00
* fix(verifier): retain UF2 workspace until resource cleanup completes * fix(verifiers): retain firmware workspace cleanup ownership
415 lines
13 KiB
JavaScript
415 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { withVerifierWorkspace } from "../../lib/verifier-workspace.mjs";
|
|
import {
|
|
assertAgrees,
|
|
buildElf,
|
|
checkTools,
|
|
commands,
|
|
elf2image,
|
|
extract,
|
|
imageInfo,
|
|
inspect,
|
|
} from "./esp-image-tools.mjs";
|
|
import {
|
|
buildTable,
|
|
checkEspIdf,
|
|
decodeTable,
|
|
mergeBin,
|
|
otatool,
|
|
padTo,
|
|
readOtadata,
|
|
} from "./esp-flash-tools.mjs";
|
|
|
|
let workspace;
|
|
const path = (name) => join(workspace, name);
|
|
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
|
|
const DEFAULT_CSV = `# Name, Type, SubType, Offset, Size, Flags
|
|
nvs,data,nvs,0x9000,0x4000,
|
|
otadata,data,ota,0xd000,0x2000,
|
|
phy_init,data,phy,0xf000,0x1000,
|
|
factory,app,factory,0x10000,0x20000,
|
|
ota_0,app,ota_0,0x30000,0x20000,
|
|
ota_1,app,ota_1,0x50000,0x20000,
|
|
keys,data,nvs_keys,0x70000,0x1000,encrypted
|
|
`;
|
|
// Blank offsets: gen_esp32part places each partition after the moved table.
|
|
const RELOCATED_CSV = `# Name, Type, SubType, Offset, Size, Flags
|
|
nvs,data,nvs,,0x4000,
|
|
otadata,data,ota,,0x2000,
|
|
factory,app,factory,,0x20000,
|
|
`;
|
|
// ESP-IDF 5.3 entries over the bootloader region and the table sector;
|
|
// gen_esp32part fills their ranges from --primary-bootloader-offset.
|
|
const PRIMARY_CSV = `# Name, Type, SubType, Offset, Size, Flags
|
|
PrimaryBTLDR,bootloader,primary,N/A,N/A,
|
|
PrimaryPrtTable,partition_table,primary,N/A,N/A,
|
|
nvs,data,nvs,0x9000,0x4000,
|
|
factory,app,factory,0x10000,0x20000,
|
|
`;
|
|
|
|
/** Nested ESP image facts, shaped like a top-level inventory for assertAgrees. */
|
|
const nested = (facts) => ({
|
|
inventory: { manifest: { root_format: "esp-image" } },
|
|
root: facts.image,
|
|
segments: facts.segments.map((segment) => ({ format_metadata: segment })),
|
|
});
|
|
|
|
const appFacts = (rea, label) => {
|
|
const row = rea.inventory.occurrences.find(
|
|
({ format_metadata: metadata }) =>
|
|
metadata?.kind === "esp-flash-partition" && metadata.label === label,
|
|
);
|
|
return rea.root.apps.find(
|
|
({ partition_index: index }) => index === row?.format_metadata.index,
|
|
)?.facts;
|
|
};
|
|
|
|
/** Every declared partition agrees with ESP-IDF's decoding of the table REA extracted. */
|
|
const assertTableAgrees = async (name, rea, extraction) => {
|
|
const decoded = await decodeTable(
|
|
join(extraction.output_root, "partition-table.bin"),
|
|
);
|
|
const declared = [
|
|
...rea.inventory.occurrences
|
|
.filter(({ format_metadata: m }) => m?.kind === "esp-flash-partition")
|
|
.map(({ format_metadata: m }) => m),
|
|
...rea.root.partitions_outside_dump,
|
|
].sort((left, right) => left.index - right.index);
|
|
assert.deepEqual(
|
|
declared.map((p) => ({
|
|
name: p.label,
|
|
type: p.type_name,
|
|
subtype: p.subtype_name,
|
|
offset: p.flash_address,
|
|
size: p.size,
|
|
encrypted: p.encrypted,
|
|
readonly: p.readonly,
|
|
})),
|
|
decoded,
|
|
`${name} table`,
|
|
);
|
|
};
|
|
|
|
/** Each extracted file is the dump's bytes at its range; the source is unchanged. */
|
|
const assertExtraction = async (name, file, rea, flags = []) => {
|
|
const bytes = await readFile(file);
|
|
const extraction = await extract(file, workspace, flags);
|
|
assert.equal(extraction.containment_verified, true, name);
|
|
assert.equal(
|
|
extraction.manifest.root_sha256,
|
|
sha256(bytes),
|
|
`${name} source`,
|
|
);
|
|
const rows = rea.inventory.occurrences.filter(
|
|
({ logical_path }) => logical_path !== ".",
|
|
);
|
|
assert.equal(extraction.artifacts.length, rows.length, `${name} entries`);
|
|
for (const { logical_path: entry, source_location: range } of rows) {
|
|
const written = await readFile(join(extraction.output_root, entry));
|
|
assert.ok(
|
|
written.equals(bytes.subarray(range.offset, range.offset + range.length)),
|
|
`${name} ${entry}`,
|
|
);
|
|
}
|
|
assert.equal(sha256(await readFile(file)), sha256(bytes), `${name} source`);
|
|
return extraction;
|
|
};
|
|
|
|
const build = async () => {
|
|
const app = await buildElf("app.c", "app.ld", path("app.elf"));
|
|
const boot = await buildElf(
|
|
"bootloader.c",
|
|
"bootloader.ld",
|
|
path("boot.elf"),
|
|
);
|
|
const descriptor = ["--elf-sha256-offset", "0xb0"];
|
|
await elf2image("esp32c3", app, path("app.bin"), descriptor);
|
|
await elf2image("esp32c3", boot, path("boot.bin"));
|
|
// esptool converts the RISC-V ELF for any chip; these use ESP32 addresses.
|
|
const app32 = await buildElf(
|
|
"app.c",
|
|
"esp32-app-riscv.ld",
|
|
path("app32.elf"),
|
|
);
|
|
const boot32 = await buildElf(
|
|
"bootloader.c",
|
|
"esp32-bootloader.ld",
|
|
path("boot32.elf"),
|
|
);
|
|
await elf2image("esp32", app32, path("app32.bin"), descriptor);
|
|
await elf2image("esp32", boot32, path("boot32.bin"));
|
|
await writeFile(path("default.csv"), DEFAULT_CSV);
|
|
await writeFile(path("relocated.csv"), RELOCATED_CSV);
|
|
await writeFile(path("primary.csv"), PRIMARY_CSV);
|
|
await buildTable(path("default.csv"), path("default-table.bin"));
|
|
await buildTable(path("relocated.csv"), path("relocated-table.bin"), [
|
|
"--offset",
|
|
"0xa000",
|
|
]);
|
|
await buildTable(path("primary.csv"), path("primary-table.bin"), [
|
|
"--primary-bootloader-offset",
|
|
"0x0",
|
|
]);
|
|
};
|
|
|
|
const assertOtaAgrees = async (name, file, rea) => {
|
|
const records = await readOtadata(file, 0, 0x8000);
|
|
assert.deepEqual(
|
|
rea.root.otadata.records.map(({ ota_seq: seq, crc }) => ({ seq, crc })),
|
|
records,
|
|
`${name} otadata`,
|
|
);
|
|
};
|
|
|
|
const defaultLayout = async (report) => {
|
|
const file = path("c3.bin");
|
|
await mergeBin(
|
|
"esp32c3",
|
|
file,
|
|
[
|
|
{ at: 0x0, file: path("boot.bin") },
|
|
{ at: 0x8000, file: path("default-table.bin") },
|
|
{ at: 0x1_0000, file: path("app.bin") },
|
|
{ at: 0x3_0000, file: path("app.bin") },
|
|
],
|
|
padTo("1MB"),
|
|
);
|
|
await otatool(file, 0, ["switch_ota_partition", "--slot", "0"]);
|
|
const rea = await inspect(file);
|
|
assert.equal(rea.inventory.manifest.root_format, "esp-flash-image");
|
|
assert.equal(rea.inventory.coverage.status, "complete");
|
|
assert.deepEqual(rea.root.undescribed, []);
|
|
assert.equal(rea.root.partition_table.method, "default");
|
|
assert.equal(rea.root.partition_table.md5.status, "valid");
|
|
assertAgrees(
|
|
"c3 bootloader",
|
|
await imageInfo(path("boot.bin")),
|
|
nested(rea.root.bootloader.facts),
|
|
);
|
|
const appOracle = await imageInfo(path("app.bin"));
|
|
for (const label of ["factory", "ota_0"])
|
|
assertAgrees(`c3 ${label}`, appOracle, nested(appFacts(rea, label)));
|
|
assert.equal(appFacts(rea, "ota_1").status, "erased");
|
|
await assertOtaAgrees("c3 slot 0", file, rea);
|
|
assert.deepEqual(
|
|
[rea.root.boot_selection.slot, rea.root.boot_selection.basis],
|
|
[0, "otadata"],
|
|
);
|
|
const extraction = await assertExtraction("c3", file, rea);
|
|
await assertTableAgrees("c3", rea, extraction);
|
|
// A second switch writes the other record; the higher sequence selects ota_1.
|
|
await otatool(file, 0, ["switch_ota_partition", "--slot", "1"]);
|
|
const switched = await inspect(file);
|
|
await assertOtaAgrees("c3 slot 1", file, switched);
|
|
assert.equal(switched.root.boot_selection.slot, 1);
|
|
assert.equal(switched.root.boot_selection.running_partition, "unknown");
|
|
// Erased records leave the bootloader's factory fallback.
|
|
await otatool(file, 0, ["erase_otadata"]);
|
|
const erased = await inspect(file);
|
|
await assertOtaAgrees("c3 erased", file, erased);
|
|
const factory = erased.inventory.occurrences.find(
|
|
({ format_metadata: m }) => m?.label === "factory",
|
|
);
|
|
assert.deepEqual(
|
|
[
|
|
erased.root.boot_selection.basis,
|
|
erased.root.boot_selection.partition_index,
|
|
],
|
|
["factory-fallback", factory?.format_metadata.index],
|
|
);
|
|
report.cases.push({
|
|
name: "c3.bin",
|
|
partitions: extraction.artifacts.length - 2,
|
|
selected: [0, 1, "factory-fallback"],
|
|
});
|
|
};
|
|
|
|
const primaryEntries = async (report) => {
|
|
const file = path("primary.bin");
|
|
await mergeBin(
|
|
"esp32c3",
|
|
file,
|
|
[
|
|
{ at: 0x0, file: path("boot.bin") },
|
|
{ at: 0x8000, file: path("primary-table.bin") },
|
|
{ at: 0x1_0000, file: path("app.bin") },
|
|
],
|
|
padTo("1MB"),
|
|
);
|
|
const rea = await inspect(file);
|
|
assert.equal(rea.inventory.coverage.status, "complete");
|
|
assert.deepEqual(
|
|
rea.inventory.limitations.filter((text) =>
|
|
/overlaps|before the end of the partition table/u.test(text),
|
|
),
|
|
[],
|
|
"primary entries are not misplaced",
|
|
);
|
|
const extraction = await assertExtraction("primary", file, rea);
|
|
await assertTableAgrees("primary", rea, extraction);
|
|
// Without otadata the bootloader selects the factory index.
|
|
const factory = rea.inventory.occurrences.find(
|
|
({ format_metadata: m }) => m?.label === "factory",
|
|
);
|
|
assert.deepEqual(
|
|
[
|
|
rea.root.otadata,
|
|
rea.root.boot_selection.basis,
|
|
rea.root.boot_selection.partition_index,
|
|
],
|
|
[null, "no-otadata", factory?.format_metadata.index],
|
|
);
|
|
report.cases.push({
|
|
name: "primary.bin",
|
|
partitions: extraction.artifacts.length - 2,
|
|
});
|
|
};
|
|
|
|
const relocated = async (report) => {
|
|
const [, , factory] = await decodeTable(path("relocated-table.bin"));
|
|
const file = path("relocated.bin");
|
|
await mergeBin(
|
|
"esp32c3",
|
|
file,
|
|
[
|
|
{ at: 0x0, file: path("boot.bin") },
|
|
{ at: 0xa000, file: path("relocated-table.bin") },
|
|
{ at: factory.offset, file: path("app.bin") },
|
|
],
|
|
padTo("1MB"),
|
|
);
|
|
const scanned = await inspect(file);
|
|
assert.deepEqual(
|
|
[
|
|
scanned.root.partition_table.flash_address,
|
|
scanned.root.partition_table.method,
|
|
],
|
|
[0xa000, "scan"],
|
|
);
|
|
const caller = [
|
|
"--target-format",
|
|
"esp-flash-image",
|
|
"--partition-table-offset",
|
|
"0xa000",
|
|
];
|
|
const selected = await inspect(file, caller);
|
|
assert.equal(selected.root.partition_table.method, "caller");
|
|
await assertTableAgrees(
|
|
"relocated",
|
|
selected,
|
|
await assertExtraction("relocated", file, selected, caller),
|
|
);
|
|
report.cases.push({
|
|
name: "relocated.bin",
|
|
table: "0xa000",
|
|
methods: ["scan", "caller"],
|
|
});
|
|
};
|
|
|
|
const esp32Origin = async (report) => {
|
|
const parts = [
|
|
{ at: 0x1000, file: path("boot32.bin") },
|
|
{ at: 0x8000, file: path("default-table.bin") },
|
|
{ at: 0x1_0000, file: path("app32.bin") },
|
|
];
|
|
const whole = path("esp32.bin");
|
|
await mergeBin("esp32", whole, parts, padTo("1MB"));
|
|
const rea = await inspect(whole);
|
|
assert.deepEqual(
|
|
[rea.root.chip.target, rea.root.bootloader.flash_address],
|
|
["esp32", 0x1000],
|
|
);
|
|
assertAgrees(
|
|
"esp32 factory",
|
|
await imageInfo(path("app32.bin")),
|
|
nested(appFacts(rea, "factory")),
|
|
);
|
|
// esptool writes the same parts as a dump that starts at flash 0x1000.
|
|
const partial = path("esp32-from-0x1000.bin");
|
|
await mergeBin("esp32", partial, parts, ["--target-offset", "0x1000"]);
|
|
const guessed = await inspect(partial);
|
|
assert.equal(
|
|
guessed.inventory.manifest.root_format,
|
|
"esp-image",
|
|
"no origin, no layout",
|
|
);
|
|
// The table at file offset 0x7000 shows where the dump was read from.
|
|
assert.deepEqual(guessed.root.flash_dump, {
|
|
flash_offset: 0x1000,
|
|
table_file_offset: 0x7000,
|
|
});
|
|
const flags = [
|
|
"--target-format",
|
|
"esp-flash-image",
|
|
"--flash-offset",
|
|
"0x1000",
|
|
];
|
|
const origin = await inspect(partial, flags);
|
|
assert.deepEqual(origin.root.flash_offset, {
|
|
value: 0x1000,
|
|
source: "caller",
|
|
});
|
|
await assertExtraction("esp32 from 0x1000", partial, origin, flags);
|
|
report.cases.push({
|
|
name: "esp32-from-0x1000.bin",
|
|
automatic: "esp-image",
|
|
flash_offset: "0x1000",
|
|
});
|
|
};
|
|
|
|
const damaged = async (report) => {
|
|
const bytes = await readFile(path("c3.bin"));
|
|
const corrupt = Buffer.from(bytes);
|
|
// Flip a byte of the factory app's first segment data.
|
|
corrupt[0x1_0000 + 0x18 + 8] ^= 0x01;
|
|
await writeFile(path("corrupt.bin"), corrupt);
|
|
const rea = await inspect(path("corrupt.bin"));
|
|
assert.equal(appFacts(rea, "factory").image.checksum.status, "invalid");
|
|
assert.equal(appFacts(rea, "ota_0").image.checksum.status, "valid");
|
|
await writeFile(path("truncated.bin"), bytes.subarray(0, 0x4_0000));
|
|
const cut = await inspect(path("truncated.bin"));
|
|
// Partitions past the end of the file are entries holding no bytes.
|
|
assert.equal(cut.inventory.coverage.status, "complete");
|
|
assert.deepEqual(cut.root.partitions_outside_dump, []);
|
|
const empty = cut.inventory.occurrences
|
|
.filter(({ declared_size: size }) => size === 0)
|
|
.map(({ format_metadata: m }) => m?.label);
|
|
assert.deepEqual(empty, ["keys", "ota_1"]);
|
|
assert.ok(
|
|
cut.inventory.occurrences.some(
|
|
({ logical_path }) => logical_path === "partitions/factory.bin",
|
|
),
|
|
);
|
|
report.cases.push(
|
|
{ name: "corrupt.bin", factory: "invalid" },
|
|
{ name: "truncated.bin", without_bytes: empty },
|
|
);
|
|
};
|
|
|
|
const verify = async () => {
|
|
const tools = await checkTools("verify:esp-flash");
|
|
const espIdf = await checkEspIdf();
|
|
workspace = await mkdtemp(join(tmpdir(), "rea-esp-flash-proof-"));
|
|
const report = {
|
|
esptool: tools.esptoolVersion,
|
|
esp_idf: espIdf,
|
|
compiler: tools.compilerVersion,
|
|
cases: [],
|
|
};
|
|
await build();
|
|
await defaultLayout(report);
|
|
await primaryEntries(report);
|
|
await relocated(report);
|
|
await esp32Origin(report);
|
|
await damaged(report);
|
|
console.log(JSON.stringify(report, null, 2));
|
|
};
|
|
|
|
await withVerifierWorkspace(verify, commands, () => workspace);
|