Files
reverse-engineering-anything/tests/acceptance/artifactComparisonEspFlash.test.ts
T

209 lines
8.0 KiB
TypeScript

import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import {
ESP32C3_APP_SEGMENTS,
ESP32C3_BOOTLOADER_SEGMENTS,
espImage,
} from "../fixtures/espImage.js";
import {
TWO_OTA_PARTITIONS,
espFlashDump,
flashImage,
partitionTable,
} from "../../src/domain/firmware/espFlash.fixture.js";
import { artifactComparisonResultSchema } from "../../src/domain/artifactComparison.js";
import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js";
import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js";
import { parseEvidence, type Evidence } from "../../src/domain/evidence.js";
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const BOOTLOADER = {
at: 0,
bytes: espImage({ segments: ESP32C3_BOOTLOADER_SEGMENTS }),
};
/** A table without an MD5 whose first entry has these label bytes. */
const labelBytes = (bytes: readonly number[]): Buffer => {
const table = Buffer.from(
partitionTable(TWO_OTA_PARTITIONS, { md5: "absent" }),
);
table.fill(0, 12, 28);
Buffer.from(bytes).copy(table, 12);
return table;
};
/** Inspect files and compare their inventories through one MCP session. */
const comparisonSession = async () => {
const root = await createTestTempDirectory("rea-esp-flash-comparison-");
const { call } = await connectLocalToolsMcp();
const inspect = async (name: string, bytes: Buffer): Promise<Evidence> => {
const path = join(root, name);
await writeFile(path, bytes);
const opened = await call("open_binary", { path });
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
const response = await call("inspect_artifact", {});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const inventory = artifactInspectionResultSchema
.parse(parseEvidence(response.structuredContent).normalized_result)
.substeps.find(
({ operation }) => operation === "inventory_artifact",
)?.evidence;
if (inventory === undefined) throw new Error("Missing inventory Evidence");
return parseEvidence(inventory);
};
// Inline flash-dump inventories exceed the default MCP input element
// allowance, so compare the Evidence the session retained.
const compare = async (left: Evidence, right: Evidence) => {
const response = await call("compare_artifacts", {
left: { kind: "retained-evidence", evidence_id: left.evidence_id },
right: { kind: "retained-evidence", evidence_id: right.evidence_id },
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
return artifactComparisonResultSchema.parse(
parseEvidence(response.structuredContent).normalized_result,
);
};
return { inspect, compare };
};
const occurrence = (evidence: Evidence, path: string) => {
const found = artifactInventoryResultSchema
.parse(evidence.normalized_result)
.occurrences.find(({ logical_path }) => logical_path === path);
if (found === undefined) throw new Error(`Missing ${path}`);
return found;
};
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.
const readonly = TWO_OTA_PARTITIONS.map((part) =>
part.label === "nvs" ? { ...part, flags: 0x2 } : part,
);
const left = await inspect(
"flash.bin",
dump(partitionTable(TWO_OTA_PARTITIONS)),
);
const right = await inspect("readonly.bin", dump(partitionTable(readonly)));
const leftNvs = occurrence(left, "partitions/nvs.bin");
const rightNvs = occurrence(right, "partitions/nvs.bin");
expect(rightNvs.artifact_id).toBe(leftNvs.artifact_id);
expect(leftNvs.format_metadata).toMatchObject({ readonly: false });
expect(rightNvs.format_metadata).toMatchObject({ readonly: true });
const changed = await compare(left, right);
expect(
changed.changes.find(
({ logical_path }) => logical_path === "partitions/nvs.bin",
),
).toEqual({
classification: "changed",
logical_path: "partitions/nvs.bin",
dimensions: ["metadata"],
left_occurrence_id: leftNvs.occurrence_id,
right_occurrence_id: rightNvs.occurrence_id,
left_artifact_id: leftNvs.artifact_id,
right_artifact_id: rightNvs.artifact_id,
evidence_links: [left.evidence_id, right.evidence_id],
});
// Only the table's bytes and the declaration it gives nvs changed.
expect(changedPaths(changed)).toEqual([
"partition-table.bin",
"partitions/nvs.bin",
]);
// Labels that are not UTF-8 compare by their bytes.
const first = await inspect("label-a.bin", dump(labelBytes([0xff, 0x01])));
const second = await inspect("label-b.bin", dump(labelBytes([0xfe, 0x01])));
expect(occurrence(first, "partitions/0.bin").format_metadata).toMatchObject({
label: null,
label_hex: "ff01",
});
expect(changedPaths(await compare(first, second))).toEqual([
"partition-table.bin",
"partitions/0.bin",
]);
// Equal declarations leave every partition unchanged.
const same = await compare(
left,
await inspect("copy.bin", dump(partitionTable(TWO_OTA_PARTITIONS))),
);
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, the truncated ota_0
// and ota_1, which neither file holds, are the same on both sides.
expect(result.summary).toMatchObject({
unchanged: 7,
changed: 2,
unknown: 0,
});
// A full dump of the same parts holds ota_0 whole and ota_1 erased: those
// entries change, and no partition is added or removed.
const full = await inspect(
"full.bin",
flashImage(0x4_0000, [
BOOTLOADER,
{ at: 0x8000, bytes: partitionTable(TWO_OTA_PARTITIONS) },
{ at: 0x1_0000, bytes: app },
{ at: 0x2_0000, bytes: app },
]),
);
const longer = await compare(left, full);
expect(longer.status).toBe("changed");
expect(
longer.changes
.filter(({ logical_path }) => logical_path !== ".")
.map(({ logical_path, classification }) => [logical_path, classification])
.sort(),
).toEqual([
["partitions/ota_0.bin", "changed"],
["partitions/ota_1.bin", "changed"],
]);
expect(occurrence(left, "partitions/ota_1.bin").limitations).toEqual([
expect.stringMatching(/partitions\/ota_1\.bin.*outside.*65536/iu),
]);
});