Skip to content

Commit 4fb7e03

Browse files
Zenofexclaude
andauthored
Port bdinfo and mtdparts, both of which were dead on real captures (#27)
Engine side: [BootIntel.com@ca2bd80e](BootIntel/BootIntel.com@ca2bd80e). This port started by measuring what it was porting. Across all 31 public corpus logs the engine's `bdinfo` and `mtdparts` parsers produced **nothing** — zero board info, zero devices, zero partitions — while two of those logs contain a full bdinfo dump. ``` U-Boot session in verified-image.log evidence Environment size: 1650/4091 bytes 10 variables, environment 1650/4091 bytes board info 9 fields image check uImage CRC (passed) 4 partitions on nor0 u-boot 0x00020000 @ 0x00000000 read-only kernel 0x00100000 @ 0x00020000 rootfs 0x006c0000 @ 0x00120000 art 0x00010000 @ 0x007f0000 read-only ``` The read-only column is the operationally interesting one: it says which partitions an operator at that prompt can rewrite. ## Why they were dead Both blocks were parsed only once the prompt regex had matched the line where the command was typed. `bootintel-20` prints its dump after `Boot-> bdinfo`, and `Boot->` is not U-Boot's default prompt, so the gate never opened. They are now recognised by their own shape, which is how the environment has always worked and why the environment was never affected. bdinfo needs a discriminator against the environment, since `baudrate` and `ethaddr` appear in both. It is the whitespace around the `=` — `printenv` emits `baudrate=115200`, bdinfo pads to a column and emits `baudrate = 115200 bps`. Tested in both directions. ## Two bugs fixed rather than faithfully reproduced - **`size` and `start` are not board-info keys.** `bootintel-5` prints an MTD table in a vendor format whose rows are exactly `size = 0x180000`, and `size` in the allowlist recorded that as board info. A live false positive on a real log. - **The device pattern never matched U-Boot's output.** It required `#parts` with no space and no bracketed chip id, while U-Boot prints `device nor0 <spi0.0>, # parts = 4`. The partition rows parsed; the device they belong to did not. No corpus log contains an mtdparts dump, which is exactly why that second one survived — so the new `mtdparts.log` fixture is labelled **SYNTHETIC** in the generator, and it is the only one that is. A fixture nobody has seen in the wild is weaker evidence than one that came off a board. ## Parity The expectation renders bdinfo, the device and the partitions, restricted to `source == "uboot_mtdparts"`: `mtd_partitions` also carries rows from the kernel-log and vendor-format detectors that this crate does not implement, and rendering all of them would compare two different things and call the difference drift. Nine fixtures now, eight of them real captures, reproduced byte for byte by both implementations. ## Verification - `cargo test --workspace`: 364 passed, 0 failed - `clippy --workspace --all-targets -- -D warnings` and `fmt --all --check`: clean - Engine side: 437 passed in `ci-api-regression`, baseline-diffed with no new failures, deployed - Ran the built binary against both fixtures 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c6c1c9a commit 4fb7e03

6 files changed

Lines changed: 350 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,26 @@ All notable changes to bootintel-cli are documented here. Format follows [Keep a
88

99
## [Unreleased]
1010

11+
### Added
12+
- **`bootintel verdict` now reports board info and the flash partition table**
13+
when a capture contains a `bdinfo` or `mtdparts` dump. The partition rows carry
14+
the `mask_flags` read-only marker, which is the operationally interesting
15+
column: it says which partitions an operator at that prompt can rewrite.
16+
17+
Both were dead code on the engine side and the port started by measuring that,
18+
not by assuming it. Across all 31 public corpus logs they produced nothing,
19+
while two of those logs contain a full bdinfo dump. The cause was a command
20+
gate: the blocks were only parsed once the prompt regex had matched the line
21+
where the command was typed, and `Boot-> bdinfo` is not U-Boot's default
22+
prompt. They are now recognised by their own shape.
23+
24+
The discriminator between board info and an environment variable is the
25+
whitespace around the `=`: `printenv` emits `baudrate=115200`, `bdinfo` pads to
26+
a column and emits `baudrate = 115200 bps`.
27+
28+
`verdict --json` gained `bdinfo` and `mtd_device` under `uboot_shell`, and a
29+
top-level `mtd_partitions` array, matching the engine's placement.
30+
1131
## [0.10.0] — 2026-09-28 — the kernel's own hardening report
1232

1333
`bootintel verdict` now answers for captures that never reach a U-Boot prompt.

‎crates/cli/src/cmd/verdict.rs‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,22 @@ fn json(
180180
shell.insert("env_used_bytes".into(), used.into());
181181
shell.insert("env_total_bytes".into(), total.into());
182182
}
183+
// Same placement as the engine: board info and the flash device hang off the
184+
// shell, partitions are top level and tagged with the parser that found them.
185+
if !session.bdinfo.is_empty() {
186+
shell.insert(
187+
"bdinfo".into(),
188+
session
189+
.bdinfo
190+
.iter()
191+
.map(|(k, v)| (k.clone(), serde_json::Value::from(v.clone())))
192+
.collect::<serde_json::Map<String, serde_json::Value>>()
193+
.into(),
194+
);
195+
}
196+
if let Some(dev) = &session.mtd_device {
197+
shell.insert("mtd_device".into(), dev.clone().into());
198+
}
183199
// Mirrors the engine's `boot_integrity` key names, and omits what was not
184200
// observed rather than emitting nulls: absence of a field means the capture
185201
// said nothing, which is different from a field saying "no".
@@ -259,6 +275,13 @@ fn json(
259275
"uboot_env": session.env.iter()
260276
.map(|(k, v)| (k.clone(), serde_json::Value::from(v.clone())))
261277
.collect::<serde_json::Map<String, serde_json::Value>>(),
278+
"mtd_partitions": session.mtd_partitions.iter().map(|p| serde_json::json!({
279+
"name": p.name,
280+
"size": p.size,
281+
"offset": p.offset,
282+
"read_only": p.read_only,
283+
"source": "uboot_mtdparts",
284+
})).collect::<Vec<_>>(),
262285
"boot_chain_verdict": verdicts.iter().map(|v| serde_json::json!({
263286
"title": v.title,
264287
"state": v.state,
@@ -320,6 +343,35 @@ pub(crate) fn write_text<W: Write>(
320343
session.env.len(),
321344
if session.env.len() == 1 { "" } else { "s" }
322345
)?;
346+
if !session.bdinfo.is_empty() {
347+
writeln!(
348+
out,
349+
" board info {} field{}",
350+
session.bdinfo.len(),
351+
if session.bdinfo.len() == 1 { "" } else { "s" }
352+
)?;
353+
}
354+
if !session.mtd_partitions.is_empty() {
355+
let dev = session.mtd_device.as_deref().unwrap_or("flash");
356+
writeln!(
357+
out,
358+
" {} partitions on {}",
359+
session.mtd_partitions.len(),
360+
sanitize_for_term(dev)
361+
)?;
362+
for p in &session.mtd_partitions {
363+
// The read-only flag is the operationally interesting column: it is
364+
// what says which partitions an operator at this prompt can rewrite.
365+
writeln!(
366+
out,
367+
" {:<16} 0x{:08x} @ 0x{:08x}{}",
368+
sanitize_for_term(&p.name),
369+
p.size,
370+
p.offset,
371+
if p.read_only { " read-only" } else { "" }
372+
)?;
373+
}
374+
}
323375
if let Some(check) = &integrity.image_check {
324376
let mechanism = if check == "fit_hash" {
325377
let algos = if integrity.image_hash_algorithms.is_empty() {

‎crates/detectors/src/boot_chain.rs‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ pub struct UbootSession {
4646
pub env: BTreeMap<String, String>,
4747
pub env_used_bytes: Option<u64>,
4848
pub env_total_bytes: Option<u64>,
49+
/// `bdinfo` output: what the board reports about itself.
50+
pub bdinfo: BTreeMap<String, String>,
51+
/// The flash device named by an `mtdparts` dump.
52+
pub mtd_device: Option<String>,
53+
/// Partitions from an `mtdparts` dump, in the order printed.
54+
pub mtd_partitions: Vec<MtdPartition>,
4955
}
5056

5157
use std::sync::LazyLock;
@@ -239,6 +245,72 @@ pub fn parse_integrity(log: &str) -> BootIntegrity {
239245
bi
240246
}
241247

248+
// bdinfo prints aligned `name = value`. WHITESPACE BOTH SIDES OF THE `=` IS
249+
// REQUIRED, and it is what separates a bdinfo line from an environment line:
250+
// `printenv` emits `ethaddr=00:1F:...` with no spaces, bdinfo pads to a column
251+
// and emits `ethaddr = 00:1F:...`. Without that, every environment dump
252+
// containing baudrate or ethaddr would also be read as board info.
253+
static RE_BDINFO: LazyLock<Regex> =
254+
LazyLock::new(|| Regex::new(r"^\s*([A-Za-z][\w /()\-]{0,31}?)\s+=\s+(\S.*?)\s*$").unwrap());
255+
256+
/// The allowlist is what makes bdinfo self-evidencing, which matters because the
257+
/// command that produced it cannot be relied on: bootintel-20 prints a full dump
258+
/// after `Boot-> bdinfo`, and `Boot->` is not U-Boot's default prompt, so a
259+
/// command-gated parser read that dump as nothing at all.
260+
///
261+
/// `start` and `size` are deliberately absent: too generic to stand alone.
262+
/// bootintel-5 prints an MTD table as `mtd_part[0]:` / `name = KERNEL` /
263+
/// `size = 0x180000`, and `size` in this list recorded that as board info.
264+
const BDINFO_KEYS: &[&str] = &[
265+
"arch_number",
266+
"boot_params",
267+
"dram bank",
268+
"flashstart",
269+
"flashsize",
270+
"flashoffset",
271+
"baudrate",
272+
"relocaddr",
273+
"reloc off",
274+
"ethaddr",
275+
"ip_addr",
276+
"fdt_blob",
277+
"irq_sp",
278+
"sp start",
279+
"eth0name",
280+
"memstart",
281+
"memsize",
282+
"eth1name",
283+
"ethaddr1",
284+
"current eth",
285+
"fdt_addr",
286+
"sp_start",
287+
"reloc_off",
288+
"dram_bank",
289+
];
290+
291+
// `device nor0 <spi0.0>, # parts = 4`. The bracketed chip identifier is optional
292+
// and the space in `# parts` is real. The engine's first version of this pattern
293+
// required `#parts` with no space and no brackets, so it never matched U-Boot's
294+
// actual output: the partition lines parsed and the device name did not. No
295+
// public corpus log contains an mtdparts dump, so nothing caught it until a
296+
// fixture was written for the documented format.
297+
static RE_MTD_DEV: LazyLock<Regex> = LazyLock::new(|| {
298+
Regex::new(r"(?i)^\s*device\s+(\S+)(?:\s+<[^>]*>)?\s*,\s*#\s*parts\s*=\s*(\d+)").unwrap()
299+
});
300+
static RE_MTD_PART: LazyLock<Regex> = LazyLock::new(|| {
301+
Regex::new(r"(?i)^\s*\d+:\s*(\S+)\s+0x([0-9a-f]+)\s+0x([0-9a-f]+)\s+(\d)").unwrap()
302+
});
303+
304+
/// One partition as `mtdparts` printed it at the prompt.
305+
#[derive(Debug, Default, Clone, PartialEq, Eq)]
306+
pub struct MtdPartition {
307+
pub name: String,
308+
pub size: u64,
309+
pub offset: u64,
310+
/// The `mask_flags` column: 1 means the partition is marked read-only.
311+
pub read_only: bool,
312+
}
313+
242314
/// Truncate to `max` CHARACTERS, mirroring Python's `s[:max]`.
243315
///
244316
/// `String::truncate` counts bytes and panics mid-codepoint, and a capture is
@@ -326,6 +398,49 @@ pub fn parse_session(log: &str) -> UbootSession {
326398
continue;
327399
}
328400

401+
// bdinfo and mtdparts, recognised by their own shape rather than by
402+
// having seen the command that produced them, for the reason in
403+
// BDINFO_KEYS. Checked before the continuation logic so a bdinfo line
404+
// is never appended to a buffered environment value.
405+
if let Some(caps) = RE_BDINFO.captures(line) {
406+
let key = caps[1].trim();
407+
if BDINFO_KEYS.contains(&key.to_ascii_lowercase().as_str()) {
408+
s.bdinfo
409+
.entry(key.to_string())
410+
.or_insert_with(|| caps[2].trim().to_string());
411+
note(&mut s, line);
412+
continue;
413+
}
414+
}
415+
if let Some(caps) = RE_MTD_DEV.captures(line) {
416+
if s.mtd_device.is_none() {
417+
s.mtd_device = Some(caps[1].to_string());
418+
}
419+
note(&mut s, line);
420+
continue;
421+
}
422+
if let Some(caps) = RE_MTD_PART.captures(line) {
423+
// Hex without a `0x`, as U-Boot prints it. A width beyond u64 is not
424+
// a partition table, so a failed parse drops the row rather than
425+
// inventing a zero.
426+
if let (Ok(size), Ok(offset)) = (
427+
u64::from_str_radix(&caps[2], 16),
428+
u64::from_str_radix(&caps[3], 16),
429+
) {
430+
let part = MtdPartition {
431+
name: caps[1].to_string(),
432+
size,
433+
offset,
434+
read_only: &caps[4] == "1",
435+
};
436+
if !s.mtd_partitions.contains(&part) {
437+
s.mtd_partitions.push(part);
438+
}
439+
note(&mut s, line);
440+
continue;
441+
}
442+
}
443+
329444
// A long U-Boot value wraps in a terminal capture, so the continuation
330445
// has no `KEY=`. Treating it as a boundary discarded whole
331446
// environments. Append instead, bounded: a couple of wrapped lines is

‎crates/detectors/tests/boot_chain.rs‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,23 @@ fn render(name: &str, log: &str) -> Vec<String> {
149149
"ignored_kernel_parameters",
150150
h.ignored_kernel_parameters.as_deref(),
151151
);
152+
for (key, value) in &session.bdinfo {
153+
field(&mut out, " ", "bdinfo", &format!("{key}={value}"));
154+
}
155+
if let Some(dev) = &session.mtd_device {
156+
field(&mut out, " ", "mtd_device", dev);
157+
}
158+
for part in &session.mtd_partitions {
159+
field(
160+
&mut out,
161+
" ",
162+
"mtd_part",
163+
&format!(
164+
"{} size=0x{:x} offset=0x{:x} ro={}",
165+
part.name, part.size, part.offset, part.read_only
166+
),
167+
);
168+
}
152169
for (key, value) in &session.env {
153170
field(&mut out, " ", "env", &format!("{key}={value}"));
154171
}
@@ -461,3 +478,82 @@ fn a_check_with_no_captured_result_is_unknown_rather_than_passing() {
461478
.expect("a verdict");
462479
assert_eq!(v.state, "unknown");
463480
}
481+
482+
/// bdinfo is recognised by its own shape, because the command that produced it
483+
/// cannot be relied on: bootintel-20 prints a full dump after `Boot-> bdinfo`,
484+
/// and `Boot->` is not U-Boot's default prompt. A command-gated parser read that
485+
/// entire dump as nothing.
486+
#[test]
487+
fn a_bdinfo_dump_behind_a_vendor_prompt_is_still_read() {
488+
let log = "Boot-> bdinfo\n\
489+
boot_params = 0x87D2EFB0\n\
490+
memstart = 0x80000000\n\
491+
flashsize = 0x01000000\n\
492+
ethaddr = 00:1F:45:F2:B7:3B\n";
493+
let s = boot_chain::parse_session(log);
494+
assert_eq!(
495+
s.bdinfo.get("boot_params").map(String::as_str),
496+
Some("0x87D2EFB0")
497+
);
498+
assert_eq!(
499+
s.bdinfo.get("memstart").map(String::as_str),
500+
Some("0x80000000")
501+
);
502+
assert_eq!(s.bdinfo.len(), 4);
503+
assert!(s.reached, "a bdinfo dump proves someone was at the prompt");
504+
}
505+
506+
/// The discriminator between board info and an environment variable is the
507+
/// whitespace around the `=`. Without it, every environment dump containing
508+
/// baudrate or ethaddr would also be recorded as board info.
509+
#[test]
510+
fn an_environment_line_is_not_board_info() {
511+
let log = "=> printenv\nbaudrate=115200\nethaddr=00:11:22:33:44:55\n\
512+
Environment size: 40/65532 bytes\n";
513+
let s = boot_chain::parse_session(log);
514+
assert!(
515+
s.bdinfo.is_empty(),
516+
"read the environment as board info: {:?}",
517+
s.bdinfo
518+
);
519+
assert_eq!(s.env.len(), 2);
520+
}
521+
522+
/// `size` and `start` are too generic to be board-info keys on their own.
523+
/// bootintel-5 prints an MTD table in a vendor format whose rows are exactly
524+
/// `size = 0x180000`, and treating that as bdinfo was a live false positive.
525+
#[test]
526+
fn a_vendor_mtd_table_is_not_board_info() {
527+
let log = "mtd_part[0]:\nname = KERNEL\nsize = 0x180000\noffset = 0x37000\n";
528+
let s = boot_chain::parse_session(log);
529+
assert!(s.bdinfo.is_empty(), "{:?}", s.bdinfo);
530+
}
531+
532+
/// U-Boot prints `device nor0 <spi0.0>, # parts = 4`: the bracketed chip id is
533+
/// optional and the space in `# parts` is real. The engine's first pattern
534+
/// required neither and so never matched, recording the partitions but not the
535+
/// device they belong to.
536+
#[test]
537+
fn an_mtdparts_dump_is_parsed_including_its_device() {
538+
let log = "=> mtdparts\n\n\
539+
device nor0 <spi0.0>, # parts = 4\n \
540+
#: name size offset mask_flags\n \
541+
0: u-boot 0x00020000 0x00000000 1\n \
542+
1: kernel 0x00100000 0x00020000 0\n";
543+
let s = boot_chain::parse_session(log);
544+
assert_eq!(s.mtd_device.as_deref(), Some("nor0"));
545+
assert_eq!(s.mtd_partitions.len(), 2);
546+
assert_eq!(s.mtd_partitions[0].name, "u-boot");
547+
assert_eq!(s.mtd_partitions[0].size, 0x20000);
548+
assert!(
549+
s.mtd_partitions[0].read_only,
550+
"mask_flags 1 means read-only"
551+
);
552+
assert!(!s.mtd_partitions[1].read_only);
553+
}
554+
555+
#[test]
556+
fn a_device_line_without_the_bracketed_chip_id_also_parses() {
557+
let s = boot_chain::parse_session("device nand0, #parts = 2\n");
558+
assert_eq!(s.mtd_device.as_deref(), Some("nand0"));
559+
}

0 commit comments

Comments
 (0)