From eac68962190a6bf7d127c485d5462299cbbf9639 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Fri, 24 Jul 2026 16:29:36 +0200 Subject: [PATCH] fwmanager: Add BootMonitor capability and GpioBootMonitor adapter BootMonitor reports a device's boot liveness as BootStatus in the api leaf crate; GpioBootMonitor implements it over one HAL GpioPort input line. MonitorError meets the core::error::Error bound and keeps the concrete HAL error reachable through source(). new() rejects empty pin masks; boot latches are cleared by the reset path, never the observer. Closes: https://github.com/9elements/openprot/issues/6 Assisted-by: Claude:claude-opus-4-8 Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast --- services/fwmanager/api/BUILD.bazel | 1 + services/fwmanager/api/src/boot_monitor.rs | 175 +++++++++ services/fwmanager/api/src/lib.rs | 10 +- services/fwmanager/hal-adapters/BUILD.bazel | 1 + .../hal-adapters/src/gpio_boot_monitor.rs | 350 ++++++++++++++++++ services/fwmanager/hal-adapters/src/lib.rs | 5 +- 6 files changed, 539 insertions(+), 3 deletions(-) create mode 100644 services/fwmanager/api/src/boot_monitor.rs create mode 100644 services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs diff --git a/services/fwmanager/api/BUILD.bazel b/services/fwmanager/api/BUILD.bazel index e5653a61..c096c1c7 100644 --- a/services/fwmanager/api/BUILD.bazel +++ b/services/fwmanager/api/BUILD.bazel @@ -7,6 +7,7 @@ rust_library( name = "fwmanager_api", srcs = [ "src/boot_control.rs", + "src/boot_monitor.rs", "src/lib.rs", ], edition = "2024", diff --git a/services/fwmanager/api/src/boot_monitor.rs b/services/fwmanager/api/src/boot_monitor.rs new file mode 100644 index 00000000..931970e9 --- /dev/null +++ b/services/fwmanager/api/src/boot_monitor.rs @@ -0,0 +1,175 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Observation capability: read a managed device's boot liveness. + +/// Liveness of a managed device's boot: Boot Confirmation only. +/// +/// Reports only that a device came up, never what booted; confirming the +/// running image is the one the RoT staged is attestation, a separate step. +/// `Failed` is optional device-reported evidence and never the only failure +/// path, since a hung device reports nothing — a stuck boot is caught by the +/// orchestrator's timeout, not by this enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootStatus { + /// Held in reset by the orchestrator; not yet released. + InReset, + /// Released, but boot completion not yet observed. + Booting, + /// Boot completion observed. + Booted, + /// Device reported a boot failure. + Failed, +} + +/// Observation capability: read a managed device's boot liveness. +/// +/// Pull-shaped: where the underlying signal is an edge or pulse, the interrupt +/// latches a flag beneath this seam and `boot_status` only reads it. No +/// callback registration, which would require allocation and invert control +/// into device implementations. +/// +/// The reported status must describe the **current** boot cycle. An +/// implementation backed by a latched signal must guarantee the latch is +/// cleared whenever the device re-enters reset, so evidence left over from a +/// previous boot never reads as [`BootStatus::Booted`]. This trait +/// deliberately has no re-arm operation: clearing is the reset path's job +/// (hardware tying the latch to the device's reset line, or the same platform +/// code that drives `BootControl`), not the observer's — a monitor that could +/// clear its own evidence would let a read race a reset. +pub trait BootMonitor { + /// The error type reported by this device's boot monitor. + /// + /// Requires [`core::error::Error`] (in `core` since Rust 1.81) so the + /// orchestrator gets `Display` and a `source()` cause chain, not just a + /// `Debug` dump. Error categories stay implementation-defined — this + /// crate names no error vocabulary of its own; a consumer that knows the + /// concrete adapter can recover its details by downcasting the + /// `&dyn core::error::Error`. + type Error: core::error::Error; + + /// Returns the current liveness of the device. + /// + /// # Errors + /// + /// Returns an error if the underlying liveness signal cannot be read. + fn boot_status(&self) -> Result; +} + +#[cfg(test)] +#[allow(clippy::bool_assert_comparison)] +mod tests { + use super::*; + use core::cell::Cell; + + // ── Trait contract ────────────────────────────────────────────────── + // MockMonitor implements the trait without any HAL dependency. If a + // HAL-specific bound sneaks back onto `Error`, this module stops + // compiling. + + struct MockMonitor { + ready_after: usize, + polls: Cell, + fail: bool, + } + + #[derive(Debug, PartialEq, Eq)] + struct MockFault; + + impl core::fmt::Display for MockFault { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("mock monitor fault") + } + } + + impl core::error::Error for MockFault {} + + impl BootMonitor for MockMonitor { + type Error = MockFault; + + fn boot_status(&self) -> Result { + if self.fail { + return Err(MockFault); + } + let polls = self.polls.get(); + self.polls.set(polls + 1); + Ok(if polls >= self.ready_after { + BootStatus::Booted + } else { + BootStatus::Booting + }) + } + } + + // A device that is still coming up reads Booting, then Booted once it + // is up. + #[test] + fn status_progresses_from_booting_to_booted() { + let mon = MockMonitor { + ready_after: 1, + polls: Cell::new(0), + fail: false, + }; + + assert_eq!( + mon.boot_status().expect("boot_status failed"), + BootStatus::Booting + ); + assert_eq!( + mon.boot_status().expect("boot_status failed"), + BootStatus::Booted + ); + } + + #[test] + fn errors_surface_through_the_generic_seam() { + let mon = MockMonitor { + ready_after: 0, + polls: Cell::new(0), + fail: true, + }; + + let err = comes_up_within(&mon, 1).expect_err("expected the monitor fault"); + + // Display comes from the core::error::Error bound, not a Debug dump. + assert_eq!(err.to_string(), "mock monitor fault"); + } + + // ── The orchestrator's future shape ───────────────────────────────── + // Usage examples for the future orchestrator, not API guarantees; move + // these to the orchestrator crate once it exists. + + /// Poll a monitor up to `poll_budget` times. `Booting` is not a failure; + /// `Ok(false)` means the budget ran out before the device came up. + fn comes_up_within(mon: &M, poll_budget: usize) -> Result { + for _ in 0..poll_budget { + if mon.boot_status()? == BootStatus::Booted { + return Ok(true); + } + } + Ok(false) + } + + // A device that comes up within the poll budget reads Booted. + #[test] + fn a_device_that_comes_up_within_budget_is_booted() { + let mon = MockMonitor { + ready_after: 2, + polls: Cell::new(0), + fail: false, + }; + + assert_eq!(comes_up_within(&mon, 5).expect("boot_status failed"), true); + } + + #[test] + fn a_device_that_never_comes_up_is_a_timeout_not_an_error() { + let mon = MockMonitor { + ready_after: usize::MAX, + polls: Cell::new(0), + fail: false, + }; + + assert_eq!(comes_up_within(&mon, 3).expect("boot_status failed"), false); + } +} diff --git a/services/fwmanager/api/src/lib.rs b/services/fwmanager/api/src/lib.rs index 8d577b97..401ff47a 100644 --- a/services/fwmanager/api/src/lib.rs +++ b/services/fwmanager/api/src/lib.rs @@ -7,15 +7,21 @@ //! single managed device's reset without knowing which controller line it //! maps to. //! +//! `BootMonitor` is the observation capability: the orchestrator reads a +//! device's boot liveness. +//! //! This crate is a dependency-free leaf: it holds only the capability //! contracts, and everything depends downward on it. Concrete adapters bind a //! trait to a signal source and live in their own crates, so naming a //! capability never drags in the stack behind it — the HAL-backed -//! `HalBootControl` is in `fwmanager-hal-adapters`; other backends implement -//! the same trait from their own transport crate. +//! `HalBootControl` and `GpioBootMonitor` are in `fwmanager-hal-adapters`; +//! other backends (for example an MCTP-ready `BootMonitor`) implement the +//! same traits from their own transport crate. #![cfg_attr(not(test), no_std)] mod boot_control; +mod boot_monitor; pub use boot_control::BootControl; +pub use boot_monitor::{BootMonitor, BootStatus}; diff --git a/services/fwmanager/hal-adapters/BUILD.bazel b/services/fwmanager/hal-adapters/BUILD.bazel index aff19e34..14e65f0d 100644 --- a/services/fwmanager/hal-adapters/BUILD.bazel +++ b/services/fwmanager/hal-adapters/BUILD.bazel @@ -6,6 +6,7 @@ load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") rust_library( name = "fwmanager_hal_adapters", srcs = [ + "src/gpio_boot_monitor.rs", "src/hal_boot_control.rs", "src/lib.rs", ], diff --git a/services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs b/services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs new file mode 100644 index 00000000..b4c22841 --- /dev/null +++ b/services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs @@ -0,0 +1,350 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! HAL-backed [`BootMonitor`]: read a device's boot-complete signal off a GPIO +//! input line. + +use fwmanager_api::{BootMonitor, BootStatus}; +use openprot_hal_blocking::gpio_port::{ + ActivePolarity, GpioError, GpioErrorKind, GpioPort, PinMask, +}; + +/// Adapts any HAL GPIO error into a [`core::error::Error`]. +/// +/// GPIO ports keep implementing the HAL `GpioError`/`kind()` pattern +/// unchanged; this wrapper supplies the `Display` and `core::error::Error` +/// machinery [`BootMonitor::Error`] requires, so no per-implementation work is +/// needed. The underlying category stays reachable via [`MonitorError::kind`], +/// and the concrete HAL error through the +/// [`source()`](core::error::Error::source) chain, downcast to +/// [`GpioCause`](GpioCause). +#[derive(Debug)] +pub struct MonitorError(GpioCause); + +/// The concrete HAL error underneath a [`MonitorError`], surfaced as its +/// [`source()`](core::error::Error::source). +/// +/// `GpioError` only guarantees `Debug`, so the HAL error cannot be the source +/// itself; this wrapper adds the `Display`/`Error` machinery. It renders the +/// full concrete error, not just its kind — the tail of a rendered error +/// chain carries the implementation's details. +#[derive(Debug)] +pub struct GpioCause(pub E); + +impl core::fmt::Display for MonitorError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "boot monitor error: {:?}", self.kind()) + } +} + +impl core::error::Error for MonitorError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + Some(&self.0) + } +} + +impl core::fmt::Display for GpioCause { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{:?}", self.0) + } +} + +impl core::error::Error for GpioCause {} + +impl GpioError for MonitorError { + fn kind(&self) -> GpioErrorKind { + let GpioCause(inner) = &self.0; + inner.kind() + } +} + +impl From for MonitorError { + fn from(err: E) -> Self { + Self(GpioCause(err)) + } +} + +/// Binds one input line of a HAL GPIO port to a managed device's boot-complete +/// signal. +/// +/// The input-side counterpart to [`HalBootControl`]: the `(port, ready_pin, +/// active)` binding is made once, in platform configuration, and the +/// orchestrator never learns which line belongs to which device. A signal that +/// latches high is read directly; an edge or pulse (for example a heartbeat) is +/// latched in hardware at bring-up so `read_input` still reflects it. +/// +/// Where a hardware latch is used, the platform must clear it whenever the +/// device re-enters reset (typically by wiring the latch's clear to the +/// device's reset line) — [`BootMonitor`] requires that evidence from a +/// previous boot never reads as [`BootStatus::Booted`], and this adapter only +/// reads the line, it cannot re-arm it. +/// +/// [`HalBootControl`]: crate::HalBootControl +pub struct GpioBootMonitor { + port: P, + ready_pin: P::Mask, + active: ActivePolarity, +} + +impl GpioBootMonitor

{ + /// Binds `port`'s line `ready_pin`, asserted per `active`, as a device's + /// boot-complete signal. + /// + /// `ready_pin` must name exactly one line — boot-complete is a single + /// signal. Masks naming several lines are not supported (the `PinMask` + /// contract offers no way to reject them here, so they are unspecified + /// behavior, not a feature). + /// + /// # Panics + /// + /// Panics if `ready_pin` is empty. An empty mask is vacuously contained + /// in every sample, so it would report every device `Booted` from the + /// moment reset is released — a misbinding that must fail at + /// construction, in platform bring-up, not stay silent in the field. + pub fn new(port: P, ready_pin: P::Mask, active: ActivePolarity) -> Self { + assert!( + !ready_pin.is_empty(), + "GpioBootMonitor bound to an empty pin mask" + ); + Self { + port, + ready_pin, + active, + } + } +} + +// `P::Error: 'static` because `source()` hands out `&(dyn Error + 'static)` +// referencing the wrapped HAL error. Error types are plain data; this costs +// no real implementation anything. +impl BootMonitor for GpioBootMonitor

+where + P::Error: 'static, +{ + type Error = MonitorError; + + /// # Errors + /// + /// Propagates any error returned by the port's `read_input`. + fn boot_status(&self) -> Result { + let high = self.port.read_input()?.contains(self.ready_pin); + let booted = match self.active { + ActivePolarity::ActiveHigh => high, + ActivePolarity::ActiveLow => !high, + }; + Ok(if booted { + BootStatus::Booted + } else { + BootStatus::Booting + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openprot_hal_blocking::gpio_port::GpioErrorType; + + // BMC boot-complete on line 4. Normally set in config.rs. + const BMC_READY: Mask = Mask(1 << 4); + + /// Bitmask over a single mock GPIO bank. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct Mask(u8); + + impl PinMask for Mask { + fn empty() -> Self { + Self(0) + } + + fn all() -> Self { + Self(u8::MAX) + } + + fn is_empty(&self) -> bool { + self.0 == 0 + } + + fn contains(&self, other: Self) -> bool { + self.0 & other.0 == other.0 + } + + fn union(&self, other: Self) -> Self { + Self(self.0 | other.0) + } + + fn intersection(&self, other: Self) -> Self { + Self(self.0 & other.0) + } + + fn toggle(&self) -> Self { + Self(!self.0) + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct MockError(GpioErrorKind); + + impl GpioError for MockError { + fn kind(&self) -> GpioErrorKind { + self.0 + } + } + + /// Mock HAL GPIO port: returns a fixed input sample, with opt-in read + /// failure injection. Output-side methods must never be called. + struct MockGpioPort { + input: Mask, + fail: Option, + } + + impl MockGpioPort { + fn with_input(input: Mask) -> Self { + Self { input, fail: None } + } + + fn failing(kind: GpioErrorKind) -> Self { + Self { + input: Mask::empty(), + fail: Some(kind), + } + } + } + + impl GpioErrorType for MockGpioPort { + type Error = MockError; + } + + impl GpioPort for MockGpioPort { + type Config = (); + type Mask = Mask; + + fn read_input(&self) -> Result { + match self.fail { + Some(kind) => Err(MockError(kind)), + None => Ok(self.input), + } + } + + fn configure(&mut self, _: Mask, _: ()) -> Result<(), MockError> { + panic!("BootMonitor must never configure pins"); + } + + fn set_reset(&mut self, _: Mask, _: Mask) -> Result<(), MockError> { + panic!("BootMonitor must never drive outputs"); + } + + fn toggle(&mut self, _: Mask) -> Result<(), MockError> { + panic!("BootMonitor must never drive outputs"); + } + } + + // A ready line asserted high reads as Booted under active-high polarity. + #[test] + fn booted_when_ready_line_asserted() { + let mon = GpioBootMonitor::new( + MockGpioPort::with_input(BMC_READY), + BMC_READY, + ActivePolarity::ActiveHigh, + ); + + assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booted); + } + + // A deasserted ready line is not a failure — the device is still Booting + // until the orchestrator's own timeout fires. + #[test] + fn booting_when_ready_line_deasserted() { + let mon = GpioBootMonitor::new( + MockGpioPort::with_input(Mask::empty()), + BMC_READY, + ActivePolarity::ActiveHigh, + ); + + assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booting); + } + + // Active-low inverts the sense: a low line is asserted, hence Booted. + #[test] + fn respects_active_low_polarity() { + let mon = GpioBootMonitor::new( + MockGpioPort::with_input(Mask::empty()), + BMC_READY, + ActivePolarity::ActiveLow, + ); + + assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booted); + } + + // Only the configured line matters: an unrelated high line leaves the + // device Booting. + #[test] + fn ignores_other_lines() { + let mon = GpioBootMonitor::new( + MockGpioPort::with_input(Mask(1 << 2)), + BMC_READY, + ActivePolarity::ActiveHigh, + ); + + assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booting); + } + + // An empty mask would read as Booted forever (it is vacuously contained + // in every sample); binding one must die at construction. + #[test] + #[should_panic(expected = "empty pin mask")] + fn empty_ready_mask_panics_at_construction() { + GpioBootMonitor::new( + MockGpioPort::with_input(Mask::empty()), + Mask::empty(), + ActivePolarity::ActiveHigh, + ); + } + + // A controller error surfaces through BootMonitor unchanged. + #[test] + fn port_error_propagates_through_boot_monitor() { + let mon = GpioBootMonitor::new( + MockGpioPort::failing(GpioErrorKind::HardwareFailure), + BMC_READY, + ActivePolarity::ActiveHigh, + ); + + let err = mon + .boot_status() + .expect_err("expected the port error to propagate"); + assert_eq!(err.kind(), GpioErrorKind::HardwareFailure); + } + + /// Compile-time fence: the adapter error must satisfy `core::error::Error` + /// (Display + source) *and* the HAL `GpioError` (with `kind()`). Fails to + /// compile if either is dropped. + fn _assert_error_contract() {} + + #[test] + fn monitor_error_satisfies_the_full_contract() { + _assert_error_contract::>(); + } + + #[test] + fn monitor_error_renders_a_human_readable_message() { + let err = MonitorError::from(MockError(GpioErrorKind::HardwareFailure)); + + assert_eq!(err.to_string(), "boot monitor error: HardwareFailure"); + } + + // The cause chain: source() carries the concrete HAL error with its full + // details (not just the kind), and downcasting recovers the typed error. + #[test] + fn source_exposes_the_concrete_hal_error() { + let err = MonitorError::from(MockError(GpioErrorKind::HardwareFailure)); + + let src = core::error::Error::source(&err).expect("source must carry the HAL error"); + assert_eq!(src.to_string(), "MockError(HardwareFailure)"); + + let cause = src + .downcast_ref::>() + .expect("source must downcast to the concrete cause"); + assert_eq!(cause.0, MockError(GpioErrorKind::HardwareFailure)); + } +} diff --git a/services/fwmanager/hal-adapters/src/lib.rs b/services/fwmanager/hal-adapters/src/lib.rs index da8ebf30..f4eb93c4 100644 --- a/services/fwmanager/hal-adapters/src/lib.rs +++ b/services/fwmanager/hal-adapters/src/lib.rs @@ -5,13 +5,16 @@ //! //! Each type here implements a capability trait from `fwmanager-api` against //! a HAL-blocking trait: [`HalBootControl`] drives `BootControl` over a -//! `ResetControl` line. Adapters live in this crate — not in the leaf +//! `ResetControl` line, and [`GpioBootMonitor`] reads `BootMonitor` off a +//! `GpioPort` input line. Adapters live in this crate — not in the leaf //! `fwmanager-api` — so that depending on a capability contract never pulls //! in the HAL. A transport-backed adapter belongs in its own crate depending //! on its own stack, by the same rule. #![cfg_attr(not(test), no_std)] +mod gpio_boot_monitor; mod hal_boot_control; +pub use gpio_boot_monitor::{GpioBootMonitor, GpioCause, MonitorError}; pub use hal_boot_control::{BootError, HalBootControl};