Skip to content

Commit c4194bb

Browse files
committed
fwmanager: downstream update lifecycle tests, black-box style
Cover the downstream firmware-update lifecycle with host tests that assert only on the device's externally visible signals — reset transitions, what was flashed while the device was held, which slot it booted, what got committed: - update success: the device is held in reset the whole time firmware is written (programs_while_running == 0), rebooted on the new slot, and committed only after the booted state was observed (commit_preceded_by_ready). - failed firmware write: the device's broken write path surfaces through the flash seam; nothing is armed or committed and the device comes back up on its old slot, target slot untouched. - corrupt staged image: rejected during staging, staged bytes discarded, the running device never disturbed. - trial-boot timeout: no boot evidence within the window rolls the trial back and reboots the still-committed old slot. - negative control: a deliberately careless driver that flashes a live device and commits without boot evidence is caught by the instrumentation — proof the positive assertions aren't vacuous while the drivers are still test-local. stage_update/activate_update are the test-local reference rendering of the trial-boot protocol from the BootControl docs (hold, flash, arm trial, release, observe, commit/rollback). They are the executable spec for the orchestrator from PR OpenPRoT#357: when it merges, the real state machine replaces these drivers and the assertions stay. Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast <christina.quast@9elements.com>
1 parent 3119e42 commit c4194bb

2 files changed

Lines changed: 389 additions & 0 deletions

File tree

services/fwmanager/tests/BUILD.bazel

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,21 @@ rust_test(
1919
"//services/fwmanager/hal-adapters:fwmanager_hal_adapters",
2020
],
2121
)
22+
23+
# The downstream firmware-update lifecycle, same black-box style: staging,
24+
# activation under reset, trial boot, commit/rollback — asserted purely on
25+
# the device's externally visible signals. The test-local drivers are the
26+
# executable spec for the orchestrator (PR #357).
27+
rust_test(
28+
name = "lifecycle_update_test",
29+
srcs = ["lifecycle_update.rs"],
30+
edition = "2024",
31+
deps = [
32+
"//hal/blocking",
33+
"//hal/blocking/flash",
34+
"//platform/impls/baremetal/mock",
35+
"//services/fwmanager/api:fwmanager_api",
36+
"//services/fwmanager/hal-adapters:fwmanager_hal_adapters",
37+
"//util/types",
38+
],
39+
)
Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
// Licensed under the Apache-2.0 license
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Host integration tests for the downstream firmware-update lifecycle,
5+
//! black-box style: a MockDownstreamDevice on the eRoT's seams (reset line,
6+
//! boot-complete line, slot flash, staging flash, slot store), with every
7+
//! assertion made on externally visible signals — reset transitions, what
8+
//! was flashed while the device was held, which slot the device booted,
9+
//! what got committed.
10+
//!
11+
//! No orchestrator: `stage_update`/`activate_update` are the reference
12+
//! rendering of the update protocol sketched in the `BootControl` docs
13+
//! (hold → flash → arm trial → release → observe → commit/rollback). They
14+
//! are the executable spec the orchestrator (PR #357) must satisfy — when
15+
//! it lands, it replaces these drivers and the assertions stay.
16+
17+
use std::error::Error;
18+
19+
use fwmanager_api::{await_boot, BootControl, BootMonitor, BootProgress, SlotControl};
20+
use fwmanager_hal_adapters::{GpioBootMonitor, HalBootControl};
21+
use hal_flash::{Flash, FlashAddress};
22+
use openprot_hal_blocking::gpio_port::ActivePolarity;
23+
use openprot_platform_mock::downstream_device::{
24+
MockDownstreamDevice, MockPinMask, MockRegion, MOCK_DEVICE_FLASH_SIZE,
25+
};
26+
27+
// Board binding: BMC reset on controller line 7, boot-complete on GPIO
28+
// line 4. Normally set in the board's devices.rs.
29+
const BMC_RESET: u8 = 7;
30+
const BMC_READY: MockPinMask = MockPinMask(1 << 4);
31+
32+
// Same stand-in image format as boot_flow.rs: 4 magic bytes, payload, and a
33+
// final byte making the XOR over the whole image zero.
34+
const IMAGE_LEN: usize = 16;
35+
const MAGIC: [u8; 4] = *b"OPRT";
36+
37+
fn image_with_payload(fill: u8) -> [u8; IMAGE_LEN] {
38+
let mut image = [0u8; IMAGE_LEN];
39+
image[..4].copy_from_slice(&MAGIC);
40+
image[4..IMAGE_LEN - 1].fill(fill);
41+
let checksum = image[..IMAGE_LEN - 1].iter().fold(0, |acc, b| acc ^ b);
42+
image[IMAGE_LEN - 1] = checksum;
43+
image
44+
}
45+
46+
fn running_image() -> [u8; IMAGE_LEN] {
47+
image_with_payload(0xAB)
48+
}
49+
50+
fn update_image() -> [u8; IMAGE_LEN] {
51+
image_with_payload(0xCD)
52+
}
53+
54+
fn corrupt_update_image() -> [u8; IMAGE_LEN] {
55+
let mut image = update_image();
56+
image[7] ^= 0x01;
57+
image
58+
}
59+
60+
fn image_is_valid(image: &[u8; IMAGE_LEN]) -> bool {
61+
let checksum = image.iter().fold(0, |acc, b| acc ^ b);
62+
image[..4] == MAGIC && checksum == 0
63+
}
64+
65+
#[derive(Debug, PartialEq, Eq)]
66+
enum UpdateOutcome {
67+
/// Trial boot confirmed; the new slot is committed.
68+
Committed,
69+
/// The staged image failed authentication; staging was discarded and no
70+
/// slot or reset line was ever touched.
71+
RejectedStaged,
72+
/// The device's firmware write path reported a failure; nothing was
73+
/// armed or committed and the device was released on its old slot.
74+
WriteFailed,
75+
/// The trial boot produced no (or negative) evidence; the trial was
76+
/// rolled back and the device rebooted on its old slot.
77+
RolledBack,
78+
}
79+
80+
/// Stage `image` into the device's staging region and authenticate it
81+
/// there. Rejection discards the staged bytes. The device keeps running —
82+
/// staging never touches the reset line or a bootable slot.
83+
fn stage_update<F: Flash>(
84+
staging: &mut F,
85+
image: &[u8; IMAGE_LEN],
86+
) -> Result<Option<UpdateOutcome>, Box<dyn Error>>
87+
where
88+
F::Error: core::fmt::Debug,
89+
{
90+
if staging.program(FlashAddress::new(0), image).is_err() {
91+
return Ok(Some(UpdateOutcome::WriteFailed));
92+
}
93+
94+
// AuthenticateUpdate: read back what actually landed and check it.
95+
let mut staged = [0u8; IMAGE_LEN];
96+
staging
97+
.read(FlashAddress::new(0), &mut staged)
98+
.map_err(|e| format!("staging read failed: {e:?}"))?;
99+
if !image_is_valid(&staged) {
100+
staging
101+
.erase(
102+
FlashAddress::new(0),
103+
util_types::PowerOf2Usize::new(MOCK_DEVICE_FLASH_SIZE).unwrap(),
104+
)
105+
.map_err(|e| format!("staging erase failed: {e:?}"))?;
106+
return Ok(Some(UpdateOutcome::RejectedStaged));
107+
}
108+
Ok(None)
109+
}
110+
111+
/// Activate the staged update on the device's inactive slot as a trial
112+
/// boot: hold the device in reset, flash the slot while nothing runs, arm
113+
/// it as a trial, release, and watch the boot window. Commit only on
114+
/// observed boot completion; anything else rolls back and reboots the old
115+
/// slot.
116+
#[allow(clippy::too_many_arguments)]
117+
fn activate_update<C, M, S, F1, F2>(
118+
control: &mut C,
119+
monitor: &M,
120+
slots: &mut S,
121+
staging: &mut F1,
122+
target_flash: &mut F2,
123+
target_slot: usize,
124+
poll_budget: usize,
125+
) -> Result<UpdateOutcome, Box<dyn Error>>
126+
where
127+
C: BootControl,
128+
M: BootMonitor,
129+
S: SlotControl<SlotId = usize>,
130+
F1: Flash,
131+
F2: Flash,
132+
C::Error: 'static,
133+
M::Error: 'static,
134+
S::Error: 'static,
135+
F1::Error: core::fmt::Debug,
136+
F2::Error: core::fmt::Debug,
137+
{
138+
// The device must be frozen before its firmware is touched.
139+
control.hold_in_reset()?;
140+
141+
// Copy staging into the inactive slot.
142+
let mut staged = [0u8; IMAGE_LEN];
143+
staging
144+
.read(FlashAddress::new(0), &mut staged)
145+
.map_err(|e| format!("staging read failed: {e:?}"))?;
146+
if target_flash.program(FlashAddress::new(0), &staged).is_err() {
147+
// The device told us its firmware write failed. Nothing is armed;
148+
// releasing boots the untouched committed slot.
149+
control.release()?;
150+
return Ok(UpdateOutcome::WriteFailed);
151+
}
152+
153+
// Arm the trial and boot it.
154+
slots.set_trial(target_slot)?;
155+
control.release()?;
156+
157+
match await_boot(monitor, poll_budget)? {
158+
BootProgress::Booted => {
159+
slots.commit()?;
160+
Ok(UpdateOutcome::Committed)
161+
}
162+
BootProgress::Failed | BootProgress::Timeout => {
163+
control.hold_in_reset()?;
164+
slots.rollback()?;
165+
control.release()?; // reboot the still-committed old slot
166+
Ok(UpdateOutcome::RolledBack)
167+
}
168+
}
169+
}
170+
171+
/// Rig: a device running `running_image()` from slot A, plus the eRoT's
172+
/// capability bindings. Returns the device already booted and confirmed.
173+
fn booted_device() -> MockDownstreamDevice {
174+
let device = MockDownstreamDevice::held_in_reset(Some(1));
175+
device.load_firmware(&running_image());
176+
{
177+
let mut control = HalBootControl::new(device.reset_line(), BMC_RESET);
178+
let ready = device.ready_line(BMC_READY);
179+
let monitor = GpioBootMonitor::new(&ready, BMC_READY, ActivePolarity::ActiveHigh);
180+
control.release().expect("initial release");
181+
assert_eq!(
182+
await_boot(&monitor, 10).expect("initial boot"),
183+
BootProgress::Booted
184+
);
185+
}
186+
device
187+
}
188+
189+
/// A successful downstream update: the eRoT stages the new image, holds the
190+
/// device in reset for the whole time firmware is being flashed, reboots it
191+
/// on the new slot, and commits only after observing the booted state.
192+
#[test]
193+
fn update_succeeds_and_device_is_held_in_reset_while_flashed() {
194+
let device = booted_device();
195+
let target = 1 - device.committed_slot();
196+
197+
let mut control = HalBootControl::new(device.reset_line(), BMC_RESET);
198+
let ready = device.ready_line(BMC_READY);
199+
let monitor = GpioBootMonitor::new(&ready, BMC_READY, ActivePolarity::ActiveHigh);
200+
let mut slots = device.slot_store();
201+
let mut staging = device.region_flash(MockRegion::Staging);
202+
let mut target_flash = device.region_flash(MockRegion::Slot(target));
203+
204+
assert_eq!(stage_update(&mut staging, &update_image()).unwrap(), None);
205+
let outcome = activate_update(
206+
&mut control,
207+
&monitor,
208+
&mut slots,
209+
&mut staging,
210+
&mut target_flash,
211+
target,
212+
10,
213+
)
214+
.expect("update flow failed");
215+
216+
assert_eq!(outcome, UpdateOutcome::Committed);
217+
// Reset discipline: no firmware byte was ever written to a running
218+
// device — every program op happened while the reset line was asserted.
219+
assert_eq!(device.programs_while_running(), 0);
220+
// The device was rebooted into the new slot and reached the booted
221+
// state before anything was committed.
222+
assert_eq!(device.last_boot_slot(), Some(target));
223+
assert_eq!(device.commit_preceded_by_ready(), Some(true));
224+
assert_eq!(device.committed_slot(), target);
225+
assert_eq!(device.commit_count(), 1);
226+
assert_eq!(device.rollback_count(), 0);
227+
assert!(!device.is_in_reset());
228+
// The new image really is what the new slot runs.
229+
assert_eq!(device.slot_content(target)[..IMAGE_LEN], update_image());
230+
}
231+
232+
/// The device's firmware write path fails during activation: the eRoT
233+
/// notices the failed write, arms and commits nothing, and the device comes
234+
/// back up on its old slot with its old image untouched.
235+
#[test]
236+
fn failed_firmware_write_is_noticed_and_nothing_is_committed() {
237+
let device = booted_device();
238+
let old_slot = device.committed_slot();
239+
let target = 1 - old_slot;
240+
241+
let mut control = HalBootControl::new(device.reset_line(), BMC_RESET);
242+
let ready = device.ready_line(BMC_READY);
243+
let monitor = GpioBootMonitor::new(&ready, BMC_READY, ActivePolarity::ActiveHigh);
244+
let mut slots = device.slot_store();
245+
let mut staging = device.region_flash(MockRegion::Staging);
246+
let mut target_flash = device.region_flash(MockRegion::Slot(target));
247+
248+
assert_eq!(stage_update(&mut staging, &update_image()).unwrap(), None);
249+
// The device's write path breaks after staging succeeded.
250+
device.inject_program_fault();
251+
252+
let outcome = activate_update(
253+
&mut control,
254+
&monitor,
255+
&mut slots,
256+
&mut staging,
257+
&mut target_flash,
258+
target,
259+
10,
260+
)
261+
.expect("update flow failed");
262+
263+
assert_eq!(outcome, UpdateOutcome::WriteFailed);
264+
// Nothing was armed or committed; the old slot is still the boot
265+
// selection and the device was released back onto it.
266+
assert_eq!(device.committed_slot(), old_slot);
267+
assert_eq!(device.commit_count(), 0);
268+
assert_eq!(device.last_boot_slot(), Some(old_slot));
269+
assert!(!device.is_in_reset());
270+
// The target slot was never written: still erased.
271+
assert!(device.slot_content(target).iter().all(|&b| b == 0xFF));
272+
}
273+
274+
/// A corrupt staged image is rejected during staging: the staged bytes are
275+
/// discarded and the running device is never disturbed — no reset, no slot
276+
/// write, no trial.
277+
#[test]
278+
fn corrupt_staged_image_is_rejected_without_touching_the_device() {
279+
let device = booted_device();
280+
let target = 1 - device.committed_slot();
281+
282+
let mut staging = device.region_flash(MockRegion::Staging);
283+
let outcome = stage_update(&mut staging, &corrupt_update_image()).unwrap();
284+
285+
assert_eq!(outcome, Some(UpdateOutcome::RejectedStaged));
286+
assert!(!device.is_in_reset(), "staging must not reset the device");
287+
assert!(device.slot_content(target).iter().all(|&b| b == 0xFF));
288+
assert_eq!(device.commit_count(), 0);
289+
assert_eq!(device.rollback_count(), 0);
290+
// Discarded: the staging region holds no image anymore.
291+
let mut staged = [0u8; IMAGE_LEN];
292+
staging.read(FlashAddress::new(0), &mut staged).unwrap();
293+
assert!(staged.iter().all(|&b| b == 0xFF));
294+
}
295+
296+
/// The trial image never reaches the booted state: the boot window expires,
297+
/// the trial is rolled back, and the device is rebooted on the old slot —
298+
/// which stays committed.
299+
#[test]
300+
fn trial_boot_timeout_rolls_back_and_reboots_the_old_slot() {
301+
let device = booted_device();
302+
let old_slot = device.committed_slot();
303+
let target = 1 - old_slot;
304+
305+
let mut control = HalBootControl::new(device.reset_line(), BMC_RESET);
306+
let ready = device.ready_line(BMC_READY);
307+
let monitor = GpioBootMonitor::new(&ready, BMC_READY, ActivePolarity::ActiveHigh);
308+
let mut slots = device.slot_store();
309+
let mut staging = device.region_flash(MockRegion::Staging);
310+
let mut target_flash = device.region_flash(MockRegion::Slot(target));
311+
312+
assert_eq!(stage_update(&mut staging, &update_image()).unwrap(), None);
313+
// The new image is broken in a way verification can't see: it flashes
314+
// fine but the device never asserts boot-complete running it.
315+
device.set_boots_after(None);
316+
317+
let outcome = activate_update(
318+
&mut control,
319+
&monitor,
320+
&mut slots,
321+
&mut staging,
322+
&mut target_flash,
323+
target,
324+
10,
325+
)
326+
.expect("update flow failed");
327+
328+
assert_eq!(outcome, UpdateOutcome::RolledBack);
329+
assert_eq!(device.rollback_count(), 1);
330+
assert_eq!(device.commit_count(), 0);
331+
assert_eq!(device.committed_slot(), old_slot);
332+
// The recovery reboot went back to the old slot.
333+
assert_eq!(device.last_boot_slot(), Some(old_slot));
334+
assert!(!device.is_in_reset());
335+
}
336+
337+
/// Negative control for the instrumentation itself: a deliberately careless
338+
/// driver that breaks the update rules must be *caught* by the device's
339+
/// detectors. Until the real orchestrator replaces the test-local drivers,
340+
/// this is what keeps the other tests honest — it proves their assertions
341+
/// would fail for an implementation that flashes a live device or commits
342+
/// without boot confirmation, rather than passing vacuously.
343+
#[test]
344+
fn instrumentation_catches_a_driver_that_violates_the_update_rules() {
345+
let device = booted_device();
346+
let target = 1 - device.committed_slot();
347+
348+
let mut control = HalBootControl::new(device.reset_line(), BMC_RESET);
349+
let mut slots = device.slot_store();
350+
let mut target_flash = device.region_flash(MockRegion::Slot(target));
351+
352+
// Violation 1: write firmware into a bootable slot while the device is
353+
// running — no hold_in_reset first.
354+
assert!(!device.is_in_reset());
355+
target_flash
356+
.program(FlashAddress::new(0), &update_image())
357+
.unwrap();
358+
359+
// Violation 2: arm the trial and commit it immediately after the
360+
// reboot, without waiting for any boot evidence.
361+
control.hold_in_reset().unwrap(); // clears the stale ready latch
362+
slots.set_trial(target).unwrap();
363+
control.release().unwrap();
364+
slots.commit().unwrap(); // no await_boot — nothing confirmed this image
365+
366+
// Both detectors must have recorded the violations. If either of these
367+
// assertions ever fails, the positive tests above have lost their
368+
// teeth.
369+
assert_eq!(device.programs_while_running(), 1);
370+
assert_eq!(device.commit_preceded_by_ready(), Some(false));
371+
}

0 commit comments

Comments
 (0)