fix(artifacts): keep ESP flash coverage complete for a dump cut short
An esptool merge-bin dump ends after the last part it wrote, usually inside its last partition. Such a partition made the layout's coverage unknown, so artifact comparison of two merge-bin dumps reported every unchanged partition as unknown. The partition table names every partition whatever the file's length, so a partition cut short or left out by the end of the file hides no entry. As the ESP image reader does for a cut-short segment, its entry holds the bytes the file has and an entry limitation gives the declared size; a partition with no bytes stays in partitions_outside_dump. Coverage stays complete unless undescribed bytes remain or no bootloader is located. An MCP compare_artifacts test of two dumps that end inside ota_0 now classifies every partition, and fails on the old rule.
This commit is contained in:
1 parent
31599c9e49
commit
ecbc1a7720
5 files changed
+96
-38
No files matched your search
+16
-9
@@ -119,10 +119,12 @@ partitions like any other, whose bytes are also `bootloader.bin` and
|
|||||||
`partition-table.bin`, and they are exempt from the rule that partitions
|
`partition-table.bin`, and they are exempt from the rule that partitions
|
||||||
follow the table, as in `gen_esp32part.py`.
|
follow the table, as in `gen_esp32part.py`.
|
||||||
|
|
||||||
A partition that the file holds only in part is the bytes the file holds,
|
A partition that the file holds only in part, as when an `esptool merge-bin`
|
||||||
with a limitation. One unreadable partition does not hide the others:
|
dump ends inside its last partition, is the bytes the file holds, with an
|
||||||
partitions outside the dump, before its first byte or overlapping another
|
entry limitation that gives the size the table declares. One unreadable
|
||||||
are each reported while the rest stay readable.
|
partition does not hide the others: partitions outside the dump, before its
|
||||||
|
first byte or overlapping another are each reported while the rest stay
|
||||||
|
readable.
|
||||||
|
|
||||||
### App partitions and the bootloader
|
### App partitions and the bootloader
|
||||||
|
|
||||||
@@ -173,8 +175,11 @@ to another partition when an app fails to load.
|
|||||||
Erased flash (`0xFF`) between regions is described. In a gap between
|
Erased flash (`0xFF`) between regions is described. In a gap between
|
||||||
regions, the first byte that is not erased starts an `undescribed` range to
|
regions, the first byte that is not erased starts an `undescribed` range to
|
||||||
the end of the gap, and makes coverage `unknown`; the rest of that range is
|
the end of the gap, and makes coverage `unknown`; the rest of that range is
|
||||||
not examined. Coverage is also `unknown` when a partition is truncated or
|
not examined. Coverage is also `unknown` when no bootloader is located. A
|
||||||
outside the dump, or when no bootloader is located.
|
partition cut short or left out by the end of the file does not make it
|
||||||
|
`unknown`: the table names every partition, so no entry is hidden, and the
|
||||||
|
entry or `partitions_outside_dump` says what the file lacks. Artifact
|
||||||
|
comparison of two such dumps therefore classifies every partition.
|
||||||
|
|
||||||
`extract_artifact` writes every entry as an ordinary file, `nvs_keys`
|
`extract_artifact` writes every entry as an ordinary file, `nvs_keys`
|
||||||
partitions included. On a device without flash encryption an `nvs_keys`
|
partitions included. On a device without flash encryption an `nvs_keys`
|
||||||
@@ -216,8 +221,9 @@ offsets, single images that are not scanned, overlaps, primary entries,
|
|||||||
truncation and partitions outside the dump, index names for unsafe labels,
|
truncation and partitions outside the dump, index names for unsafe labels,
|
||||||
repeated declarations, the otadata states and records, and each branch of
|
repeated declarations, the otadata states and records, and each branch of
|
||||||
the bootloader's selection. CLI and stdio MCP tests exercise the inputs and
|
the bootloader's selection. CLI and stdio MCP tests exercise the inputs and
|
||||||
their identity and extraction, and an MCP `compare_artifacts` test compares
|
their identity and extraction. MCP `compare_artifacts` tests compare
|
||||||
partition declarations, labels that are not UTF-8 included.
|
partition declarations, labels that are not UTF-8 included, and two dumps
|
||||||
|
that end inside their last partition, whose partitions are each classified.
|
||||||
|
|
||||||
The optional real lane builds its dumps with esptool and ESP-IDF's own tools:
|
The optional real lane builds its dumps with esptool and ESP-IDF's own tools:
|
||||||
|
|
||||||
@@ -250,7 +256,8 @@ otatool's own OTA logic runs unchanged. The lane then checks:
|
|||||||
- the relocated table, found by scanning and named with
|
- the relocated table, found by scanning and named with
|
||||||
`partition_table_offset`, and the dump written from `0x1000`, read with
|
`partition_table_offset`, and the dump written from `0x1000`, read with
|
||||||
`flash_offset` and, without it, left an ESP image;
|
`flash_offset` and, without it, left an ESP image;
|
||||||
- an app with a flipped byte beside valid partitions, and a truncated dump.
|
- an app with a flipped byte beside valid partitions, and a truncated dump,
|
||||||
|
read with complete coverage and its missing partitions outside the dump.
|
||||||
|
|
||||||
The lane ran on macOS arm64 with Node 24.22.1, Homebrew Clang and LLD 21.1.8
|
The lane ran on macOS arm64 with Node 24.22.1, Homebrew Clang and LLD 21.1.8
|
||||||
and ESP-IDF v5.5.2's `gen_esp32part.py`, `otatool.py` and `parttool.py`,
|
and ESP-IDF v5.5.2's `gen_esp32part.py`, `otatool.py` and `parttool.py`,
|
||||||
|
|||||||
@@ -357,7 +357,8 @@ const damaged = async (report) => {
|
|||||||
assert.equal(appFacts(rea, "ota_0").image.checksum.status, "valid");
|
assert.equal(appFacts(rea, "ota_0").image.checksum.status, "valid");
|
||||||
await writeFile(path("truncated.bin"), bytes.subarray(0, 0x4_0000));
|
await writeFile(path("truncated.bin"), bytes.subarray(0, 0x4_0000));
|
||||||
const cut = await inspect(path("truncated.bin"));
|
const cut = await inspect(path("truncated.bin"));
|
||||||
assert.equal(cut.inventory.coverage.status, "unknown");
|
// Partitions cut short or left out by the end of the file hide no entry.
|
||||||
|
assert.equal(cut.inventory.coverage.status, "complete");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
cut.root.partitions_outside_dump.map(({ label }) => label),
|
cut.root.partitions_outside_dump.map(({ label }) => label),
|
||||||
["ota_1", "keys"],
|
["ota_1", "keys"],
|
||||||
|
|||||||
@@ -166,7 +166,8 @@ describe("ESP flash layout coverage and selection", () => {
|
|||||||
it("keeps usable partitions beside a truncated and a missing one", async () => {
|
it("keeps usable partitions beside a truncated and a missing one", async () => {
|
||||||
const cut = DUMP.subarray(0, 0x2_8000);
|
const cut = DUMP.subarray(0, 0x2_8000);
|
||||||
const snapshot = await scanCanonicalArtifactInventory(await writeDump(cut));
|
const snapshot = await scanCanonicalArtifactInventory(await writeDump(cut));
|
||||||
expect(snapshot.coverage.status).toBe("unknown");
|
// The table names every partition, so the end of the file hides no entry.
|
||||||
|
expect(snapshot.coverage.status).toBe("complete");
|
||||||
const ota0 = snapshot.occurrences.find(
|
const ota0 = snapshot.occurrences.find(
|
||||||
({ logical_path }) => logical_path === "partitions/ota_0.bin",
|
({ logical_path }) => logical_path === "partitions/ota_0.bin",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -345,17 +345,13 @@ export const finishEspFlashLayout = (
|
|||||||
plan.otadata === undefined
|
plan.otadata === undefined
|
||||||
? null
|
? null
|
||||||
: otaFacts(plan, plan.otadata, reads.otadataRecords);
|
: otaFacts(plan, plan.otadata, reads.otadataRecords);
|
||||||
const incomplete = plan.input.table.partitions.some((part) => {
|
// The table names every partition, so one cut short or left out by the end
|
||||||
const region = plan.regions.find((item) => item.partition === part);
|
// of the file hides no entry: its entry says what the file lacks.
|
||||||
return region === undefined || region.available < part.size;
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
otadata,
|
otadata,
|
||||||
undescribed,
|
undescribed,
|
||||||
coverage:
|
coverage:
|
||||||
undescribed.length > 0 ||
|
undescribed.length > 0 || plan.input.bootloader === undefined
|
||||||
incomplete ||
|
|
||||||
plan.input.bootloader === undefined
|
|
||||||
? "unknown"
|
? "unknown"
|
||||||
: "complete",
|
: "complete",
|
||||||
limitations,
|
limitations,
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { join } from "node:path";
|
|||||||
import { expect, it } from "vitest";
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ESP32C3_APP_SEGMENTS,
|
||||||
ESP32C3_BOOTLOADER_SEGMENTS,
|
ESP32C3_BOOTLOADER_SEGMENTS,
|
||||||
espImage,
|
espImage,
|
||||||
} from "../../src/domain/firmware/espImage.fixture.js";
|
} from "../../src/domain/firmware/espImage.fixture.js";
|
||||||
import {
|
import {
|
||||||
TWO_OTA_PARTITIONS,
|
TWO_OTA_PARTITIONS,
|
||||||
espFlashDump,
|
espFlashDump,
|
||||||
|
flashImage,
|
||||||
partitionTable,
|
partitionTable,
|
||||||
} from "../../src/domain/firmware/espFlash.fixture.js";
|
} from "../../src/domain/firmware/espFlash.fixture.js";
|
||||||
import { artifactComparisonResultSchema } from "../../src/domain/artifactComparison.js";
|
import { artifactComparisonResultSchema } from "../../src/domain/artifactComparison.js";
|
||||||
@@ -33,12 +35,13 @@ const labelBytes = (bytes: readonly number[]): Buffer => {
|
|||||||
return table;
|
return table;
|
||||||
};
|
};
|
||||||
|
|
||||||
it("reports an ESP partition whose declaration alone changed", async () => {
|
/** Inspect files and compare their inventories through one MCP session. */
|
||||||
|
const comparisonSession = async () => {
|
||||||
const root = await createTestTempDirectory("rea-esp-flash-comparison-");
|
const root = await createTestTempDirectory("rea-esp-flash-comparison-");
|
||||||
const { call } = await connectLocalToolsMcp();
|
const { call } = await connectLocalToolsMcp();
|
||||||
const inspect = async (name: string, table: Buffer): Promise<Evidence> => {
|
const inspect = async (name: string, bytes: Buffer): Promise<Evidence> => {
|
||||||
const path = join(root, name);
|
const path = join(root, name);
|
||||||
await writeFile(path, espFlashDump({ bootloader: BOOTLOADER, table }));
|
await writeFile(path, bytes);
|
||||||
const opened = await call("open_binary", { path });
|
const opened = await call("open_binary", { path });
|
||||||
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
|
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
|
||||||
const response = await call("inspect_artifact", {});
|
const response = await call("inspect_artifact", {});
|
||||||
@@ -58,25 +61,37 @@ it("reports an ESP partition whose declaration alone changed", async () => {
|
|||||||
parseEvidence(response.structuredContent).normalized_result,
|
parseEvidence(response.structuredContent).normalized_result,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const occurrence = (evidence: Evidence, path: string) => {
|
return { inspect, compare };
|
||||||
const found = artifactInventoryResultSchema
|
};
|
||||||
.parse(evidence.normalized_result)
|
|
||||||
.occurrences.find(({ logical_path }) => logical_path === path);
|
const occurrence = (evidence: Evidence, path: string) => {
|
||||||
if (found === undefined) throw new Error(`Missing ${path}`);
|
const found = artifactInventoryResultSchema
|
||||||
return found;
|
.parse(evidence.normalized_result)
|
||||||
};
|
.occurrences.find(({ logical_path }) => logical_path === path);
|
||||||
const changedPaths = (result: { changes: { logical_path: string }[] }) =>
|
if (found === undefined) throw new Error(`Missing ${path}`);
|
||||||
result.changes
|
return found;
|
||||||
.map(({ logical_path }) => logical_path)
|
};
|
||||||
.filter((path) => path !== ".")
|
|
||||||
.sort();
|
const changedPaths = (result: { changes: { logical_path: string }[] }) =>
|
||||||
|
result.changes
|
||||||
|
.map(({ logical_path }) => logical_path)
|
||||||
|
.filter((path) => path !== ".")
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
it("reports an ESP partition whose declaration alone changed", async () => {
|
||||||
|
const { inspect, compare } = await comparisonSession();
|
||||||
|
const dump = (table: Buffer) =>
|
||||||
|
espFlashDump({ bootloader: BOOTLOADER, table });
|
||||||
|
|
||||||
// The same partition bytes, with nvs declared read-only.
|
// The same partition bytes, with nvs declared read-only.
|
||||||
const readonly = TWO_OTA_PARTITIONS.map((part) =>
|
const readonly = TWO_OTA_PARTITIONS.map((part) =>
|
||||||
part.label === "nvs" ? { ...part, flags: 0x2 } : part,
|
part.label === "nvs" ? { ...part, flags: 0x2 } : part,
|
||||||
);
|
);
|
||||||
const left = await inspect("flash.bin", partitionTable(TWO_OTA_PARTITIONS));
|
const left = await inspect(
|
||||||
const right = await inspect("readonly.bin", partitionTable(readonly));
|
"flash.bin",
|
||||||
|
dump(partitionTable(TWO_OTA_PARTITIONS)),
|
||||||
|
);
|
||||||
|
const right = await inspect("readonly.bin", dump(partitionTable(readonly)));
|
||||||
const leftNvs = occurrence(left, "partitions/nvs.bin");
|
const leftNvs = occurrence(left, "partitions/nvs.bin");
|
||||||
const rightNvs = occurrence(right, "partitions/nvs.bin");
|
const rightNvs = occurrence(right, "partitions/nvs.bin");
|
||||||
expect(rightNvs.artifact_id).toBe(leftNvs.artifact_id);
|
expect(rightNvs.artifact_id).toBe(leftNvs.artifact_id);
|
||||||
@@ -104,8 +119,8 @@ it("reports an ESP partition whose declaration alone changed", async () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Labels that are not UTF-8 compare by their bytes.
|
// Labels that are not UTF-8 compare by their bytes.
|
||||||
const first = await inspect("label-a.bin", labelBytes([0xff, 0x01]));
|
const first = await inspect("label-a.bin", dump(labelBytes([0xff, 0x01])));
|
||||||
const second = await inspect("label-b.bin", labelBytes([0xfe, 0x01]));
|
const second = await inspect("label-b.bin", dump(labelBytes([0xfe, 0x01])));
|
||||||
expect(occurrence(first, "partitions/0.bin").format_metadata).toMatchObject({
|
expect(occurrence(first, "partitions/0.bin").format_metadata).toMatchObject({
|
||||||
label: null,
|
label: null,
|
||||||
label_hex: "ff01",
|
label_hex: "ff01",
|
||||||
@@ -118,7 +133,45 @@ it("reports an ESP partition whose declaration alone changed", async () => {
|
|||||||
// Equal declarations leave every partition unchanged.
|
// Equal declarations leave every partition unchanged.
|
||||||
const same = await compare(
|
const same = await compare(
|
||||||
left,
|
left,
|
||||||
await inspect("copy.bin", partitionTable(TWO_OTA_PARTITIONS)),
|
await inspect("copy.bin", dump(partitionTable(TWO_OTA_PARTITIONS))),
|
||||||
);
|
);
|
||||||
expect(same.changes).toEqual([]);
|
expect(same.changes).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("classifies every partition of two dumps that end inside their last partition", async () => {
|
||||||
|
const { inspect, compare } = await comparisonSession();
|
||||||
|
const app = espImage({ segments: ESP32C3_APP_SEGMENTS });
|
||||||
|
// The same app with its DRAM segment loading 8 KiB higher.
|
||||||
|
const moved = espImage({
|
||||||
|
segments: ESP32C3_APP_SEGMENTS.map((segment, index) =>
|
||||||
|
index === 1 ? { ...segment, address: segment.address + 0x2000 } : segment,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
// Like esptool merge-bin: the file ends after the last part written, here
|
||||||
|
// inside ota_0, and ota_1 lies past its end.
|
||||||
|
const mergeBin = (factory: Buffer) =>
|
||||||
|
flashImage(0x2_0000 + app.length, [
|
||||||
|
BOOTLOADER,
|
||||||
|
{ at: 0x8000, bytes: partitionTable(TWO_OTA_PARTITIONS) },
|
||||||
|
{ at: 0x1_0000, bytes: factory },
|
||||||
|
{ at: 0x2_0000, bytes: app },
|
||||||
|
]);
|
||||||
|
const left = await inspect("left.bin", mergeBin(app));
|
||||||
|
const right = await inspect("right.bin", mergeBin(moved));
|
||||||
|
const inventory = artifactInventoryResultSchema.parse(left.normalized_result);
|
||||||
|
expect(inventory.coverage.status).toBe("complete");
|
||||||
|
expect(occurrence(left, "partitions/ota_0.bin").limitations).toEqual([
|
||||||
|
`partitions/ota_0.bin is truncated: the dump holds ${String(app.length)} of its 65536 bytes.`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await compare(left, right);
|
||||||
|
expect(result.status).toBe("changed");
|
||||||
|
expect(changedPaths(result)).toEqual(["partitions/factory.bin"]);
|
||||||
|
// The bootloader, the table, nvs, otadata, phy_init and the truncated
|
||||||
|
// ota_0 hold the same bytes on both sides.
|
||||||
|
expect(result.summary).toMatchObject({
|
||||||
|
unchanged: 6,
|
||||||
|
changed: 2,
|
||||||
|
unknown: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user