Skip to content

Commit bc76528

Browse files
committed
fw write: 5-point validity gate + wizard (ADR-015 Rule 11a)
Gate every fw write behind a fail-closed validity check, presented wizard-style with a confirm prompt (--yes skips only the prompt, never the validation): 1. well-formed — valid MPI2 FW image (signatures), via parse_fw_header 2. checksum — file-level U32 sum == 0 (new verify_file_checksum; algorithm confirmed empirically across the corpus) 3. right-chip — VendorID 0x1000 + ProductID in {0x2713,0x2213} (SAS2008, from corpus scan; SAS2208=0x2221 excluded) + parseable layout 4. fit — image.ImageSize <= card's active FW-region size, where the active region is inferred from the running firmware's size (ceiling = smallest candidate >= current). No guessed const; refuses if the card's FLASH_LAYOUT can't be read. 5. write-integrity — post-write read-back sha verify. New reusable building blocks (carry over to firmware synthesis — a self-built image passes the same gate): - firmware::flash_layout — walks the ext-image chain, parses FLASH_LAYOUT (MPI2_FLASH_LAYOUT_DATA); corpus-validated, NOT a config page. - firmware::validate — the 5 checks as a structured FwValidation report. Risk model reduced to 'doesn't fit OR isn't right for the card' — personality (IT vs IR) is not a risk axis (IT/IR FLASH_LAYOUT geometry byte-identical P07-P20). Verified non-destructively on the live SAS2008 @0000:03:00.0: IR + IT images pass 4/4 (declined at prompt), a BIOS option-ROM is refused at well-formed. 249 tests (build/clippy -D warnings/fmt/musl green).
1 parent b306786 commit bc76528

6 files changed

Lines changed: 523 additions & 7 deletions

File tree

src/cli/fw.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//! `fw write` — validated, wizard-style firmware write (FW_DOWNLOAD).
2+
//!
3+
//! Wraps the raw region write with the 5-point validity gate (firmware::validate)
4+
//! and a wizard-style confirmation. The risk model is just "fits + right for the
5+
//! card" (ADR-015 Rule 11a); personality is not a risk axis. `--yes` skips only
6+
//! the interactive confirm — validation always runs and always fails closed.
7+
8+
use std::path::Path;
9+
10+
use sha2::{Digest, Sha256};
11+
12+
use crate::firmware::validate::{check_fit, validate_image};
13+
use crate::mpi::messages::ImageType;
14+
15+
fn sha_hex(b: &[u8]) -> String {
16+
let mut h = Sha256::new();
17+
h.update(b);
18+
format!("{:x}", h.finalize())
19+
}
20+
21+
/// Validated firmware write: pre-flight checks 1-4, wizard confirm, write,
22+
/// then read-back verify (check 5).
23+
pub fn run_write(bdf: String, from_file: &Path, yes: bool) -> Result<(), crate::Error> {
24+
let image = std::fs::read(from_file)
25+
.map_err(|e| crate::Error::Other(format!("read {}: {}", from_file.display(), e)))?;
26+
if image.is_empty() {
27+
return Err(crate::Error::Other(format!(
28+
"{} is empty",
29+
from_file.display()
30+
)));
31+
}
32+
33+
let mut card = crate::card::discover_one(&bdf)
34+
.map_err(|e| crate::Error::Other(format!("discover_one({}): {}", bdf, e)))?;
35+
36+
// Pull the card's current firmware (FW_UPLOAD — non-destructive) so the fit
37+
// check can infer the active FW-region size from what's already running.
38+
let current = card
39+
.read_region(ImageType::Fw)
40+
.map_err(|e| crate::Error::Other(format!("FW_UPLOAD (for fit check): {}", e)))?;
41+
42+
// 5-point gate: 1-3 image-only, 4 card-derived. (#5 = read-back, post-write.)
43+
let mut val = validate_image(&image);
44+
check_fit(&mut val, &image, &current);
45+
46+
// Wizard presentation.
47+
eprintln!();
48+
eprintln!("fw write — pre-flight validation");
49+
eprintln!(" image: {} ({} bytes)", from_file.display(), image.len());
50+
eprintln!(" card: {}", bdf);
51+
for c in &val.checks {
52+
eprintln!(
53+
" [{}] {:<12} {}",
54+
if c.pass { "PASS" } else { "FAIL" },
55+
c.name,
56+
c.detail
57+
);
58+
}
59+
60+
if !val.ok() {
61+
let f = val.first_failure().unwrap();
62+
return Err(crate::Error::Other(format!(
63+
"refusing to flash — validation failed at '{}': {}",
64+
f.name, f.detail
65+
)));
66+
}
67+
eprintln!(" [ -- ] write-integrity verified by read-back after write");
68+
69+
if !yes {
70+
eprint!("\nAll checks passed. Proceed with DESTRUCTIVE fw write? [y/N] ");
71+
use std::io::Write;
72+
std::io::stderr().flush().ok();
73+
let mut line = String::new();
74+
std::io::stdin().read_line(&mut line)?;
75+
if !matches!(line.trim(), "y" | "Y" | "yes" | "YES") {
76+
return Err(crate::Error::Other("aborted by user".into()));
77+
}
78+
}
79+
80+
// Write (FW_DOWNLOAD).
81+
card.write_region(ImageType::Fw, &image)
82+
.map_err(|e| crate::Error::Other(format!("fw write: {}", e)))?;
83+
84+
// Check 5 — read-back verify (ADR-015 Rule 5).
85+
match card.read_region(ImageType::Fw) {
86+
Ok(rb) => {
87+
let want = sha_hex(&image);
88+
let got = sha_hex(&rb[..rb.len().min(image.len())]);
89+
if want == got {
90+
eprintln!("fw write: OK ({} bytes), read-back verified ✓", image.len());
91+
} else {
92+
return Err(crate::Error::Other(format!(
93+
"fw write: read-back MISMATCH (wrote {} got {}) — investigate before trusting",
94+
want, got
95+
)));
96+
}
97+
}
98+
Err(e) => eprintln!(
99+
"fw write: OK ({} bytes), but read-back verify failed: {} (verify manually)",
100+
image.len(),
101+
e
102+
),
103+
}
104+
Ok(())
105+
}

src/cli/mod.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod config;
99
pub mod detect;
1010
pub mod erase;
1111
pub mod flash;
12+
pub mod fw;
1213
pub mod recover;
1314
pub mod region;
1415
pub mod safety;
@@ -283,13 +284,9 @@ pub fn run(cli: Cli) -> Result<(), crate::Error> {
283284
FwCommand::Write(a) => {
284285
let bdf = crate::card::resolve_bdf(cli.pci.as_deref())
285286
.map_err(|e| crate::Error::Other(format!("{}", e)))?;
286-
region::run_write(
287-
bdf,
288-
crate::mpi::messages::ImageType::Fw,
289-
"fw",
290-
&a.from_file,
291-
a.yes,
292-
)
287+
// fw write goes through the validated, wizard-style path (5-point
288+
// gate); bios/nvdata stay on the raw region writer.
289+
fw::run_write(bdf, &a.from_file, a.yes)
293290
}
294291
FwCommand::ReversePhy { input, output } => {
295292
let data = std::fs::read(&input)?;

src/firmware/flash_layout.rs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
//! FLASH_LAYOUT extended-image parser for SAS2008 firmware.
2+
//!
3+
//! `FLASH_LAYOUT` is an MPI extended image (`MPI2_EXT_IMAGE_TYPE_FLASH_LAYOUT = 0x06`)
4+
//! embedded *inside* a firmware blob — NOT a config page. It is located by walking
5+
//! the extended-image linked list from the FW header's `NextImageHeaderOffset`, and
6+
//! defines the flash partition map (region offsets/sizes) for each candidate chip
7+
//! geometry. The bootloader selects the layout matching the detected physical chip.
8+
//!
9+
//! Cites: references/upstream/lsiutil/lsi/mpi2_ioc.h —
10+
//! `MPI2_FW_IMAGE_HEADER` (NextImageHeaderOffset@0x30),
11+
//! `MPI2_EXT_IMAGE_HEADER` (ImageType@0x00, ImageSize@0x08, NextImageHeaderOffset@0x0C),
12+
//! `MPI2_FLASH_LAYOUT_DATA` / `MPI2_FLASH_LAYOUT` / `MPI2_FLASH_REGION` (1469-1518).
13+
//!
14+
//! Validated against the lsi-flash-firmware corpus (2026-05-29): see
15+
//! lsi-flash-notes/02-hardware/flash-regions.md.
16+
17+
use crate::firmware::inspect::{FwError, MPI2_FW_HEADER_SIGNATURE};
18+
19+
/// `MPI2_FLASH_REGION_FIRMWARE` — the region a `fw` image lands in (mpi2_ioc.h:1507).
20+
pub const REGION_FIRMWARE: u8 = 0x01;
21+
22+
const EXT_IMAGE_TYPE_FLASH_LAYOUT: u8 = 0x06;
23+
const FW_HDR_NEXT_IMAGE_OFFSET: usize = 0x30; // U32 in MPI2_FW_IMAGE_HEADER
24+
25+
/// One flash region within a layout (`MPI2_FLASH_REGION`, 16 bytes).
26+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27+
pub struct FlashRegion {
28+
pub region_type: u8,
29+
pub offset: u32,
30+
pub size: u32,
31+
}
32+
33+
/// One candidate flash geometry, keyed by physical chip size (`MPI2_FLASH_LAYOUT`).
34+
#[derive(Debug, Clone, PartialEq, Eq)]
35+
pub struct Layout {
36+
pub flash_size: u32,
37+
pub regions: Vec<FlashRegion>,
38+
}
39+
40+
/// Parsed FLASH_LAYOUT: all candidate geometries the firmware supports.
41+
#[derive(Debug, Clone, PartialEq, Eq)]
42+
pub struct FlashLayout {
43+
pub layouts: Vec<Layout>,
44+
}
45+
46+
impl FlashLayout {
47+
/// Distinct FIRMWARE-region sizes across all candidate layouts, ascending.
48+
/// (1 MiB on >=4 MB chips, 640 KiB on the 2 MB chip layout — corpus 2026-05-29.)
49+
pub fn firmware_region_sizes(&self) -> Vec<u32> {
50+
let mut v: Vec<u32> = self
51+
.layouts
52+
.iter()
53+
.flat_map(|l| l.regions.iter())
54+
.filter(|r| r.region_type == REGION_FIRMWARE)
55+
.map(|r| r.size)
56+
.collect();
57+
v.sort_unstable();
58+
v.dedup();
59+
v
60+
}
61+
}
62+
63+
fn rd_u16(d: &[u8], o: usize) -> u16 {
64+
u16::from_le_bytes([d[o], d[o + 1]])
65+
}
66+
fn rd_u32(d: &[u8], o: usize) -> u32 {
67+
u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]])
68+
}
69+
70+
/// Walk the extended-image chain and parse the embedded FLASH_LAYOUT.
71+
///
72+
/// The ext-image offset is image-specific (a linked list), never hardcoded.
73+
pub fn parse_flash_layout(data: &[u8]) -> Result<FlashLayout, FwError> {
74+
if data.len() < 0x40 || rd_u32(data, 0) != MPI2_FW_HEADER_SIGNATURE {
75+
return Err(FwError::TooShort(data.len()));
76+
}
77+
78+
// 1. Find the FLASH_LAYOUT ext-image by walking the chain.
79+
let mut off = rd_u32(data, FW_HDR_NEXT_IMAGE_OFFSET) as usize;
80+
let mut seen = std::collections::HashSet::new();
81+
let mut base = None;
82+
while off != 0 && off + 0x10 <= data.len() && seen.insert(off) {
83+
let itype = data[off]; // MPI2_EXT_IMAGE_HEADER.ImageType @0x00
84+
let next = rd_u32(data, off + 0x0C) as usize; // NextImageHeaderOffset @0x0C
85+
if itype == EXT_IMAGE_TYPE_FLASH_LAYOUT {
86+
base = Some(off);
87+
break;
88+
}
89+
if next == 0 || next == off {
90+
break;
91+
}
92+
off = next;
93+
}
94+
let base = base.ok_or(FwError::NoFlashLayout)?;
95+
96+
// 2. Locate MPI2_FLASH_LAYOUT_DATA within the ext-image payload. A vendor
97+
// identify-string region precedes it; the struct begins where
98+
// SizeOfRegion==0x10 with sane NumberOfLayouts / RegionsPerLayout counts.
99+
let payload = base + 0x10;
100+
let scan_end = (payload + 0x40).min(data.len().saturating_sub(0x20));
101+
let mut d = None;
102+
for c in payload..scan_end {
103+
let size_of_region = data[c + 0x02];
104+
let n_layouts = rd_u16(data, c + 0x04);
105+
let regions_per = rd_u16(data, c + 0x06);
106+
if size_of_region == 0x10
107+
&& data[c] == 0x00
108+
&& (1..=16).contains(&n_layouts)
109+
&& (1..=16).contains(&regions_per)
110+
{
111+
d = Some(c);
112+
break;
113+
}
114+
}
115+
let d = d.ok_or(FwError::NoFlashLayout)?;
116+
117+
let size_of_region = data[d + 0x02] as usize; // 0x10
118+
let n_layouts = rd_u16(data, d + 0x04) as usize;
119+
let regions_per = rd_u16(data, d + 0x06) as usize;
120+
let stride = 0x10 + regions_per * size_of_region; // MPI2_FLASH_LAYOUT size
121+
122+
let mut layouts = Vec::with_capacity(n_layouts);
123+
for li in 0..n_layouts {
124+
let lp = d + 0x10 + li * stride;
125+
if lp + stride > data.len() {
126+
break;
127+
}
128+
let flash_size = rd_u32(data, lp); // MPI2_FLASH_LAYOUT.FlashSize @0x00
129+
let mut regions = Vec::with_capacity(regions_per);
130+
for ri in 0..regions_per {
131+
let ro = lp + 0x10 + ri * size_of_region;
132+
regions.push(FlashRegion {
133+
region_type: data[ro], // @0x00
134+
offset: rd_u32(data, ro + 0x04), // @0x04
135+
size: rd_u32(data, ro + 0x08), // @0x08
136+
});
137+
}
138+
layouts.push(Layout {
139+
flash_size,
140+
regions,
141+
});
142+
}
143+
if layouts.is_empty() {
144+
return Err(FwError::NoFlashLayout);
145+
}
146+
Ok(FlashLayout { layouts })
147+
}
148+
149+
#[cfg(test)]
150+
mod tests {
151+
use super::*;
152+
153+
/// Parse FLASH_LAYOUT from the golden 2118it.bin (skips if fixture absent).
154+
/// Every SAS2008 layout must expose a 1 MiB FIRMWARE region; the geometry
155+
/// is corpus-validated in lsi-flash-notes/02-hardware/flash-regions.md.
156+
#[test]
157+
fn parses_2118it_flash_layout() {
158+
let base = std::env::var("LSI_FLASH_FIXTURES").unwrap_or_else(|_| {
159+
"/Users/mjackson/Developer/lsi-flash-notes/09-research-archive/upstream/lsi_sas_hba_crossflash_guide".to_string()
160+
});
161+
let fixture = std::path::PathBuf::from(base).join("2118it.bin");
162+
let Ok(data) = std::fs::read(&fixture) else {
163+
eprintln!("skipping: fixture {} not present", fixture.display());
164+
return;
165+
};
166+
let fl = parse_flash_layout(&data).expect("FLASH_LAYOUT should parse");
167+
assert!(!fl.layouts.is_empty());
168+
let fw_sizes = fl.firmware_region_sizes();
169+
assert!(
170+
fw_sizes.contains(&0x100000),
171+
"expected a 1 MiB FW region, got {:X?}",
172+
fw_sizes
173+
);
174+
// Every layout must contain exactly one FIRMWARE region.
175+
for l in &fl.layouts {
176+
let n = l
177+
.regions
178+
.iter()
179+
.filter(|r| r.region_type == REGION_FIRMWARE)
180+
.count();
181+
assert_eq!(n, 1, "each layout has one FIRMWARE region");
182+
}
183+
}
184+
185+
#[test]
186+
fn rejects_non_firmware_blob() {
187+
assert!(matches!(
188+
parse_flash_layout(&[0u8; 0x80]),
189+
Err(FwError::TooShort(_))
190+
));
191+
}
192+
}

src/firmware/inspect.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ pub enum FwError {
7373

7474
#[error("Invalid firmware signature2 (expected 0x{expected:08X}, got 0x{got:08X})")]
7575
InvalidSignature2 { expected: u32, got: u32 },
76+
77+
#[error("No FLASH_LAYOUT extended image found in firmware")]
78+
NoFlashLayout,
7679
}
7780

7881
/// Parse firmware header from binary. Cites mpi2_ioc.h + golden file test.
@@ -167,6 +170,29 @@ pub fn parse_fw_header(data: &[u8]) -> Result<FwHeader, FwError> {
167170
})
168171
}
169172

173+
/// Verify the firmware's file-level U32 checksum: the sum of all little-endian
174+
/// U32 words over `ImageSize` must equal 0 (mod 2^32). Empirically confirmed
175+
/// across the SAS2008 corpus (lsi-flash-notes/02-hardware/flash-regions.md,
176+
/// 2026-05-29). Inverse of `synthesize::fix_file_checksum`. Returns false on a
177+
/// malformed header or a truncated image (fail-closed).
178+
pub fn verify_file_checksum(data: &[u8]) -> bool {
179+
let hdr = match parse_fw_header(data) {
180+
Ok(h) => h,
181+
Err(_) => return false,
182+
};
183+
let n = (hdr.image_size as usize) / 4;
184+
if n == 0 || n * 4 > data.len() {
185+
return false;
186+
}
187+
let mut sum: u32 = 0;
188+
for i in 0..n {
189+
sum = sum.wrapping_add(u32::from_le_bytes(
190+
data[i * 4..i * 4 + 4].try_into().unwrap(),
191+
));
192+
}
193+
sum == 0
194+
}
195+
170196
/// Inspect firmware file and print human-readable info.
171197
pub fn inspect_firmware(path: &str) -> Result<FwHeader, FwError> {
172198
let data = std::fs::read(path)?;

src/firmware/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
//!
44
//! Cites: references/upstream/lsiutil/lsi/mpi2_ioc.h (lines 1314-1362, 1365-1409)
55
6+
pub mod flash_layout;
67
pub mod inspect;
78
pub mod synthesize;
9+
pub mod validate;
810
pub use inspect::*;
911
pub use synthesize::*;

0 commit comments

Comments
 (0)