From b4ba96a9a5166b5a379f7bf7394b937ece4eb3a6 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Sat, 24 Oct 2026 07:45:22 -0700 Subject: [PATCH] feat(firmware): inspect flattened devicetree blobs (#7009) * feat(firmware): inspect flattened devicetree blobs Adds inspect-devicetree and inspect_devicetree for FDT v16/v17 blobs without target execution or external dependencies. Results include validated headers, reservations, a node/property tree with bounded raw previews, and derived standard values and selected phandle references. Parsing enforces file, tree, and decoded representation budgets and rejects malformed input without partial success. Summary delivery supports retained Evidence pagination, with tests covering parsing, service validation, CLI delivery, and MCP access after the input is removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016y53PdpyMAp9RSW65fatHe * fix(firmware): reject conflicting devicetree phandles and bound page summaries A node whose phandle and linux,phandle disagree no longer resolves under either value; the conflict is reported as a limitation. Finalization failures after a completed read retain the file-snapshot-hash partial observation, matching the Go provider. Devicetree view pages carry fixed-size context and counts instead of repeating summary strings, so a large bootargs no longer pushes every page over the MCP response budget. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016y53PdpyMAp9RSW65fatHe --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/cli.md | 12 + docs/devicetree.md | 102 +++++ docs/firmware-analysis.md | 7 + package.json | 1 + scripts/lib/catalog-builders.mjs | 7 + scripts/verify/firmware/devicetree.mjs | 88 ++++ src/application/CapabilityInventory.ts | 1 + .../binaryDiagnostics/DevicetreePort.ts | 20 + .../DevicetreeService.test.ts | 110 +++++ .../binaryDiagnostics/DevicetreeService.ts | 129 ++++++ src/cli/binaryDiagnosticsCommands.ts | 51 +++ src/cliCommandNames.ts | 1 + src/composition/binaryDiagnostics.ts | 5 + .../native/binaryDiagnosticsToolContracts.ts | 25 ++ src/contracts/toolEffects.test.ts | 1 + src/contracts/toolEffects.ts | 1 + src/domain/analysisView/analysisView.ts | 24 ++ src/domain/analysisView/devicetreeView.ts | 157 +++++++ src/domain/artifactOperationError.ts | 1 + src/domain/firmware/devicetree.ts | 98 +++++ src/domain/investigationProviders.ts | 6 + src/firmware/devicetree/Devicetree.fixture.ts | 86 ++++ .../devicetree/DevicetreeParser.test.ts | 268 ++++++++++++ src/firmware/devicetree/DevicetreeParser.ts | 402 ++++++++++++++++++ src/firmware/devicetree/DevicetreeProvider.ts | 227 ++++++++++ src/server/createServer.ts | 16 + src/server/registerDevicetreeTool.ts | 59 +++ tests/acceptance/devicetreeCli.test.ts | 100 +++++ .../tools/localToolContracts.test.ts | 35 +- .../firmware/devicetreeFinalization.test.ts | 79 ++++ tests/boundary/mcp/devicetreeMcp.test.ts | 201 +++++++++ 31 files changed, 2311 insertions(+), 9 deletions(-) create mode 100644 docs/devicetree.md create mode 100644 scripts/verify/firmware/devicetree.mjs create mode 100644 src/application/binaryDiagnostics/DevicetreePort.ts create mode 100644 src/application/binaryDiagnostics/DevicetreeService.test.ts create mode 100644 src/application/binaryDiagnostics/DevicetreeService.ts create mode 100644 src/domain/analysisView/devicetreeView.ts create mode 100644 src/domain/firmware/devicetree.ts create mode 100644 src/firmware/devicetree/Devicetree.fixture.ts create mode 100644 src/firmware/devicetree/DevicetreeParser.test.ts create mode 100644 src/firmware/devicetree/DevicetreeParser.ts create mode 100644 src/firmware/devicetree/DevicetreeProvider.ts create mode 100644 src/server/registerDevicetreeTool.ts create mode 100644 tests/acceptance/devicetreeCli.test.ts create mode 100644 tests/boundary/firmware/devicetreeFinalization.test.ts create mode 100644 tests/boundary/mcp/devicetreeMcp.test.ts diff --git a/docs/cli.md b/docs/cli.md index a3c33479d5..bbcb6b5b26 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1043,6 +1043,18 @@ admission; omit the flag to stream large JSON documents without materializing the complete formatted text. Exhausted token windows display an empty range at the total token count and have no next offset. +### `inspect-devicetree` + +Inspect an explicit local FDT v16/v17 blob without execution or an external tool. +Returns header, reservations, node/property tree, derived standard values and +selected phandle references. See [devicetree inspection](devicetree.md) for +limits and retained-view pagination. + +```bash +rea inspect-devicetree /artifacts/board.dtb --json +rea inspect-devicetree /artifacts/board.dtb --detail summary --evidence_output /artifacts/board-evidence.json --json +``` + ### PE TLS and load configuration ```sh diff --git a/docs/devicetree.md b/docs/devicetree.md new file mode 100644 index 0000000000..6cc8dae16a --- /dev/null +++ b/docs/devicetree.md @@ -0,0 +1,102 @@ +# Flattened devicetree inspection + +`inspect_devicetree` / `rea inspect-devicetree` reads a local DTB without an +engine, target execution or external tools. It supports FDT versions 16 and 17; +other versions return `unsupported_target`. Corrupt recognized blobs return +`invalid_request`, and exhausted budgets return `resource_constraint` without +partial success. + +```bash +rea inspect-devicetree /artifacts/board.dtb --json +rea inspect-devicetree /artifacts/board.dtb --detail summary --evidence_output /artifacts/board-evidence.json --json +``` + +The complete Evidence includes the artifact SHA-256, validated header fields, +64-bit memory reservations as exact hexadecimal strings, and a flat node tree +with explicit parent paths. Each property retains its file offset, byte length, +and up to 256 raw bytes as hex. `truncated` qualifies the preview only; parsing +coverage includes the entire declared tree. Version 16 has no declared structure +size, so `header.size_dt_struct` is null. + +Derived fields carry `confidence: derived`: standard ASCII string lists, scalar +phandles and cell counts, and `reg` tuples using the parent's `#address-cells` +and `#size-cells` (defaults 2 and 1). Zero-cell components are null (omitted), distinct from an encoded zero. Cell +counts above four, invalid counts and incomplete tuples remain undecoded with a +limitation. Addresses are bus-local; no `ranges` translation is attempted. +Invalid or ambiguous strings remain raw, rather than being guessed. + +The summary is labeled derived and includes root compatible/model, chosen +bootargs/stdout-path and aliases. An undecodable alias retains its name with a +null path; it is not silently treated as absent. Single-cell `interrupt-parent`, `remote-endpoint` and `msi-parent` +references resolve through phandle declarations. Missing or duplicate targets +have null target paths. A node whose `phandle` and `linux,phandle` disagree +adds a limitation, and references to either value stay unresolved. Binding-specific reference arrays and overlays are not +resolved; an empty reference list does not prove absence of hardware links. + +MCP `detail: summary` retains complete Evidence in the session. Follow its +`parent_evidence_id` with `inspect_analysis_view`: + +```json +{ + "source": { "kind": "retained-evidence", "evidence_id": "ev:" }, + "view": { + "kind": "devicetree", + "collection": "properties", + "path": "/serial@1000", + "offset": 0, + "limit": 32 + } +} +``` + +Collections are `nodes` (identity and property counts), `properties` (requires an +exact node path), `reservations`, and `references`. Pages use retained parsed +facts and do not reopen the file. Each page carries fixed-size context +(artifact, header, coverage and counts) rather than the summary strings, so a +large `bootargs` value does not repeat on every page. Source indices are scoped to the selected +collection and parent Evidence; property indices are scoped to their node. + +| Input budget | Default | Ceiling | +| ---------------- | ------: | ------: | +| `max_file_bytes` | 4 MiB | 16 MiB | +| `max_nodes` | 8,192 | 65,536 | +| `max_properties` | 32,768 | 262,144 | +| `max_depth` | 64 | 256 | + +Decoded names, paths, values and row overhead share a fixed 32 MiB representation +allowance, charged before expansion. It has no runtime override. + +The reservation count also uses `max_properties`. CLI flags use kebab case, +for example `--max-nodes 16384`. View pages contain at most 256 rows. + +## Verification + +Synthetic tests cover v16/v17, malformed headers/blocks/names/tokens, resource +limits, derived values, and CLI/MCP delivery including retained pages after +removing the input. The optional real lane requires **dtc 1.8.1**: + +```bash +npm run build:cached +node scripts/verify/firmware/devicetree.mjs +``` + +It compiles a DTS with reserved-memory, chosen, aliases and phandles, compares +REA's node paths against `dtc -I dtb -O dts`, and checks the derived report. +`REA_DTC_PATH` selects the verification executable; it is not runtime setup. + +The dtc 1.8.1 lane ran on macOS arm64 with Node 26.5.0. Its seven node paths +matched the decompiled tree. The report included `model: "REA DTB verification"`, +`bootargs: "console=ttyS0"`, alias `serial0 -> /serial@1000`, reservation +`0x80000000 + 0x1000`, and a resolved reference from `/consumer` to +`/serial@1000`. The deliberately minimal remote-endpoint fixture produces dtc +graph-binding warnings; this lane verifies FDT structure and selected property +derivations, not hardware-binding validity. + +A repeat run of that complete compile/decompile/CLI verification took 2.13 s +elapsed, 1.52 s user CPU and 0.27 s system CPU under `/usr/bin/time -l`. This is a +small fixture with warm filesystem caches, not a cold-start or large-board +benchmark. Peak process-family memory and whole-workflow token usage were not +measured; the sandbox denied the memory-reporting system query. No pre-feature +equivalent REA workflow exists for comparison. The pinned Node 24.22.1/npm +11.16.0 toolchain was unavailable; dependency installation used the local cache +with Node 26.5.0/npm 12.2.0 instead. diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index aabc077e8f..5ae5033cb0 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -285,3 +285,10 @@ REA_FIRMWARE_VERIFY_GHIDRA=1 npm run verify:firmware `scripts/rea.mjs` to verify the packaged CLI/MCP workflow. Real coverage here is generated gzip/USTAR/ext4 with a Linux x64 ELF child; SquashFS, UBIFS, proprietary firmware, other hosts and architectures remain unverified. + +## Inspect extracted devicetrees + +Use [`inspect_devicetree`](devicetree.md) / `rea inspect-devicetree` on an +extracted DTB for its node/property tree, reservations, hardware summary and +selected phandle relationships. FDT v16/v17 inspection is portable and requires +no external tool or disassembler. diff --git a/package.json b/package.json index d90e89758d..ae2e6ca247 100644 --- a/package.json +++ b/package.json @@ -233,6 +233,7 @@ "verify:application-workflows": "npm run build:cached && node scripts/verify/javascript/application-workflows.mjs", "verify:managed": "npm run build:cached && node scripts/verify/managed/conformance.mjs", "verify:firmware": "npm run build:cached && node scripts/verify/firmware/analysis.mjs", + "verify:devicetree": "npm run build:cached && node scripts/verify/firmware/devicetree.mjs", "fixtures:firmware": "node scripts/fixtures/firmware/generate.mjs", "verify:android": "npm run build:cached && node scripts/verify/android/analysis.mjs", "verify:jeb": "npm run build:cached && node scripts/verify-jeb.mjs", diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 81709b4700..550f00b5bf 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -268,6 +268,13 @@ export const providerCatalog = (sources) => { ({ name }) => name === "inspect_pe_imports", ), }, + { + identity: sources.artifactProviders.DEVICETREE_PROVIDER, + contracts: + sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter( + ({ name }) => name === "inspect_devicetree", + ), + }, { identity: sources.artifactProviders.PE_LOAD_CONFIG_PROVIDER, contracts: diff --git a/scripts/verify/firmware/devicetree.mjs b/scripts/verify/firmware/devicetree.mjs new file mode 100644 index 0000000000..c4ac2211c3 --- /dev/null +++ b/scripts/verify/firmware/devicetree.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Optional verification tool only; the runtime inspector never starts dtc. +const dtc = process.env.REA_DTC_PATH ?? "dtc"; +assert.match( + execFileSync(dtc, ["--version"], { encoding: "utf8" }), + /DTC 1\.8\.1\b/, +); +const root = mkdtempSync(join(tmpdir(), "rea-dtb-verification-")); +try { + const source = join(root, "board.dts"), + blob = join(root, "board.dtb"); + writeFileSync( + source, + `/dts-v1/; +/memreserve/ 0x80000000 0x1000; +/ { + compatible = "rea,verification"; model = "REA DTB verification"; + #address-cells = <1>; #size-cells = <1>; + chosen { bootargs = "console=ttyS0"; stdout-path = "serial0"; }; + aliases { serial0 = &serial; }; + reserved-memory { #address-cells = <1>; #size-cells = <1>; ranges; + region@80000000 { reg = <0x80000000 0x1000>; no-map; }; + }; + serial: serial@1000 { reg = <0x1000 0x100>; phandle = <1>; }; + consumer { remote-endpoint = <&serial>; }; +};\n`, + ); + execFileSync(dtc, ["-I", "dts", "-O", "dtb", "-o", blob, source]); + const decompiled = execFileSync(dtc, ["-I", "dtb", "-O", "dts", blob], { + encoding: "utf8", + }); + const evidence = JSON.parse( + execFileSync( + process.execPath, + ["scripts/rea.mjs", "inspect-devicetree", blob, "--json"], + { encoding: "utf8" }, + ), + ); + const report = evidence.normalized_result; + // This controlled fixture uses one opening/closing brace per node line. + const paths = [], + stack = []; + for (const line of decompiled.split("\n")) { + const begin = /^\s*([^\s{}]+)\s*\{\s*$/.exec(line); + if (begin) { + stack.push(begin[1]); + paths.push(stack.length === 1 ? "/" : `/${stack.slice(1).join("/")}`); + } + if (/^\s*};/.test(line)) stack.pop(); + } + assert.deepEqual( + report.nodes.map((n) => n.path), + paths, + ); + assert.equal(report.summary.bootargs, "console=ttyS0"); + assert.deepEqual(report.summary.aliases, [ + { name: "serial0", path: "/serial@1000" }, + ]); + assert.equal(report.references[0].target, "/serial@1000"); + assert.equal(report.reservations[0].address, "0x80000000"); + assert.equal( + report.nodes + .find((n) => n.path.endsWith("region@80000000")) + .properties.find((p) => p.name === "reg").derived.reg[0].size, + "0x1000", + ); + assert.equal(readFileSync(blob).readUInt32BE(0), 0xd00dfeed); + console.log( + JSON.stringify( + { + dtc: "1.8.1", + matched_node_paths: paths, + summary: report.summary, + reservations: report.reservations, + references: report.references, + }, + null, + 2, + ), + ); +} finally { + rmSync(root, { recursive: true, force: true }); +} diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index ed427f5d9c..d3eb6ad101 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -534,6 +534,7 @@ const workflowAvailabilityFor = ({ return { reason: "available", remediation: null }; if ( name === "inspect_elf_notes" || + name === "inspect_devicetree" || name === "inspect_kernel_module" || name === "inspect_pe_load_config" ) diff --git a/src/application/binaryDiagnostics/DevicetreePort.ts b/src/application/binaryDiagnostics/DevicetreePort.ts new file mode 100644 index 0000000000..9926b506c4 --- /dev/null +++ b/src/application/binaryDiagnostics/DevicetreePort.ts @@ -0,0 +1,20 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + InspectDevicetreeInput, + Devicetree, +} from "../../domain/firmware/devicetree.js"; +import type { Result } from "../../domain/result.js"; + +/** Stable read and devicetree-table decoding, independent of a disassembler session. */ +export interface DevicetreePort { + readonly identity: ProviderIdentity; + close?(): Promise; + inspect( + input: InspectDevicetreeInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/binaryDiagnostics/DevicetreeService.test.ts b/src/application/binaryDiagnostics/DevicetreeService.test.ts new file mode 100644 index 0000000000..51b9d11fde --- /dev/null +++ b/src/application/binaryDiagnostics/DevicetreeService.test.ts @@ -0,0 +1,110 @@ +import { expect, it } from "vitest"; +import { DevicetreeService } from "./DevicetreeService.js"; +import { DEVICETREE_PROVIDER } from "../../domain/investigationProviders.js"; +import type { Devicetree } from "../../domain/firmware/devicetree.js"; +import { ok } from "../../domain/result.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +const report = (): Devicetree => ({ + artifact: { path: "/board.dtb", bytes: 128, sha256: "a".repeat(64) }, + header: { + magic: 0xd00dfeed, + totalsize: 128, + off_dt_struct: 56, + off_dt_strings: 100, + off_mem_rsvmap: 40, + version: 17, + last_comp_version: 16, + boot_cpuid_phys: 0, + size_dt_strings: 6, + size_dt_struct: 44, + }, + reservations: [], + nodes: [ + { + path: "/", + parent: null, + offset: 56, + properties: [ + { + name: "model", + offset: 76, + bytes: 2, + hex: "6100", + truncated: false, + derived: { + confidence: "derived", + strings: ["a"], + cells: null, + reg: null, + }, + }, + ], + }, + ], + references: [], + summary: { + confidence: "derived", + compatible: null, + model: "a", + bootargs: null, + stdout_path: null, + aliases: [], + }, + coverage: { status: "complete", examined_nodes: 1, examined_properties: 1 }, + limitations: [], +}); + +it("validates caller paths before invoking the provider", async () => { + let calls = 0; + const service = new DevicetreeService({ + identity: DEVICETREE_PROVIDER, + inspect: () => { + calls++; + return Promise.resolve(ok(report())); + }, + }); + const result = await service.inspect({ path: "relative.dtb" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisInputError); + expect(calls).toBe(0); +}); +it("rejects cancellation at provider completion", async () => { + const controller = new AbortController(); + const service = new DevicetreeService({ + identity: DEVICETREE_PROVIDER, + inspect: () => { + controller.abort(); + return Promise.resolve(ok(report())); + }, + }); + const result = await service.inspect( + { path: "/board.dtb" }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError); +}); +it("rejects provider coverage and source-range inconsistencies", async () => { + for (const change of [ + (r: ReturnType) => { + r.coverage.examined_nodes++; + }, + (r: ReturnType) => { + r.nodes[0]!.properties[0]!.offset = r.artifact.bytes; + }, + ]) { + const r = report(); + change(r); + const service = new DevicetreeService({ + identity: DEVICETREE_PROVIDER, + inspect: () => Promise.resolve(ok(r)), + }); + const result = await service.inspect({ path: "/board.dtb" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); + } +}); diff --git a/src/application/binaryDiagnostics/DevicetreeService.ts b/src/application/binaryDiagnostics/DevicetreeService.ts new file mode 100644 index 0000000000..424719b6f3 --- /dev/null +++ b/src/application/binaryDiagnostics/DevicetreeService.ts @@ -0,0 +1,129 @@ +import { isAbsolute } from "node:path"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { DevicetreePort } from "./DevicetreePort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { + inspectDevicetreeInputSchema, + devicetreeSchema, + type Devicetree, +} from "../../domain/firmware/devicetree.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; + +const OPERATION = "inspect_devicetree"; + +/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */ +export class DevicetreeService { + constructor(readonly provider: DevicetreePort) {} + + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); + const input = inspectDevicetreeInputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { + cause: input.error, + }), + ); + if (!isAbsolute(input.data.path)) + return err( + new AnalysisInputError(OPERATION, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: "Expected an absolute filesystem path on this host.", + }, + ]), + ); + const result = await this.provider.inspect(input.data, options); + if (!result.ok) return result; + const report = devicetreeSchema.safeParse(result.value); + if ( + !report.success || + report.data.artifact.path !== input.data.path || + report.data.artifact.bytes > input.data.max_file_bytes || + report.data.nodes.length > input.data.max_nodes || + report.data.coverage.examined_properties > input.data.max_properties || + !validReport(report.data) + ) + return err( + new AnalysisOutputError( + OPERATION, + "devicetree provider returned malformed output or changed artifact identity.", + ), + ); + const value = report.data; + const locations = [ + { kind: "artifact-path" as const, path: value.artifact.path }, + ]; + return completeProviderEvidence( + createEvidence( + { + path: value.artifact.path, + format: "file", + sha256: value.artifact.sha256, + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.data, + result: value, + confidence: "observed", + limitations: value.limitations, + locations, + }, + ), + options?.signal, + ); + } +} + +/** Validate coverage and byte provenance at the provider boundary. */ +const validReport = (report: Devicetree): boolean => { + if ( + report.header.totalsize > report.artifact.bytes || + report.coverage.examined_nodes !== report.nodes.length + ) + return false; + let properties = 0; + const paths = new Set(); + for (const node of report.nodes) { + if ( + paths.has(node.path) || + (node.parent !== null && !paths.has(node.parent)) || + node.offset >= report.header.totalsize + ) + return false; + paths.add(node.path); + properties += node.properties.length; + for (const property of node.properties) { + if ( + property.offset > report.header.totalsize - property.bytes || + property.hex.length !== Math.min(property.bytes, 256) * 2 || + property.truncated !== property.bytes > 256 + ) + return false; + } + } + return ( + properties === report.coverage.examined_properties && + report.reservations.every((r) => r.offset <= report.header.totalsize - 16) + ); +}; diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 4c7679ba35..cb25ec4fe2 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -1,3 +1,4 @@ +import { inspectDevicetreeInputSchema } from "../domain/firmware/devicetree.js"; import { inspectPeLoadConfigInputSchema } from "../domain/native/peLoadConfig.js"; import { inspectByteEntropyInputSchema } from "../domain/native/byteEntropy.js"; import { z } from "incur"; @@ -7,6 +8,7 @@ import { createBinaryLayoutService, createElfNotesService, createAppleIpsService, + createDevicetreeService, createKernelModuleService, createPeLoadConfigService, createPeResourcesService, @@ -337,6 +339,55 @@ export const registerBinaryDiagnosticsCommands = ( ); }), }); + cli.command(CLI_COMMANDS.inspectDevicetree, { + description: "Inspect a flattened devicetree blob without executing it", + args: z.object({ + path: localPathStringSchema.describe("Local flattened devicetree image"), + }), + options: z.object({ + maxFileBytes: inspectDevicetreeInputSchema.shape.max_file_bytes.describe( + "Maximum FDT file size in bytes (default 4194304)", + ), + maxNodes: inspectDevicetreeInputSchema.shape.max_nodes.describe( + "Maximum number of device-tree nodes (default 8192)", + ), + maxProperties: inspectDevicetreeInputSchema.shape.max_properties.describe( + "Maximum number of device-tree properties (default 32768)", + ), + maxDepth: inspectDevicetreeInputSchema.shape.max_depth.describe( + "Maximum device-tree nesting depth (default 64)", + ), + ...analysisEvidenceDeliveryOptions, + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + const devicetree = createDevicetreeService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectDevicetree, + async () => + deliverCliAnalysisEvidence({ + run: () => + devicetree.inspect( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + max_nodes: options.maxNodes, + max_properties: options.maxProperties, + max_depth: options.maxDepth, + }, + { signal }, + ), + service: devicetree, + providerId: "rea-devicetree", + operation: "inspect_devicetree", + signal, + detail: options.detail, + evidenceOutput: options.evidence_output, + }), + ); + }), + }); cli.command(CLI_COMMANDS.inspectNativeExports, { description: "Inspect a PE or ELF image's export table without executing it", diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 101ef8eb71..f1e8cf0132 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -73,6 +73,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectPeCertificates: "inspect-pe-certificates", inspectPeImports: "inspect-pe-imports", inspectNativeExports: "inspect-native-exports", + inspectDevicetree: "inspect-devicetree", inspectElfNotes: "inspect-elf-notes", inspectAppleIps: "inspect-apple-ips", inspectKernelModule: "inspect-kernel-module", diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts index 933c04e253..85cff42e1c 100644 --- a/src/composition/binaryDiagnostics.ts +++ b/src/composition/binaryDiagnostics.ts @@ -22,6 +22,11 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j import { NativeExportsService } from "../application/binaryDiagnostics/NativeExportsService.js"; import { NativeExportsProvider } from "../native/exports/NativeExportsProvider.js"; +import { DevicetreeService } from "../application/binaryDiagnostics/DevicetreeService.js"; +import { DevicetreeProvider } from "../firmware/devicetree/DevicetreeProvider.js"; +export const createDevicetreeService = (): DevicetreeService => + new DevicetreeService(new DevicetreeProvider()); + export { pwntoolsHostSupported }; /** Portable PE inspection with no engine, installation or target acquisition. */ diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index 9ac2ff2c18..dc035e31aa 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -1,3 +1,8 @@ +import { + inspectDevicetreeInputSchema, + devicetreeSchema, +} from "../../domain/firmware/devicetree.js"; +import { devicetreeSummaryViewResultSchema } from "../../domain/analysisView/analysisView.js"; import { inspectPeLoadConfigInputSchema, peLoadConfigSchema, @@ -70,6 +75,26 @@ export const inspectByteEntropyRequestSchema = publicInputStrictObject( /** Offline binary diagnostics are independent of macOS process/UI providers. */ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ + { + name: "inspect_devicetree", + ...toolContractMetadata("inspect_devicetree"), + kind: "native-provider", + description: + "Inspect an explicit local flattened devicetree blob (FDT v16/v17) without execution or external tools. Returns validated header, memory reservations, full node/property tree with bounded raw hex, derived standard strings, cells and parent-sized reg tuples, aliases and selected single-cell phandle references with unresolved targets. Binding-specific arrays, overlays and runtime hardware remain unknown. Malformed structure or exhausted named budgets fails without partial success. Complete detail returns all Evidence inline; summary retains it for inspect_analysis_view kind devicetree, collections nodes, properties (requires exact path), reservations and references, with offset/limit pagination.", + inputSchema: inspectDevicetreeInputSchema.extend({ + detail: analysisDetailSchema, + }), + outputSchema: analysisDetailOutputSchemaOf( + devicetreeSchema, + devicetreeSummaryViewResultSchema, + ), + examples: [ + { + title: "Inspect a firmware hardware description", + input: { path: "/artifacts/board.dtb", detail: "summary" }, + }, + ], + }, { name: "inspect_pe_load_config", ...toolContractMetadata("inspect_pe_load_config"), diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index 14ccf2bf2e..91125c58d4 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -28,6 +28,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { inspect_byte_entropy: "states that it profiles the file without execution", inspect_pe_resources: "states that it does not execute the image", inspect_native_exports: "reads the export table without executing it", + inspect_devicetree: "states that it does not execute the image", inspect_elf_notes: "states that it does not execute the image", inspect_apple_ips: "states that it does not execute a target", inspect_kernel_module: "states that it does not execute the module", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 45afb62559..52bc29ea5e 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -262,6 +262,7 @@ export const TOOL_EFFECTS: Readonly> = { inspect_native_exports: evidence, inspect_elf_notes: evidence, inspect_apple_ips: evidence, + inspect_devicetree: evidence, inspect_kernel_module: evidence, inspect_pe_load_config: evidence, inspect_analysis_view: evidence, diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 4f8d983671..b6b3ea633b 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1,3 +1,10 @@ +import { + devicetreeViewRequestSchema, + devicetreeViewItemsSchema, + devicetreeSummarySchema, + devicetreePageContextSchema, + projectDevicetreeView, +} from "./devicetreeView.js"; import { apkSignatureViewRequestSchema, apkSignatureViewItemsSchema, @@ -351,6 +358,7 @@ const nativeViewSchema = z.strictObject({ /** Caller-selected projection of already completed analysis Evidence. */ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ + devicetreeViewRequestSchema, erlangBeamViewRequestSchema, pythonPycViewRequestSchema, nativeExportsViewRequestSchema, @@ -479,6 +487,7 @@ const viewResultBase = { parent_operation: z.enum([ "inspect_python_pyc", "inspect_erlang_beam", + "inspect_devicetree", "inspect_byte_entropy", "inspect_native_exports", "inspect_binary_layout", @@ -534,6 +543,11 @@ export const nativeExportsSummaryViewResultSchema = z.strictObject({ summary: nativeExportsSummarySchema, }); +export const devicetreeSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("inspect_devicetree"), + summary: devicetreeSummarySchema, +}); export const erlangBeamSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, parent_operation: z.literal("inspect_erlang_beam"), @@ -984,6 +998,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ }), z.discriminatedUnion("parent_operation", [ erlangBeamSummaryViewResultSchema, + devicetreeSummaryViewResultSchema, pythonPycSummaryViewResultSchema, nativeExportsSummaryViewResultSchema, byteEntropySummaryViewResultSchema, @@ -1015,6 +1030,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ nativeFunctionSummaryViewResultSchema, sourceToBundleSummaryViewResultSchema, ]), + z.strictObject({ + ...viewResultBase, + kind: z.literal("devicetree"), + parent_operation: z.literal("inspect_devicetree"), + view: devicetreeViewRequestSchema, + devicetree: devicetreePageContextSchema, + items: devicetreeViewItemsSchema, + }), erlangBeamTableViewResultSchema, pythonPycCodeObjectsViewResultSchema, z.strictObject({ @@ -1285,6 +1308,7 @@ const parentArtifactPath = (parent: AnalysisViewParent): string => { /** Named source owners for views that need no extra decoder or accumulator. */ const retainedViewProjectors = new Map([ ["inspect_erlang_beam", projectErlangBeamView], + ["inspect_devicetree", projectDevicetreeView], ["inspect_native_exports", projectNativeExportsView], ["inspect_python_pyc", projectPythonPycView], ["frida_session_status", projectFridaSessionStatusView], diff --git a/src/domain/analysisView/devicetreeView.ts b/src/domain/analysisView/devicetreeView.ts new file mode 100644 index 0000000000..c27e1a12ed --- /dev/null +++ b/src/domain/analysisView/devicetreeView.ts @@ -0,0 +1,157 @@ +import { z } from "zod"; +import { devicetreeSchema } from "../firmware/devicetree.js"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { err, ok, type Result } from "../result.js"; +import type { + AnalysisViewParent, + AnalysisViewRequest, + UnsignedAnalysisView, +} from "./analysisView.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { createAnalysisViewResultReader } from "./analysisViewParent.js"; +import { + boundedViewMessages, + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; + +export const devicetreeViewRequestSchema = z.strictObject({ + kind: z.literal("devicetree"), + collection: z.enum(["nodes", "properties", "reservations", "references"]), + path: z + .string() + .optional() + .describe( + "Exact node path; required for properties and optional for nodes", + ), + offset: z.int().nonnegative().default(0), + limit: z.int().positive().max(256).default(32), +}); +const sourceIndex = { source_index: z.int().nonnegative() }; +export const devicetreeViewItemsSchema = z.union([ + z.array( + devicetreeSchema.shape.nodes.element + .omit({ properties: true }) + .extend({ property_count: z.int().nonnegative(), ...sourceIndex }), + ), + z.array( + devicetreeSchema.shape.nodes.element.shape.properties.element.extend( + sourceIndex, + ), + ), + z.array(devicetreeSchema.shape.reservations.element.extend(sourceIndex)), + z.array(devicetreeSchema.shape.references.element.extend(sourceIndex)), +]); +const counts = { + reservation_count: z.int().nonnegative(), + reference_count: z.int().nonnegative(), + unresolved_reference_count: z.int().nonnegative(), +}; +export const devicetreeSummarySchema = devicetreeSchema + .pick({ artifact: true, header: true, summary: true, coverage: true }) + .extend(counts); +/** Fixed-size page context; unbounded summary strings stay in the summary view. */ +export const devicetreePageContextSchema = devicetreeSchema + .pick({ artifact: true, header: true, coverage: true }) + .extend({ ...counts, alias_count: z.int().nonnegative() }); +const readReport = createAnalysisViewResultReader(devicetreeSchema); +export const projectDevicetreeView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + const invalid = (message: string) => + err( + analysisViewInputError([ + { path: ["view"], reason: "invalid_value", message }, + ]), + ); + if (view.kind !== "summary" && view.kind !== "devicetree") + return invalid( + "Devicetree Evidence supports summary and devicetree views.", + ); + const parsed = readReport(parent); + if (!parsed.ok) return parsed; + const r = parsed.value; + const context = { + artifact: r.artifact, + header: r.header, + coverage: r.coverage, + reservation_count: r.reservations.length, + reference_count: r.references.length, + unresolved_reference_count: r.references.filter( + (ref) => ref.target === null, + ).length, + }; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_devicetree" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: { path: r.artifact.path, sha256: r.artifact.sha256 }, + limitations: boundedViewMessages( + [...parent.limitations, ...r.limitations], + parent.evidenceId, + "limitations", + ), + unknowns: [ + "Binding-specific references, address translation and runtime hardware state are not established.", + ], + }; + if (view.kind === "summary") + return ok({ + ...shared, + kind: "summary", + view, + summary: { ...context, summary: r.summary }, + coverage: completeWithinViewCoverage(r.nodes.length, r.nodes.length), + }); + if (view.collection === "properties" && view.path === undefined) + return invalid("A properties page requires an exact node path."); + if ( + view.path !== undefined && + view.collection !== "nodes" && + view.collection !== "properties" + ) + return invalid("path selects only nodes or properties."); + const nodeIndex = + view.path === undefined + ? -1 + : r.nodes.findIndex((n) => n.path === view.path); + const node = r.nodes[nodeIndex]; + if (view.collection === "properties" && node === undefined) + return invalid("The selected node path is absent from this Evidence."); + // Slice retained rows before allocating projections; exact node selection keeps its original index. + const page = (rows: readonly T[], originalIndex?: number) => ({ + items: rows.slice(view.offset, view.offset + view.limit).map((row, i) => ({ + ...row, + source_index: originalIndex ?? view.offset + i, + })), + total: rows.length, + }); + const selectedNodes = page( + view.path === undefined ? r.nodes : node === undefined ? [] : [node], + view.path === undefined ? undefined : nodeIndex, + ); + const selected = + view.collection === "nodes" + ? { + items: selectedNodes.items.map(({ properties, ...n }) => ({ + ...n, + property_count: properties.length, + })), + total: selectedNodes.total, + } + : view.collection === "properties" + ? page(node?.properties ?? []) + : view.collection === "reservations" + ? page(r.reservations) + : page(r.references); + const { items, total } = selected; + return ok({ + ...shared, + kind: "devicetree", + view, + devicetree: { ...context, alias_count: r.summary.aliases.length }, + items, + coverage: pageViewCoverage(view.offset, items.length, total), + }); +}; diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index ae6f7087ad..1f1ac73f3e 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -32,6 +32,7 @@ export class ArtifactOperationError extends AnalysisError { | "inspect_pe_resources" | "inspect_pe_certificates" | "inspect_elf_notes" + | "inspect_devicetree" | "inspect_kernel_module" | "inspect_erlang_beam" | "extract_artifact" diff --git a/src/domain/firmware/devicetree.ts b/src/domain/firmware/devicetree.ts new file mode 100644 index 0000000000..41b9b778ed --- /dev/null +++ b/src/domain/firmware/devicetree.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; +import { digestSchema } from "../digests.js"; +import { localPathStringSchema } from "../localPath.js"; +const uint = z.number().int().min(0).max(0xffffffff); +export const MAX_DEVICETREE_FILE_BYTES = 16777216; +export const inspectDevicetreeInputSchema = z.strictObject({ + path: localPathStringSchema, + max_file_bytes: z + .number() + .int() + .min(40) + .max(MAX_DEVICETREE_FILE_BYTES) + .default(4194304), + max_nodes: z.number().int().min(1).max(65536).default(8192), + max_properties: z.number().int().min(1).max(262144).default(32768), + max_depth: z.number().int().min(1).max(256).default(64), +}); +export const devicetreePropertySchema = z.strictObject({ + name: z.string(), + offset: uint, + bytes: uint, + hex: z + .string() + .regex(/^[0-9a-f]*$/) + .max(512), + truncated: z.boolean(), + derived: z.strictObject({ + confidence: z.literal("derived"), + strings: z.array(z.string()).nullable(), + cells: z.array(uint).nullable(), + reg: z + .array( + z.strictObject({ + address: z.string().nullable(), + size: z.string().nullable(), + }), + ) + .nullable(), + }), +}); +export const devicetreeNodeSchema = z.strictObject({ + path: z.string(), + parent: z.string().nullable(), + offset: uint, + properties: z.array(devicetreePropertySchema), +}); +export const devicetreeSchema = z.strictObject({ + artifact: z.strictObject({ + path: z.string(), + sha256: digestSchema, + bytes: uint, + }), + header: z.strictObject({ + magic: uint, + totalsize: uint, + off_dt_struct: uint, + off_dt_strings: uint, + off_mem_rsvmap: uint, + version: uint, + last_comp_version: uint, + boot_cpuid_phys: uint, + size_dt_strings: uint, + size_dt_struct: uint.nullable(), + }), + reservations: z.array( + z.strictObject({ offset: uint, address: z.string(), size: z.string() }), + ), + nodes: z.array(devicetreeNodeSchema), + references: z.array( + z.strictObject({ + source: z.string(), + property: z.string(), + phandle: uint, + target: z.string().nullable(), + confidence: z.literal("derived"), + }), + ), + summary: z.strictObject({ + confidence: z.literal("derived"), + compatible: z.array(z.string()).nullable(), + model: z.string().nullable(), + bootargs: z.string().nullable(), + stdout_path: z.string().nullable(), + aliases: z.array( + z.strictObject({ name: z.string(), path: z.string().nullable() }), + ), + }), + coverage: z.strictObject({ + status: z.literal("complete"), + examined_nodes: uint, + examined_properties: uint, + }), + limitations: z.array(z.string()), +}); +export type Devicetree = z.infer; +export type InspectDevicetreeInput = z.infer< + typeof inspectDevicetreeInputSchema +>; diff --git a/src/domain/investigationProviders.ts b/src/domain/investigationProviders.ts index 59918382a6..0917a06af5 100644 --- a/src/domain/investigationProviders.ts +++ b/src/domain/investigationProviders.ts @@ -41,6 +41,12 @@ export const PE_IMPORTS_PROVIDER = Object.freeze({ } as const); /** Provider identity for portable, execution-free ELF note-table decoding. */ +export const DEVICETREE_PROVIDER = Object.freeze({ + id: "rea-devicetree", + name: "REA flattened devicetree parser", + version: "1", +} as const); + export const ELF_NOTES_PROVIDER = Object.freeze({ id: "rea-elf-notes", name: "REA ELF note parser", diff --git a/src/firmware/devicetree/Devicetree.fixture.ts b/src/firmware/devicetree/Devicetree.fixture.ts new file mode 100644 index 0000000000..7b3aa02752 --- /dev/null +++ b/src/firmware/devicetree/Devicetree.fixture.ts @@ -0,0 +1,86 @@ +/** Small synthetic DTB writer shared by parser and public workflow tests. */ +export interface FixtureNode { + name: string; + properties?: Record; + children?: FixtureNode[]; +} +export const cells = (...values: number[]): Buffer => { + const b = Buffer.alloc(values.length * 4); + values.forEach((v, i) => b.writeUInt32BE(v, i * 4)); + return b; +}; +export const dtString = (value: string): Buffer => Buffer.from(`${value}\0`); +export const devicetreeFixture = ( + root: FixtureNode = { + name: "", + properties: { + compatible: dtString("test,board"), + model: dtString("Test board"), + "#address-cells": cells(1), + "#size-cells": cells(1), + }, + children: [ + { + name: "chosen", + properties: { + bootargs: dtString("console=ttyS0"), + "stdout-path": dtString("serial0"), + }, + }, + { name: "aliases", properties: { serial0: dtString("/serial@1000") } }, + { + name: "serial@1000", + properties: { + reg: cells(0x1000, 0x100), + phandle: cells(1), + "interrupt-parent": cells(99), + }, + }, + { name: "consumer", properties: { "remote-endpoint": cells(1) } }, + ], + }, + version = 17, +): Buffer => { + const names = new Map(), + strings: Buffer[] = [], + structure: Buffer[] = []; + let stringBytes = 0; + const padded = (b: Buffer) => + Buffer.concat([b, Buffer.alloc((4 - (b.length % 4)) % 4)]); + const walk = (node: FixtureNode) => { + structure.push(cells(1), padded(dtString(node.name))); + for (const [name, value] of Object.entries(node.properties ?? {})) { + let offset = names.get(name); + if (offset === undefined) { + offset = stringBytes; + names.set(name, offset); + const b = dtString(name); + strings.push(b); + stringBytes += b.length; + } + structure.push(cells(3, value.length, offset), padded(value)); + } + node.children?.forEach(walk); + structure.push(cells(2)); + }; + walk(root); + structure.push(cells(9)); + const body = Buffer.concat(structure), + namesBlock = Buffer.concat(strings); + const header = cells( + 0xd00dfeed, + 72 + body.length + namesBlock.length, + 72, + 72 + body.length, + 40, + version, + 16, + 0, + namesBlock.length, + body.length, + ); + const reservations = Buffer.alloc(32); + reservations.writeBigUInt64BE(0x80000000n, 0); + reservations.writeBigUInt64BE(0x1000n, 8); + return Buffer.concat([header, reservations, body, namesBlock]); +}; diff --git a/src/firmware/devicetree/DevicetreeParser.test.ts b/src/firmware/devicetree/DevicetreeParser.test.ts new file mode 100644 index 0000000000..74ac9f4656 --- /dev/null +++ b/src/firmware/devicetree/DevicetreeParser.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { + devicetreeSchema, + inspectDevicetreeInputSchema, +} from "../../domain/firmware/devicetree.js"; +import { DevicetreeLimitFailure, parseDevicetree } from "./DevicetreeParser.js"; +import { + cells, + devicetreeFixture, + type FixtureNode, +} from "./Devicetree.fixture.js"; +const parse = (b: Buffer, limits = {}) => + parseDevicetree( + b, + { + path: "/board.dtb", + bytes: b.length, + sha256: createHash("sha256").update(b).digest("hex"), + }, + inspectDevicetreeInputSchema.parse({ path: "/board.dtb", ...limits }), + ); +describe("flattened devicetree", () => { + it.each([16, 17])( + "decodes v%s with derived facts and unresolved references", + (version) => { + const report = devicetreeSchema.parse( + parse(devicetreeFixture(undefined, version)), + ); + expect(report.summary).toMatchObject({ + compatible: ["test,board"], + model: "Test board", + bootargs: "console=ttyS0", + aliases: [{ name: "serial0", path: "/serial@1000" }], + }); + expect(report.reservations[0]).toMatchObject({ + address: "0x80000000", + size: "0x1000", + }); + expect( + report.nodes + .find((n) => n.path === "/serial@1000") + ?.properties.find((p) => p.name === "reg")?.derived.reg, + ).toEqual([{ address: "0x1000", size: "0x100" }]); + expect(report.references.map((r) => r.target)).toEqual([ + null, + "/serial@1000", + ]); + }, + ); + it.each([ + ["truncated header", (b: Buffer) => b.subarray(0, 32)], + [ + "overlapping blocks", + (b: Buffer) => { + b.writeUInt32BE(72, 12); + return b; + }, + ], + [ + "unterminated node", + (b: Buffer) => { + b.fill(65, 76, b.readUInt32BE(12)); + return b; + }, + ], + [ + "bad string offset", + (b: Buffer) => { + b.writeUInt32BE(0xffffffff, 88); + return b; + }, + ], + [ + "misaligned structure", + (b: Buffer) => { + b.writeUInt32BE(73, 8); + return b; + }, + ], + [ + "missing end", + (b: Buffer) => { + b.writeUInt32BE(4, b.readUInt32BE(12) - 4); + return b; + }, + ], + ] as const)("rejects %s", (_name, mutate) => + expect(() => parse(mutate(devicetreeFixture()))).toThrow(), + ); + it("distinguishes unsupported versions", () => { + expect(() => parse(devicetreeFixture(undefined, 18))).toThrow( + expect.objectContaining({ kind: "unsupported" }), + ); + }); + it("bounds depth, nodes and properties", () => { + let root: FixtureNode = { name: "leaf" }; + for (let i = 0; i < 10; i++) root = { name: `n${i}`, children: [root] }; + expect(() => + parse(devicetreeFixture({ name: "", children: [root] }), { + max_depth: 4, + }), + ).toThrow("max_depth"); + expect(() => parse(devicetreeFixture(), { max_nodes: 2 })).toThrow( + "max_nodes", + ); + expect(() => parse(devicetreeFixture(), { max_properties: 2 })).toThrow( + "max_properties", + ); + }); + it("bounds raw previews and preserves ambiguous values", () => { + const report = parse( + devicetreeFixture({ + name: "", + properties: { opaque: Buffer.alloc(500), model: cells(1) }, + }), + ); + expect(report.nodes[0]?.properties[0]).toMatchObject({ + bytes: 500, + truncated: true, + hex: "00".repeat(256), + }); + expect(report.summary.model).toBeNull(); + }); +}); +it("keeps multi-cell addresses exact and supports zero size cells", () => { + const r = parse( + devicetreeFixture({ + name: "", + properties: { "#address-cells": cells(2), "#size-cells": cells(0) }, + children: [ + { name: "device", properties: { reg: cells(0xfedcba98, 0x76543210) } }, + ], + }), + ); + expect(r.nodes[1]?.properties[0]?.derived.reg).toEqual([ + { address: "0xfedcba9876543210", size: null }, + ]); +}); +it("does not guess malformed reg tuples or duplicate phandle targets", () => { + const r = parse( + devicetreeFixture({ + name: "", + children: [ + { name: "a", properties: { phandle: cells(1), reg: cells(1) } }, + { + name: "b", + properties: { phandle: cells(1), "interrupt-parent": cells(1) }, + }, + ], + }), + ); + expect(r.references[0]?.target).toBeNull(); + expect( + r.nodes[1]?.properties.find((p) => p.name === "reg")?.derived.reg, + ).toBeNull(); + expect(r.limitations.some((s) => s.includes("reg at /a"))).toBe(true); +}); +it("leaves conflicting phandle declarations unresolved", () => { + const r = parse( + devicetreeFixture({ + name: "", + children: [ + { + name: "same", + properties: { phandle: cells(3), "linux,phandle": cells(3) }, + }, + { + name: "split", + properties: { phandle: cells(1), "linux,phandle": cells(2) }, + }, + { + name: "consumer", + properties: { + "interrupt-parent": cells(1), + "msi-parent": cells(2), + "remote-endpoint": cells(3), + }, + }, + ], + }), + ); + expect(r.references.map((ref) => [ref.phandle, ref.target])).toEqual([ + [1, null], + [2, null], + [3, "/same"], + ]); + expect(r.limitations.filter((s) => s.includes("conflicts"))).toEqual([ + "phandle at /split conflicts with linux,phandle (0x1 vs 0x2); references to either value are unresolved.", + ]); +}); +it("rejects unterminated reservations and unknown tokens", () => { + const reservation = devicetreeFixture(); + reservation.fill(1, 56, 72); + expect(() => parse(reservation)).toThrow("reservation"); + const token = devicetreeFixture(); + token.writeUInt32BE(0x12345678, 72); + expect(() => parse(token)).toThrow("Unknown FDT token"); +}); +it("bounds expansion of repeated paths before allocation", () => { + const longName = "a".repeat(4096); + let node: FixtureNode = { name: longName }; + for (let i = 0; i < 100; i++) node = { name: longName, children: [node] }; + expect(() => + parse(devicetreeFixture({ name: "", children: [node] }), { + max_depth: 256, + }), + ).toThrow("decoded representation"); +}); +it.each(["compatible", "status"])( + "bounds NUL-heavy %s string-list expansion", + (name) => { + const bytes = devicetreeFixture({ + name: "", + properties: { [name]: Buffer.alloc(6 * 1024 * 1024) }, + }); + expect(() => parse(bytes, { max_file_bytes: 16777216 })).toThrow( + new DevicetreeLimitFailure("decoded_bytes"), + ); + }, +); +it("accounts for string values serialized again in the summary", () => { + const value = Buffer.alloc(6 * 1024 * 1024, 0x22); + value[value.length - 1] = 0; + const child = { name: "device", properties: { compatible: value } }; + const report = parse(devicetreeFixture({ name: "", children: [child] }), { + max_file_bytes: 16777216, + }); + expect(report.nodes[1]?.properties[0]?.derived.strings?.[0]?.length).toBe( + value.length - 1, + ); + expect(() => + parse(devicetreeFixture({ ...child, name: "" }), { + max_file_bytes: 16777216, + }), + ).toThrow(new DevicetreeLimitFailure("decoded_bytes")); +}); +it("retains undecodable alias identities and labels the summary derived", () => { + const report = parse( + devicetreeFixture({ + name: "", + children: [ + { + name: "aliases", + properties: { serial0: Buffer.from("unterminated") }, + }, + ], + }), + ); + expect(report.summary).toMatchObject({ + confidence: "derived", + aliases: [{ name: "serial0", path: null }], + }); +}); +it("bounds repeated reference paths before Evidence serialization", () => { + const bytes = devicetreeFixture({ + name: "", + children: [ + { name: "target".repeat(1400), properties: { phandle: cells(1) } }, + ...Array.from({ length: 2000 }, (_, i) => ({ + name: `consumer${i}`, + properties: { "interrupt-parent": cells(1) }, + })), + ], + }); + expect(bytes.length).toBeLessThan(100_000); + expect(() => parse(bytes)).toThrow("decoded representation"); +}); diff --git a/src/firmware/devicetree/DevicetreeParser.ts b/src/firmware/devicetree/DevicetreeParser.ts new file mode 100644 index 0000000000..e67c1da0bb --- /dev/null +++ b/src/firmware/devicetree/DevicetreeParser.ts @@ -0,0 +1,402 @@ +import type { + Devicetree, + InspectDevicetreeInput, +} from "../../domain/firmware/devicetree.js"; +import { BinaryFormatFailure } from "../../native/metadata/BinaryContainer.js"; + +export class DevicetreeLimitFailure extends Error { + constructor( + readonly limit: + | keyof Omit + | "decoded_bytes", + ) { + super( + limit === "decoded_bytes" + ? "Devicetree exceeds its fixed 32 MiB decoded representation allowance; select a smaller blob. This allowance has no runtime override." + : `Devicetree exceeds ${limit}; raise that caller-selected limit within its schema ceiling or select a smaller blob.`, + ); + } +} +const malformed = (message: string): never => { + throw new BinaryFormatFailure("malformed", message); +}; +const align = (n: number) => Math.ceil(n / 4) * 4; +const hex = (n: bigint) => `0x${n.toString(16)}`; + +/** Decode one DTB snapshot. Flat nodes retain explicit parent identity without recursive output. */ +export const parseDevicetree = ( + bytes: Buffer, + artifact: Devicetree["artifact"], + input: InspectDevicetreeInput, +): Devicetree => { + if (bytes.length > input.max_file_bytes) + throw new DevicetreeLimitFailure("max_file_bytes"); + if (bytes.length < 28) malformed("Truncated FDT header"); + const u32 = (offset: number) => bytes.readUInt32BE(offset); + if (u32(0) !== 0xd00dfeed) + throw new BinaryFormatFailure( + "unsupported", + "Not a flattened devicetree blob", + ); + const version = u32(20); + if (version !== 16 && version !== 17) + throw new BinaryFormatFailure( + "unsupported", + `FDT version ${version} is unsupported; supported versions are 16 and 17`, + ); + const headerBytes = version === 17 ? 40 : 36; + if (bytes.length < headerBytes) malformed("Truncated FDT header"); + const header: Devicetree["header"] = { + magic: u32(0), + totalsize: u32(4), + off_dt_struct: u32(8), + off_dt_strings: u32(12), + off_mem_rsvmap: u32(16), + version, + last_comp_version: u32(24), + boot_cpuid_phys: u32(28), + size_dt_strings: u32(32), + size_dt_struct: version === 17 ? u32(36) : null, + }; + const end = header.totalsize; + if ( + end > bytes.length || + end < headerBytes || + header.last_comp_version > version || + header.last_comp_version > 17 + ) + malformed("Invalid FDT header size or compatibility version"); + let decodedBytes = 0; + const admit = (size: number) => { + decodedBytes += size; + if (decodedBytes > 33554432) + throw new DevicetreeLimitFailure("decoded_bytes"); + }; + const ranges: { start: number; end: number }[] = [ + { start: 0, end: headerBytes }, + ]; + const range = (start: number, size: number) => { + if (size < 0 || start < headerBytes || start > end - size) + malformed("FDT block outside totalsize"); + const r = { start, end: start + size }; + if (ranges.some((other) => r.start < other.end && other.start < r.end)) + malformed("Overlapping FDT blocks"); + ranges.push(r); + }; + if (header.off_dt_struct % 4 || header.off_mem_rsvmap % 8) + malformed("Misaligned FDT block"); + range(header.off_dt_strings, header.size_dt_strings); + // v16 lacks size_dt_struct. Bound the scan by the next declared block or totalsize. + const structEnd = + header.size_dt_struct === null + ? Math.min( + end, + ...[header.off_dt_strings, header.off_mem_rsvmap].filter( + (n) => n > header.off_dt_struct, + ), + ) + : header.off_dt_struct + header.size_dt_struct; + range(header.off_dt_struct, structEnd - header.off_dt_struct); + const reservations: Devicetree["reservations"] = []; + let cursor = header.off_mem_rsvmap; + const reservationEnd = Math.min( + end, + ...[header.off_dt_struct, header.off_dt_strings].filter((n) => n >= cursor), + ); + if (ranges.some((r) => cursor >= r.start && cursor < r.end)) + malformed("Reservation block overlaps another FDT block"); + for (;;) { + if (cursor > reservationEnd - 16) + malformed("Unterminated or overlapping FDT reservation block"); + const address = bytes.readBigUInt64BE(cursor), + size = bytes.readBigUInt64BE(cursor + 8); + cursor += 16; + if (address === 0n && size === 0n) break; + if (reservations.length >= input.max_properties) + throw new DevicetreeLimitFailure("max_properties"); + admit(128); + reservations.push({ + offset: cursor - 16, + address: hex(address), + size: hex(size), + }); + } + range(header.off_mem_rsvmap, cursor - header.off_mem_rsvmap); + const text = ( + start: number, + stop: number, + ): { value: string; next: number } => { + const nul = bytes.indexOf(0, start); + if (nul < start || nul >= stop) return malformed("Unterminated FDT name"); + const raw = bytes.subarray(start, nul); + if (raw.some((c) => c < 0x20 || c > 0x7e)) malformed("Non-ASCII FDT name"); + admit(raw.length * 2); + return { value: raw.toString("ascii"), next: nul + 1 }; + }; + const names = new Map(); + const propertiesByPath = new Map< + string, + Map + >(); + const nodes: Devicetree["nodes"] = []; + const stack: { node: Devicetree["nodes"][number]; children: boolean }[] = []; + const paths = new Set(); + let properties = 0, + finished = false; + cursor = header.off_dt_struct; + const need = (n: number) => { + if (cursor > structEnd - n) + malformed("Truncated FDT structure token or property"); + }; + while (cursor < structEnd) { + need(4); + const offset = cursor, + token = u32(cursor); + cursor += 4; + if (token === 1) { + if (stack.length >= input.max_depth) + throw new DevicetreeLimitFailure("max_depth"); + if (nodes.length >= input.max_nodes) + throw new DevicetreeLimitFailure("max_nodes"); + const name = text(cursor, structEnd); + cursor = align(name.next); + need(0); + const parent = stack.at(-1); + if ( + name.value.includes("/") || + (!parent && (nodes.length > 0 || name.value !== "")) || + (parent && !name.value) + ) + malformed("Invalid FDT node name or multiple roots"); + admit((parent?.node.path.length ?? 0) * 4 + name.value.length * 2 + 128); + const path = parent + ? `${parent.node.path === "/" ? "" : parent.node.path}/${name.value}` + : "/"; + if (paths.has(path)) malformed("Duplicate FDT node path"); + paths.add(path); + const node = { + path, + parent: parent?.node.path ?? null, + offset, + properties: [], + } satisfies Devicetree["nodes"][number]; + if (parent) parent.children = true; + propertiesByPath.set(path, new Map()); + nodes.push(node); + stack.push({ node, children: false }); + } else if (token === 2) { + if (!stack.pop()) malformed("Unbalanced FDT_END_NODE"); + } else if (token === 3) { + const owner = stack.at(-1); + if (!owner || owner.children) + return malformed("FDT property outside node or after children"); + need(8); + const length = u32(cursor), + nameoff = u32(cursor + 4); + cursor += 8; + need(length); + if (++properties > input.max_properties) + throw new DevicetreeLimitFailure("max_properties"); + if (nameoff >= header.size_dt_strings) + malformed("FDT property string offset out of range"); + let name = names.get(nameoff); + if (name === undefined) { + name = text( + header.off_dt_strings + nameoff, + header.off_dt_strings + header.size_dt_strings, + ).value; + names.set(nameoff, name); + } + const propertyMap = propertiesByPath.get(owner.node.path); + if (!name || propertyMap?.has(name)) + malformed("Empty or duplicate FDT property name"); + admit(name.length * 2 + Math.min(length, 256) * 2 + 256); + const raw = bytes.subarray(cursor, cursor + length); + // Only binding-independent standard strings and single-cell declarations are decoded. + const stringProperty = + ["compatible", "model", "status", "bootargs", "stdout-path"].includes( + name, + ) || owner.node.path === "/aliases"; + if (stringProperty) admit(raw.length * 4); + let strings: string[] | null = null; + if ( + stringProperty && + raw.length > 0 && + raw.at(-1) === 0 && + raw.subarray(0, -1).every((c) => c === 0 || (c >= 0x20 && c <= 0x7e)) + ) { + // Count entries before split allocates its array and individual strings. + let entries = 0; + for (const byte of raw) if (byte === 0) entries++; + admit(entries * 32); + const repeatedInSummary = + (owner.node.path === "/" && name === "compatible") || + (entries === 1 && + ((owner.node.path === "/" && name === "model") || + (owner.node.path === "/chosen" && + ["bootargs", "stdout-path"].includes(name)) || + owner.node.path === "/aliases")); + // Summary values serialize again, including quotes, commas and escapes. + if (repeatedInSummary) admit(raw.length * 2 + entries * 3 + 2); + strings = raw.subarray(0, -1).toString("ascii").split("\0"); + } + const cells = + [ + "phandle", + "linux,phandle", + "#address-cells", + "#size-cells", + "interrupt-parent", + "remote-endpoint", + "msi-parent", + ].includes(name) && length === 4 + ? [raw.readUInt32BE(0)] + : null; + const property = { + name, + offset: cursor, + bytes: length, + hex: raw.subarray(0, 256).toString("hex"), + truncated: length > 256, + derived: { confidence: "derived" as const, strings, cells, reg: null }, + }; + owner.node.properties.push(property); + propertyMap?.set(name, property); + cursor = align(cursor + length); + need(0); + } else if (token === 9) { + if (stack.length || nodes.length === 0) malformed("Premature FDT_END"); + if (header.size_dt_struct !== null && cursor !== structEnd) + malformed("FDT_END does not terminate the declared structure block"); + finished = true; + break; + } else if (token !== 4) malformed(`Unknown FDT token ${token}`); + } + if (!finished) malformed("Missing FDT_END"); + const byPath = new Map(nodes.map((n) => [n.path, n])); + const prop = (path: string, name: string) => + propertiesByPath.get(path)?.get(name); + const singleString = (path: string, name: string) => { + const s = prop(path, name)?.derived.strings; + return s?.length === 1 ? (s[0] ?? null) : null; + }; + const handles = new Map(); + const limitations = [ + "Raw property previews are limited to 256 bytes; offsets and lengths identify complete values in the hashed artifact.", + "Phandle references cover only single-cell interrupt-parent, remote-endpoint and msi-parent; binding-specific arrays and overlays are not resolved.", + ]; + for (const node of nodes) { + const declared = ["phandle", "linux,phandle"].flatMap((name) => { + const handle = prop(node.path, name)?.derived.cells?.[0]; + return handle === undefined ? [] : [handle]; + }); + // Disagreeing declarations leave both values ambiguous rather than choosing one. + const conflict = declared.length === 2 && declared[0] !== declared[1]; + if (conflict) { + admit(node.path.length * 2 + 256); + limitations.push( + `phandle at ${node.path} conflicts with linux,phandle (0x${declared[0]?.toString(16)} vs 0x${declared[1]?.toString(16)}); references to either value are unresolved.`, + ); + } + for (const handle of declared) + if (handle !== 0 && handle !== 0xffffffff) { + const existing = handles.get(handle); + handles.set( + handle, + !conflict && (existing === undefined || existing === node.path) + ? node.path + : null, + ); + } + const reg = prop(node.path, "reg"); + if (!reg || node.parent === null) continue; + const count = (name: string, fallback: number) => { + const p = prop(node.parent ?? "", name); + return p === undefined ? fallback : p.derived.cells?.[0]; + }; + const addresses = count("#address-cells", 2), + sizes = count("#size-cells", 1); + if ( + addresses === undefined || + sizes === undefined || + addresses > 4 || + sizes > 4 || + addresses + sizes === 0 || + reg.bytes % ((addresses + sizes) * 4) + ) { + admit(node.path.length * 2 + 256); + limitations.push( + `reg at ${node.path} was not decoded: unsupported or malformed parent cell sizes or tuple length.`, + ); + continue; + } + const readCells = (start: number, n: number) => { + if (n === 0) return null; + let value = 0n; + for (let i = 0; i < n; i++) + value = (value << 32n) | BigInt(u32(start + i * 4)); + return hex(value); + }; + admit((reg.bytes / ((addresses + sizes) * 4)) * 160); + reg.derived.reg = []; + for ( + let p = reg.offset; + p < reg.offset + reg.bytes; + p += (addresses + sizes) * 4 + ) + reg.derived.reg.push({ + address: readCells(p, addresses), + size: readCells(p + addresses * 4, sizes), + }); + } + const references: Devicetree["references"] = []; + for (const node of nodes) + for (const property of node.properties) { + const phandle = property.derived.cells?.[0]; + if ( + ["interrupt-parent", "remote-endpoint", "msi-parent"].includes( + property.name, + ) && + phandle !== undefined + ) { + const target = handles.get(phandle) ?? null; + // Shared path strings expand again in serialized Evidence for every edge. + admit( + 256 + + (node.path.length + property.name.length + (target?.length ?? 0)) * + 2, + ); + references.push({ + source: node.path, + property: property.name, + phandle, + target, + confidence: "derived", + }); + } + } + return { + artifact, + header, + reservations, + nodes, + references, + summary: { + confidence: "derived", + compatible: prop("/", "compatible")?.derived.strings ?? null, + model: singleString("/", "model"), + bootargs: singleString("/chosen", "bootargs"), + stdout_path: singleString("/chosen", "stdout-path"), + aliases: (byPath.get("/aliases")?.properties ?? []).map((p) => ({ + name: p.name, + path: singleString("/aliases", p.name), + })), + }, + coverage: { + status: "complete", + examined_nodes: nodes.length, + examined_properties: properties, + }, + limitations, + }; +}; diff --git a/src/firmware/devicetree/DevicetreeProvider.ts b/src/firmware/devicetree/DevicetreeProvider.ts new file mode 100644 index 0000000000..104aff0f79 --- /dev/null +++ b/src/firmware/devicetree/DevicetreeProvider.ts @@ -0,0 +1,227 @@ +import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { + completedStableArtifact, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; +import { + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, + bindArtifactFileCleanup, + artifactFileCancellationCause, +} from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import type { DevicetreePort } from "../../application/binaryDiagnostics/DevicetreePort.js"; +import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; +import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../../domain/analysisErrorCore.js"; +import { + MAX_DEVICETREE_FILE_BYTES, + type InspectDevicetreeInput, +} from "../../domain/firmware/devicetree.js"; +import { fileSnapshotPartialObservationSchema } from "../../domain/fileSnapshotObservation.js"; +import { err, ok } from "../../domain/result.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { DEVICETREE_PROVIDER } from "../../domain/investigationProviders.js"; +import { BinaryFormatFailure } from "../../native/metadata/BinaryContainer.js"; +import { DevicetreeLimitFailure, parseDevicetree } from "./DevicetreeParser.js"; + +const OPERATION = "inspect_devicetree"; + +/** Portable parser over one bounded, identity-checked regular file. */ +export class DevicetreeProvider implements DevicetreePort { + readonly identity = DEVICETREE_PROVIDER; + readonly #resources = new ArtifactResourceScope(); + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + if ( + !(cause instanceof ArtifactReaderFailure) || + cause.cleanup === undefined + ) + throw cause; + const error = new ProviderCleanupError( + DEVICETREE_PROVIDER.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: OPERATION }, + ); + bindArtifactFileCleanup(cause, error); + throw error; + } + } + + async inspect(input: InspectDevicetreeInput, options?: ExecutionOptions) { + try { + return await this.#resources.run(async () => { + const snapshot = await readStableArtifact( + input.path, + input.max_file_bytes, + options?.signal, + undefined, + this.#resources, + ); + return ok( + parseDevicetree( + snapshot.bytes, + { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + input, + ), + ); + }); + } catch (cause) { + return err(devicetreeFailure(cause, input)); + } + } +} + +const devicetreeFailurePrimary = ( + facts: ArtifactFilePrimaryFacts, + input: InspectDevicetreeInput, +) => { + const { cause, origin } = facts; + if (cause instanceof DevicetreeLimitFailure) + return new AnalysisResourceConstraintError( + OPERATION, + cause.limit === "decoded_bytes" ? "memory" : "capacity", + cause.message, + { + limit: cause.limit, + selected: + cause.limit === "decoded_bytes" ? 33554432 : input[cause.limit], + }, + { cause, retryable: false, remediationAction: cause.message }, + ); + if (cause instanceof BinaryFormatFailure) + return cause.kind === "unsupported" + ? new AnalysisUnsupportedTargetError( + OPERATION, + input.path, + cause.message, + { cause }, + ) + : new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ]); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) + return new AnalysisArtifactChangedError( + OPERATION, + input.path, + cause.message, + { + cause, + }, + ); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { + if (cause.reason === "limit") { + return new AnalysisResourceConstraintError( + OPERATION, + cause.boundary ?? "memory", + cause.message, + { max_file_bytes: input.max_file_bytes }, + { + cause, + retryable: false, + remediationAction: + input.max_file_bytes < MAX_DEVICETREE_FILE_BYTES + ? "Raise max_file_bytes within its schema ceiling or select a smaller DTB." + : "Select a smaller DTB; the fixed file-byte ceiling has been reached.", + }, + ); + } + + if (cause.reason === "path" || cause.reason === "format") + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ]); + } + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { + cause, + }); + if (isSelectedRegularFileError(cause)) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected devicetree blob could not be read (${String(cause.code)}): ${input.path}.`, + }, + ]); + } + return new ArtifactOperationError( + OPERATION, + origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure + ? cause.reason + : "io", + undefined, + cause instanceof Error ? cause.message : "devicetree inspection failed.", + { cause }, + ); +}; + +const devicetreeFailure = (cause: unknown, input: InspectDevicetreeInput) => { + const primary = devicetreeFailurePrimary( + artifactFilePrimaryFacts(cause), + input, + ); + const snapshot = completedStableArtifact(cause); + if (snapshot !== undefined) + // The one stable read completed before finalization failed; keep its identity. + primary.retainPartialObservation( + fileSnapshotPartialObservationSchema.parse({ + kind: "file-snapshot-hash", + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }), + ); + if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) + return primary; + const cleanup = new ProviderCleanupError( + DEVICETREE_PROVIDER.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { operation: OPERATION, cause }, + ); + const error = analysisErrorWithCleanupFailure(primary, cleanup, OPERATION); + bindArtifactFileCleanup(cause, error); + return error; +}; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 11d7fdac91..890593ad04 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -1,3 +1,5 @@ +import type { DevicetreeService } from "../application/binaryDiagnostics/DevicetreeService.js"; +import { registerDevicetreeTool } from "./registerDevicetreeTool.js"; import { pcapCaptureHostSupported } from "../domain/pcap/pcapAnalysis.js"; import { createGpuCodeObjectsService } from "../composition/gpu.js"; import { registerGpuCodeObjectsTools } from "./registerGpuCodeObjectsTools.js"; @@ -25,6 +27,7 @@ import { captureProviderEnvironment } from "./providerEnvironment.js"; import { createElfNotesService, createAppleIpsService, + createDevicetreeService, createKernelModuleService, createPeLoadConfigService, createPeCertificatesService, @@ -478,6 +481,7 @@ export const createServer = ( options.il2cppMetadata ?? createIl2cppMetadataService(); const peResources = createPeResourcesService(); const nativeExports = createNativeExportsService(); + const devicetree = createDevicetreeService(); const elfNotes = createElfNotesService(); const appleIps = createAppleIpsService(); const kernelModule = createKernelModuleService(); @@ -593,6 +597,7 @@ export const createServer = ( gpuCodeObjects.close(), peResources.close(), nativeExports.close(), + devicetree.close(), elfNotes.close(), appleIps.close(), kernelModule.close(), @@ -665,6 +670,7 @@ export const createServer = ( il2cppMetadata, peResources, nativeExports, + devicetree, elfNotes, appleIps, kernelModule, @@ -906,6 +912,7 @@ const registerConfiguredAnalysisTools = ( il2cppMetadata, peResources, nativeExports, + devicetree, elfNotes, appleIps, kernelModule, @@ -971,6 +978,14 @@ const registerConfiguredAnalysisTools = ( evidenceById, withAdmittedAnalysis, ); + registerDevicetreeTool( + server, + devicetree, + toolLogger, + recordEvidence, + evidenceById, + withAdmittedAnalysis, + ); registerElfNotesTool( server, elfNotes, @@ -1168,6 +1183,7 @@ interface ServerToolContext extends ReturnType { readonly il2cppMetadata: Il2cppMetadataService; readonly peResources: PeResourcesService; readonly nativeExports: NativeExportsService; + readonly devicetree: DevicetreeService; readonly elfNotes: ElfNotesService; readonly appleIps: AppleIpsService; readonly kernelModule: KernelModuleService; diff --git a/src/server/registerDevicetreeTool.ts b/src/server/registerDevicetreeTool.ts new file mode 100644 index 0000000000..02749c6c22 --- /dev/null +++ b/src/server/registerDevicetreeTool.ts @@ -0,0 +1,59 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { DevicetreeService } from "../application/binaryDiagnostics/DevicetreeService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { applicationToolRegistrationOptions } from "./toolRegistrationOptions.js"; +import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; +import { retainedSummaryDelivery } from "./retainedSummaryDelivery.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; +import { err } from "../domain/result.js"; + +/** Register the explicit-file devicetree workflow and retain its complete Evidence. */ +export const registerDevicetreeTool = ( + server: EvidenceMcpServer, + service: DevicetreeService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + evidenceById?: EvidenceLookup, + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_devicetree"); + const delivery = retainedSummaryDelivery(new Set([contract.name]), { + recordEvidence, + evidenceById, + }); + const handler = createEvidenceToolHandler( + server, + async (rawInput, options) => { + const parsed = contract.inputSchema.safeParse(rawInput); + if (!parsed.success) + return err( + analysisInputErrorFromIssues( + contract.name, + parsed.error.issues, + rawInput, + { cause: parsed.error }, + ), + ); + const { detail, ...input } = parsed.data; + const parameters = { detail }; + const ready = delivery.preflight(contract.name, parameters); + if (!ready.ok) return ready; + const inspected = await service.inspect(input, options); + return inspected.ok + ? delivery.projectEvidence(contract.name, parameters, inspected.value) + : inspected; + }, + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + applicationToolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/tests/acceptance/devicetreeCli.test.ts b/tests/acceptance/devicetreeCli.test.ts new file mode 100644 index 0000000000..891e4dd00d --- /dev/null +++ b/tests/acceptance/devicetreeCli.test.ts @@ -0,0 +1,100 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { z } from "zod"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { devicetreeFixture } from "../../src/firmware/devicetree/Devicetree.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest("inspects a relative DTB and preserves the source", async ({ cli }) => { + const root = await createTestTempDirectory("rea-dtb-cli-"); + const path = join(root, "board.dtb"), + bytes = devicetreeFixture(); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: [ + "inspect-devicetree", + "board.dtb", + "--max-file-bytes", + "4096", + "--max-depth", + "8", + "--max-properties", + "64", + "--json", + ], + cwd: root, + }); + expect(result.exitCode, result.stderr).toBe(0); + const report = toolContract("inspect_devicetree").outputSchema.parse( + result.json, + ).normalized_result; + expect(report).toMatchObject({ + artifact: { path }, + summary: { model: "Test board" }, + coverage: { examined_nodes: 5 }, + }); + expect(await readFile(path)).toEqual(bytes); +}); +cliTest( + "retains complete DTB Evidence when delivering a summary", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-dtb-cli-summary-"); + const path = join(root, "board.dtb"), + output = join(root, "evidence.json"); + await writeFile(path, devicetreeFixture()); + const result = await cli.run({ + arguments: [ + "inspect-devicetree", + path, + "--detail", + "summary", + "--evidence_output", + output, + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + expect( + toolContract("inspect_devicetree").outputSchema.parse( + z.object({ summary: z.unknown() }).parse(result.json).summary, + ).normalized_result, + ).toMatchObject({ + kind: "summary", + summary: { unresolved_reference_count: 1 }, + }); + expect(await readFile(output, "utf8")).toContain('"nodes"'); + }, +); +for (const [name, build, flags, code] of [ + [ + "malformed", + () => devicetreeFixture().subarray(0, 30), + [], + "invalid_request", + ], + [ + "unsupported", + () => devicetreeFixture(undefined, 18), + [], + "unsupported_target", + ], + [ + "exhausted", + () => devicetreeFixture(), + ["--max-nodes", "1"], + "resource_constraint", + ], +] as const) { + cliTest(`reports ${name} DTB input`, async ({ cli }) => { + const root = await createTestTempDirectory("rea-dtb-cli-errors-"); + const path = join(root, "board.dtb"); + await writeFile(path, build()); + const result = await cli.run({ + arguments: ["inspect-devicetree", path, ...flags, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ code }); + }); +} diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index f1a7256de5..416aec30ad 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -13,6 +13,7 @@ import { itWithCaptureCapability as captureTest } from "../../boundary/process/p import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliCompileCacheEnvironment } from "../../support/cli/cliFixture.js"; const execute = promisify(execFile); const cli = fileURLToPath(new URL("../../../scripts/rea.mjs", import.meta.url)); @@ -124,13 +125,13 @@ it("keeps overall replay verification unknown when capture divergence is unknown const runCliExpectingInputError = async ( scenarioPath: string, + environment?: NodeJS.ProcessEnv, ): Promise => { - const failure = await execute(process.execPath, [ - cli, - "capture-process", - scenarioPath, - "--json", - ]).then( + const failure = await execute( + process.execPath, + [cli, "capture-process", scenarioPath, "--json"], + { env: environment }, + ).then( () => undefined, (cause: unknown) => cause, ); @@ -225,6 +226,7 @@ const pathRequests: readonly [string, Record][] = [ ["inspect_native_exports", { path: nul }], ["inspect_elf_notes", { path: nul }], ["inspect_apple_ips", { path: nul }], + ["inspect_devicetree", { path: nul }], ["inspect_kernel_module", { path: nul }], ["inspect_pe_load_config", { path: nul }], ["inspect_python_pyc", { path: nul }], @@ -338,6 +340,10 @@ captureTest( "rejects malformed process choices through CLI and MCP before launching the command", async () => { const root = await createTestTempDirectory("rea-local-contracts-"); + const environment = cliCompileCacheEnvironment( + process.env, + join(root, "node-compile-cache"), + ); const marker = join(root, "launched"); const scenario = { executable: process.execPath, @@ -384,7 +390,13 @@ captureTest( const path = join(root, `scenario-${String(start + offset)}.json`); await writeFile(path, JSON.stringify(request)); await expect( - execute(process.execPath, [cli, "capture-process", path, "--json"]), + execute( + process.execPath, + [cli, "capture-process", path, "--json"], + { + env: environment, + }, + ), JSON.stringify(options), ).rejects.toMatchObject({ code: 1, @@ -417,6 +429,7 @@ captureTest( ); const executableCliFailure = await runCliExpectingInputError( join(root, "scenario.json"), + environment, ); expect(executableCliFailure).toMatchObject({ code: "invalid_request", @@ -460,7 +473,9 @@ captureTest( working_directory: missingWorkingDirectory, }), ); - expect(await runCliExpectingInputError(cwdScenario)).toMatchObject({ + expect( + await runCliExpectingInputError(cwdScenario, environment), + ).toMatchObject({ code: "invalid_request", details: { operation: "capture_process_scenario", @@ -475,7 +490,9 @@ captureTest( }, }); }, - 60_000, + // 22 malformed requests plus two filesystem errors each start a fresh CLI. + // Cache compilation, but allow for startup at the bounded fanout of two. + 120_000, ); captureTest.each(["untruncated", "truncated"] as const)( diff --git a/tests/boundary/firmware/devicetreeFinalization.test.ts b/tests/boundary/firmware/devicetreeFinalization.test.ts new file mode 100644 index 0000000000..02656561cd --- /dev/null +++ b/tests/boundary/firmware/devicetreeFinalization.test.ts @@ -0,0 +1,79 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { DevicetreeProvider } from "../../../src/firmware/devicetree/DevicetreeProvider.js"; +import { DevicetreeService } from "../../../src/application/binaryDiagnostics/DevicetreeService.js"; +import { devicetreeFixture } from "../../../src/firmware/devicetree/Devicetree.fixture.js"; +import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each(["control", "close-failure", "close-cancel", "close-both"] as const)( + "retains completed devicetree acquisition through %s", + async (mode) => { + const root = await createTestTempDirectory("rea-dtb-finalization-"); + const path = join(root, "board.dtb"); + const bytes = devicetreeFixture(); + await writeFile(path, bytes); + const controller = new AbortController(); + const reason = { request: "cancel during devicetree close" }; + const closeFailure = Object.assign(new Error("controlled DTB close EIO"), { + code: "EIO", + }); + const original = OwnedFileHandle.prototype.close; + let owner: OwnedFileHandle | undefined; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementationOnce(async function (this: OwnedFileHandle) { + owner = this; + if (mode === "close-cancel" || mode === "close-both") + controller.abort(reason); + if (mode === "close-failure" || mode === "close-both") + throw closeFailure; + await original.call(this); + }); + const service = new DevicetreeService(new DevicetreeProvider()); + try { + const result = await service.inspect( + { path }, + { signal: controller.signal }, + ); + if (mode === "control") { + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + summary: { model: "Test board" }, + }); + } else { + if (result.ok) throw new Error("Expected finalization failure"); + const acquired = { + kind: "file-snapshot-hash", + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }; + expect(result.error.partialObservation).toEqual(acquired); + expect( + projectAnalysisError(result.error).details?.partial_observation, + ).toEqual(acquired); + const failedClose = mode === "close-failure" || mode === "close-both"; + expect(result.error.cleanupIncomplete).toBe(failedClose); + if (failedClose) expect(owner?.handle.fd).toBeGreaterThanOrEqual(0); + if (mode !== "close-failure") { + const primary = result.error.cleanupPrimary ?? result.error; + expect(primary).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + }); + } + close.mockRestore(); + await service.close(); + expect(result.error.cleanupIncomplete).toBe(false); + } + expect(owner?.handle.fd).toBe(-1); + } finally { + close.mockRestore(); + await service.close(); + } + }, +); diff --git a/tests/boundary/mcp/devicetreeMcp.test.ts b/tests/boundary/mcp/devicetreeMcp.test.ts new file mode 100644 index 0000000000..46bfdb9e93 --- /dev/null +++ b/tests/boundary/mcp/devicetreeMcp.test.ts @@ -0,0 +1,201 @@ +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; +import { unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { z } from "zod"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js"; +import { registerDevicetreeTool } from "../../../src/server/registerDevicetreeTool.js"; +import { registerAnalysisViewTool } from "../../../src/server/registerAnalysisViewTool.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { DevicetreeProvider } from "../../../src/firmware/devicetree/DevicetreeProvider.js"; +import { DevicetreeService } from "../../../src/application/binaryDiagnostics/DevicetreeService.js"; +import { silentLogger } from "../../../src/logger.js"; +import type { Evidence } from "../../../src/domain/evidence.js"; +import { onTestFinished } from "vitest"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { + devicetreeFixture, + dtString, +} from "../../../src/firmware/devicetree/Devicetree.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; + +const createDtbServer = ( + session: ReturnType, +) => { + const service = new DevicetreeService(new DevicetreeProvider()); + onTestFinished(() => service.close()); + const record = (evidence: Evidence) => session.recordEvidence(evidence); + const lookup = (id: string) => session.evidenceById(id); + const server = new EvidenceMcpServer( + { name: "dtb-boundary", version: "1" }, + {}, + record, + createToolResultDelivery(undefined), + ); + registerDevicetreeTool(server, service, silentLogger, record, lookup); + registerAnalysisViewTool(server, silentLogger, lookup, record); + return server; +}; + +mcpTest( + "inspects DTB and pages retained properties without reopening the file", + async ({ mcp }) => { + const session = createTestBinarySession(() => { + throw new Error("DTB inspection must not start an engine"); + }); + const client = await mcp.connect(createDtbServer(session)); + try { + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_devicetree", + ); + const root = await createTestTempDirectory("rea-dtb-mcp-"); + const path = join(root, "board.dtb"); + await writeFile(path, devicetreeFixture()); + const response = await client.callTool({ + name: "inspect_devicetree", + arguments: { path, detail: "summary" }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect( + schemas.ajv.validate(schemas.outputSchema, response.structuredContent), + JSON.stringify(schemas.ajv.errors), + ).toBe(true); + const result = toolContract("inspect_devicetree").outputSchema.parse( + response.structuredContent, + ).normalized_result; + if (!("parent_evidence_id" in result)) + throw new Error("Expected summary view"); + expect(result.summary.unresolved_reference_count).toBe(1); + await unlink(path); + const page = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: result.parent_evidence_id, + }, + view: { + kind: "devicetree", + collection: "properties", + path: "/serial@1000", + offset: 0, + limit: 1, + }, + }, + }); + expect(page.isError, JSON.stringify(page)).not.toBe(true); + expect(page.structuredContent).toMatchObject({ + normalized_result: { + kind: "devicetree", + items: [ + { + name: "reg", + source_index: 0, + derived: { + confidence: "derived", + reg: [{ address: "0x1000", size: "0x100" }], + }, + }, + ], + }, + }); + } finally { + await session.close(); + } + }, + // Includes SDK schema conversion, dialect validation and two public calls. + 60_000, +); +mcpTest( + "pages nodes without repeating a large bootargs summary", + async ({ mcp }) => { + const session = createTestBinarySession(() => { + throw new Error("DTB inspection must not start an engine"); + }); + const client = await mcp.connect(createDtbServer(session)); + try { + const root = await createTestTempDirectory("rea-dtb-mcp-bootargs-"); + const path = join(root, "board.dtb"); + // Quotes double under JSON escaping; the value must not ride on every page. + const bootargs = '"'.repeat(3 * 1024 * 1024); + await writeFile( + path, + devicetreeFixture({ + name: "", + children: [ + { name: "chosen", properties: { bootargs: dtString(bootargs) } }, + ], + }), + ); + const response = await client.callTool({ + name: "inspect_devicetree", + arguments: { path, detail: "summary", max_file_bytes: 16777216 }, + }); + // The complete summary exceeds the response budget; its retained parent + // must still page, so pages cannot repeat the summary strings. + const { error } = parseMcpToolError(response); + expect(error.code).toBe("resource_constraint"); + const receipt = session.evidenceById( + z + .object({ evidence_id: z.string() }) + .parse(error.details?.reported_limits).evidence_id, + ); + const evidenceId = z + .object({ parent_evidence_id: z.string() }) + .parse(receipt?.normalized_result).parent_evidence_id; + const page = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: evidenceId, + }, + view: { kind: "devicetree", collection: "nodes", limit: 2 }, + }, + }); + expect(page.isError, JSON.stringify(page).slice(0, 4000)).not.toBe(true); + expect(JSON.stringify(page).length).toBeLessThan(65536); + expect(page.structuredContent).toMatchObject({ + normalized_result: { + kind: "devicetree", + devicetree: { alias_count: 0, reference_count: 0 }, + items: [{ path: "/" }, { path: "/chosen", property_count: 1 }], + }, + }); + } finally { + await session.close(); + } + }, + 60_000, +); +mcpTest("rejects relative paths and unsupported versions", async ({ mcp }) => { + const session = createTestBinarySession(() => { + throw new Error("No engine"); + }); + const client = await mcp.connect(createDtbServer(session)); + try { + const response = await client.callTool({ + name: "inspect_devicetree", + arguments: { path: "board.dtb" }, + }); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + }); + const root = await createTestTempDirectory("rea-dtb-mcp-version-"); + const path = join(root, "board.dtb"); + await writeFile(path, devicetreeFixture(undefined, 18)); + const unsupported = await client.callTool({ + name: "inspect_devicetree", + arguments: { path }, + }); + expect(parseMcpToolError(unsupported).error).toMatchObject({ + code: "unsupported_target", + }); + } finally { + await session.close(); + } +});