diff --git a/README.md b/README.md index 60257a6..e752ae2 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ patches/ the vendor-SDK delta (mirrors flare-edge/sdk-patches until it moves build/ the hermetic image-build wrapper (kernel → rootfs → image), incremental. ci/ CI: patches-still-apply, host tests, coverage, benchmarks. docs/ architecture + ADRs (decisions/). -tools/ dev tooling. +tools/ dev tooling. config-lint: static target-config gates (MCU-load-vs-reserved-memory — the 0x40000 brick class). ``` ## Principles diff --git a/docs/architecture.md b/docs/architecture.md index 5c8fec3..9a48503 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -106,6 +106,16 @@ node." warden-sdk owns these config-lint checks (idblock loader `.ini` vs DT reservations, partition table vs image sizes, vermagic vs kernel) as CI gates, so a mistake is caught before a flash rather than on the bench. +**Built:** `tools/config-lint` implements the first and most important of these — +the MCU-load-vs-`reserved-memory` gate. It parses the rkbin loader `.ini` for +every `LOADERn=Hpmcu` firmware and its `[LOADERn_PARAM] LOAD_ADDR`, parses the +target devicetree (`.dts`, or `dtc -I dtb` output in CI) for `reserved-memory` +ranges, and fails if any MCU load lands outside a reservation. Its test suite +encodes the c8a3 brick itself: the real Thunder-Boot `.ini` (Hpmcu @ `0x40000`) +fails against a DT with no `rtos@40000` node and passes once the reservation is +added. **Next** target-config checks: partition-table-vs-image-size and +vermagic-vs-kernel. + ## 6. Kernel forward-port (separate, bounded phase) Move to **plan44's OpenWrt RV1106 fork — Linux 6.6** (152 RV1106 patches + our diff --git a/tools/config-lint/Cargo.toml b/tools/config-lint/Cargo.toml new file mode 100644 index 0000000..a2efd61 --- /dev/null +++ b/tools/config-lint/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "warden-config-lint" +version = "0.1.0" +edition = "2021" +description = "Static target-config checks for WardenOS: catch memory-map faults (the 0x40000 MCU-load brick class) and other flash-time config mistakes before a flash, not on the bench." +license = "MIT OR Apache-2.0" + +[[bin]] +name = "config-lint" +path = "src/main.rs" + +[lib] +name = "warden_config_lint" +path = "src/lib.rs" diff --git a/tools/config-lint/README.md b/tools/config-lint/README.md new file mode 100644 index 0000000..c2bf3f6 --- /dev/null +++ b/tools/config-lint/README.md @@ -0,0 +1,42 @@ +# config-lint — static target-config gates + +Catches flash-time config faults the behavioural sim cannot: mistakes in the +*memory map*, not the logic. The first check is the one that would have caught the +**c8a3 brick** — a boot-loaded coprocessor firmware dropped at `0x40000`, which is +a `reserved-memory` carve-out on Thunder-Boot boards but plain kernel RAM on ours, +so the MCU and the kernel fought over the same DRAM and the board hung before eth0. + +## The check + +Every address the idblock loader drops MCU firmware to must sit inside a +`reserved-memory` node in the target devicetree. + +- **MCU loads** come from the rkbin loader `.ini`: each `LOADERn=Hpmcu` (any + hpmcu/mcu/amp entry) in `[LOADER_OPTION]`, with its `LOAD_ADDR` from + `[LOADERn_PARAM]`. +- **Reserved ranges** come from the devicetree: every `reg = ` inside a + `reserved-memory { … }` node. + +A load outside all reservations is a failure (non-zero exit). + +## Use + + cargo run -p warden-config-lint -- --ini --dt + +In CI, feed the *flattened* devicetree so includes and overlays are resolved: + + dtc -I dtb -O dts build/.../rv1106g-warden.dtb > /tmp/warden.dts + config-lint --ini .../RKBOOT/RV1106MINIALL*.ini --dt /tmp/warden.dts + +Exit `0` = every MCU load is reserved (or there are none); `1` = a collision was +found; `2` = usage/IO error. + +## Test + + cargo test -p warden-config-lint + +The suite encodes the brick as a regression: the real Thunder-Boot `.ini` +(Hpmcu @ `0x40000`) *fails* against a DT with no `rtos@40000` node and *passes* +once the reservation is added — and our board's non-TB loader (no boot-loaded MCU) +always passes. See `../../docs/architecture.md` §5 and, for the hardware hazard, +the `boot-loaded-mcu-0x40000-hazard` note. diff --git a/tools/config-lint/src/lib.rs b/tools/config-lint/src/lib.rs new file mode 100644 index 0000000..799d197 --- /dev/null +++ b/tools/config-lint/src/lib.rs @@ -0,0 +1,248 @@ +//! config-lint — static target-config checks the behavioural sim cannot cover. +//! +//! The c8a3 brick was a memory-map fault, not a logic bug: the boot-loaded MCU's +//! load address (`0x40000`) is a `reserved-memory` carve-out on Thunder-Boot +//! boards but plain kernel RAM on ours, so the coprocessor firmware and the kernel +//! fought over the same DRAM and the board hung before eth0. No sim catches that — +//! it needs a static check against the target devicetree: **every address the +//! idblock loader drops MCU firmware to must sit inside a `reserved-memory` node.** +//! +//! This module parses the two authoritative artifacts (the rkbin loader `.ini` +//! and the built/target devicetree) and reports any MCU load that would collide. +//! Wire the CLI into CI so the mistake is caught before a flash, not on a bench. + +/// One MCU/coprocessor firmware load declared by the loader `.ini`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McuLoad { + pub loader: String, // e.g. "LOADER2" + pub name: String, // e.g. "Hpmcu" + pub load_addr: u64, +} + +/// A `reserved-memory` range `[start, start+size)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Range { + pub start: u64, + pub size: u64, +} + +impl Range { + pub fn contains(&self, addr: u64) -> bool { + addr >= self.start && addr < self.start.saturating_add(self.size) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Finding { + pub load: McuLoad, + pub msg: String, +} + +fn parse_addr(s: &str) -> Option { + let s = s.trim(); + if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + u64::from_str_radix(hex, 16).ok() + } else { + s.parse::().ok() + } +} + +/// Does a loader entry name a coprocessor/MCU firmware (whose LOAD_ADDR matters)? +fn is_mcu_loader(name: &str) -> bool { + let n = name.to_ascii_lowercase(); + n.contains("hpmcu") || n.contains("mcu") || n.contains("amp") +} + +/// Extract MCU firmware loads from an rkbin loader `.ini`: for each `LOADERn=` +/// in `[LOADER_OPTION]` that names an MCU loader, read `LOAD_ADDR` from the matching +/// `[LOADERn_PARAM]` section. +pub fn parse_ini_mcu_loads(ini: &str) -> Vec { + let mut section = String::new(); + // loader index (e.g. "LOADER2") -> firmware name (e.g. "Hpmcu") + let mut loaders: Vec<(String, String)> = Vec::new(); + // section name -> LOAD_ADDR + let mut load_addrs: std::collections::HashMap = std::collections::HashMap::new(); + + for raw in ini.lines() { + let line = raw.split(['#', ';']).next().unwrap_or("").trim(); + if line.is_empty() { + continue; + } + if let Some(sec) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + section = sec.trim().to_string(); + continue; + } + let Some((k, v)) = line.split_once('=') else { continue }; + let (k, v) = (k.trim(), v.trim()); + if section == "LOADER_OPTION" && k.to_ascii_uppercase().starts_with("LOADER") { + loaders.push((k.to_ascii_uppercase(), v.to_string())); + } else if k.eq_ignore_ascii_case("LOAD_ADDR") { + if let Some(a) = parse_addr(v) { + load_addrs.insert(section.to_ascii_uppercase(), a); + } + } + } + + let mut out = Vec::new(); + for (loader, name) in loaders { + if !is_mcu_loader(&name) { + continue; + } + // LOADER2 -> [LOADER2_PARAM] + if let Some(&addr) = load_addrs.get(&format!("{loader}_PARAM")) { + out.push(McuLoad { loader, name, load_addr: addr }); + } + } + out +} + +/// Extract `reserved-memory` child ranges from devicetree source (a `.dts`/`.dtsi`, +/// or the flattened output of `dtc -I dtb -O dts`). Brace-tracked so only `reg`s +/// inside a `reserved-memory { ... }` node are taken. Handles `#size-cells = <1>` +/// (RV1106): each `reg = ` is one range. +pub fn parse_reserved_ranges(dt: &str) -> Vec { + let mut out = Vec::new(); + let bytes = dt.as_bytes(); + let mut i = 0; + while let Some(pos) = dt[i..].find("reserved-memory") { + let mut j = i + pos; + // find the opening brace of this node + while j < bytes.len() && bytes[j] != b'{' { + j += 1; + } + if j >= bytes.len() { + break; + } + let mut depth = 0i32; + let start = j; + while j < bytes.len() { + match bytes[j] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + j += 1; + break; + } + } + _ => {} + } + j += 1; + } + // scan reg = < a b > inside [start, j) + let block = &dt[start..j.min(dt.len())]; + for reg in block.split("reg").skip(1) { + let Some(lt) = reg.find('<') else { continue }; + let Some(gt) = reg[lt..].find('>') else { continue }; + let nums: Vec<&str> = reg[lt + 1..lt + gt].split_whitespace().collect(); + if nums.len() >= 2 { + if let (Some(a), Some(s)) = (parse_addr(nums[0]), parse_addr(nums[1])) { + out.push(Range { start: a, size: s }); + } + } + } + i = j; + } + out +} + +/// The gate: every MCU load must land inside a reserved-memory range. +pub fn check(loads: &[McuLoad], reserved: &[Range]) -> Vec { + loads + .iter() + .filter(|l| !reserved.iter().any(|r| r.contains(l.load_addr))) + .map(|l| Finding { + load: l.clone(), + msg: format!( + "{}={} loads MCU firmware at {:#x} but no reserved-memory node covers it \ + — kernel/MCU DRAM collision (the 0x40000 brick class)", + l.loader, l.name, l.load_addr + ), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // The exact idblock .ini that bricked c8a3: Hpmcu at 0x40000. + const TB_INI: &str = r#" +[LOADER_OPTION] +NUM=3 +LOADER1=FlashData +LOADER2=Hpmcu +LOADER3=FlashBoot +FlashData=bin/rv11/rv1106_ddr.bin +Hpmcu=bin/rv11/rv1106_hpmcu_tb.bin +FlashBoot=bin/rv11/rv1106_spl.bin +[LOADER2_PARAM] +LOAD_ADDR=0x40000 +FLAG=0x10007 +"#; + + // Our board's actual loader: no Hpmcu at all. + const NONTB_INI: &str = "[LOADER_OPTION]\nNUM=2\nLOADER1=FlashData\nLOADER2=FlashBoot\n"; + + const DT_WITH_RTOS: &str = r#" + reserved-memory { + #address-cells = <1>; + #size-cells = <1>; + ranges; + rtos@40000 { reg = <0x40000 0x3c000>; no-map; }; + ramoops@0f000000 { compatible = "ramoops"; reg = <0x0f000000 0x00100000>; }; + }; + "#; + const DT_NO_RTOS: &str = r#" + reserved-memory { + ranges; + ramoops@0f000000 { compatible = "ramoops"; reg = <0x0f000000 0x00100000>; }; + }; + "#; + + #[test] + fn parses_hpmcu_load_addr() { + let loads = parse_ini_mcu_loads(TB_INI); + assert_eq!(loads.len(), 1); + assert_eq!(loads[0].name, "Hpmcu"); + assert_eq!(loads[0].load_addr, 0x40000); + } + + #[test] + fn nontb_ini_has_no_mcu_loads() { + assert!(parse_ini_mcu_loads(NONTB_INI).is_empty()); + } + + #[test] + fn parses_reserved_ranges() { + let r = parse_reserved_ranges(DT_WITH_RTOS); + assert_eq!(r.len(), 2); + assert!(r.iter().any(|x| x.start == 0x40000 && x.size == 0x3c000)); + assert!(r.iter().any(|x| x.start == 0x0f00_0000)); + } + + /// The c8a3 brick, prevented: Hpmcu@0x40000 with NO reserving node -> a finding. + #[test] + fn flags_the_c8a3_brick() { + let loads = parse_ini_mcu_loads(TB_INI); + let reserved = parse_reserved_ranges(DT_NO_RTOS); + let f = check(&loads, &reserved); + assert_eq!(f.len(), 1); + assert_eq!(f[0].load.load_addr, 0x40000); + } + + /// The fix: add the rtos@40000 reservation -> the same config passes. + #[test] + fn passes_when_rtos_reserved() { + let loads = parse_ini_mcu_loads(TB_INI); + let reserved = parse_reserved_ranges(DT_WITH_RTOS); + assert!(check(&loads, &reserved).is_empty()); + } + + /// Our board (no boot-loaded MCU) always passes, reserved or not. + #[test] + fn nontb_board_always_passes() { + let loads = parse_ini_mcu_loads(NONTB_INI); + assert!(check(&loads, &parse_reserved_ranges(DT_NO_RTOS)).is_empty()); + } +} diff --git a/tools/config-lint/src/main.rs b/tools/config-lint/src/main.rs new file mode 100644 index 0000000..336e606 --- /dev/null +++ b/tools/config-lint/src/main.rs @@ -0,0 +1,77 @@ +//! config-lint CLI — the MCU-load-vs-reserved-memory gate as a CI check. +//! +//! Usage: +//! config-lint --ini --dt +//! +//! The `--dt` argument accepts a `.dts`/`.dtsi` or the flattened output of +//! `dtc -I dtb -O dts built.dtb` (preferred in CI — it resolves includes and +//! overlays, so it reflects what the board will actually boot). Exits non-zero +//! and prints each offending MCU load if any lands outside a `reserved-memory` +//! node — the 0x40000 brick class. + +use std::process::ExitCode; +use warden_config_lint::{check, parse_ini_mcu_loads, parse_reserved_ranges}; + +fn main() -> ExitCode { + let mut ini_path = None; + let mut dt_path = None; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--ini" => ini_path = args.next(), + "--dt" => dt_path = args.next(), + "-h" | "--help" => { + eprintln!("usage: config-lint --ini --dt "); + return ExitCode::SUCCESS; + } + other => { + eprintln!("config-lint: unknown argument {other:?}"); + return ExitCode::from(2); + } + } + } + + let (Some(ini_path), Some(dt_path)) = (ini_path, dt_path) else { + eprintln!("usage: config-lint --ini --dt "); + return ExitCode::from(2); + }; + + let ini = match std::fs::read_to_string(&ini_path) { + Ok(s) => s, + Err(e) => { + eprintln!("config-lint: cannot read {ini_path}: {e}"); + return ExitCode::from(2); + } + }; + let dt = match std::fs::read_to_string(&dt_path) { + Ok(s) => s, + Err(e) => { + eprintln!("config-lint: cannot read {dt_path}: {e}"); + return ExitCode::from(2); + } + }; + + let loads = parse_ini_mcu_loads(&ini); + let reserved = parse_reserved_ranges(&dt); + let findings = check(&loads, &reserved); + + if loads.is_empty() { + println!("config-lint: no boot-loaded MCU firmware in {ini_path} — nothing to reserve."); + } else { + println!( + "config-lint: {} MCU load(s) in {ini_path}, {} reserved-memory range(s) in {dt_path}.", + loads.len(), + reserved.len() + ); + } + + if findings.is_empty() { + println!("config-lint: OK — every MCU load is inside a reserved-memory node."); + ExitCode::SUCCESS + } else { + for f in &findings { + eprintln!("config-lint: FAIL — {}", f.msg); + } + ExitCode::FAILURE + } +}