From 6ec92a8425a178d78e4d6309d544397a3724d313 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Thu, 16 Jul 2026 11:07:39 +0200 Subject: [PATCH 1/4] fwmanager: Add BootControl trait and HAL adapter Introduce the Boot Orchestrator's actuation capability: the BootControl trait (hold_in_reset / release) and HalBootControl, which binds one HAL ResetControl line to a managed device. Includes a host unit test verifying that holding a device in reset asserts exactly its configured line. Includes tests that release deasserts the device's configured line and that a controller error surfaces through BootControl unchanged. Extend the fake reset controller with opt-in failure injection to drive the error case. Closes: https://github.com/9elements/openprot/issues/2 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Christina Quast --- services/fwmanager/api/BUILD.bazel | 23 +++ services/fwmanager/api/src/boot_control.rs | 206 +++++++++++++++++++++ services/fwmanager/api/src/lib.rs | 15 ++ 3 files changed, 244 insertions(+) create mode 100644 services/fwmanager/api/BUILD.bazel create mode 100644 services/fwmanager/api/src/boot_control.rs create mode 100644 services/fwmanager/api/src/lib.rs diff --git a/services/fwmanager/api/BUILD.bazel b/services/fwmanager/api/BUILD.bazel new file mode 100644 index 00000000..d78ebb6a --- /dev/null +++ b/services/fwmanager/api/BUILD.bazel @@ -0,0 +1,23 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") + +rust_library( + name = "fwmanager_api", + srcs = [ + "src/boot_control.rs", + "src/lib.rs", + ], + edition = "2024", + visibility = ["//visibility:public"], + deps = [ + "//hal/blocking", + ], +) + +# Host tests: build on the host platform, no kernel/QEMU. +rust_test( + name = "fwmanager_api_test", + crate = ":fwmanager_api", +) diff --git a/services/fwmanager/api/src/boot_control.rs b/services/fwmanager/api/src/boot_control.rs new file mode 100644 index 00000000..6fce01de --- /dev/null +++ b/services/fwmanager/api/src/boot_control.rs @@ -0,0 +1,206 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Actuation capability: hold a managed device in reset and release it. + +use openprot_hal_blocking::system_control::ResetControl; + +/// Actuation capability: hold a managed device in reset and release it. +/// +/// Stateless pass-through by design — sequencing discipline (hold before +/// release, release only after verification) belongs to the orchestrator +/// flows, where it is observable behavior. +/// +/// # How the orchestrator uses it +/// +/// During verified release the orchestrator parks a device in +/// reset, verifies its active slot while nothing is running, then releases +/// it to boot the image it just checked: +/// +/// ```ignore +/// // `dev` is this device's BootControl, obtained from the registry. +/// fn verified_release(dev: &mut D) -> Result<(), D::Error> { +/// dev.hold_in_reset()?; // freeze the device; its flash is now safe to inspect +/// verify_active_slot()?; // re-hash + signature check (a separate capability) +/// dev.release()?; // run the just-verified image +/// Ok(()) +/// } +/// ``` +/// +/// In a trial boot the same hold/release pair brackets a +/// watchdog-bounded window; the new slot is committed only if a good boot is +/// observed, otherwise the device falls back to the previous slot: +/// +/// ```ignore +/// dev.hold_in_reset()?; +/// store.set_trial(new_slot)?; // tentative boot selection — not yet committed +/// dev.release()?; // boot the trial image +/// match monitor.await_boot(window)? { +/// Booted => store.commit(new_slot)?, // observed good => make it active +/// Failed | Timeout => { /* nothing committed; previous slot still active */ } +/// } +/// ``` +pub trait BootControl { + /// The error type reported by this device's boot control. + type Error: core::fmt::Debug; + + /// Holds the device in reset. + fn hold_in_reset(&mut self) -> Result<(), Self::Error>; + + /// Releases the device from reset. + fn release(&mut self) -> Result<(), Self::Error>; +} + +/// Binds one reset line of a HAL reset controller to one managed device. +pub struct HalBootControl { + controller: C, + reset_id: C::ResetId, +} + +impl HalBootControl { + /// Creates the binding of `controller`'s line `reset_id` to a device. + pub fn new(controller: C, reset_id: C::ResetId) -> Self { + Self { + controller, + reset_id, + } + } + + /// Read access to the underlying controller. + pub fn controller(&self) -> &C { + &self.controller + } +} + +impl BootControl for HalBootControl { + type Error = C::Error; + + fn hold_in_reset(&mut self) -> Result<(), Self::Error> { + self.controller.reset_assert(&self.reset_id) + } + + fn release(&mut self) -> Result<(), Self::Error> { + self.controller.reset_deassert(&self.reset_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use core::time::Duration; + use openprot_hal_blocking::system_control::{Error, ErrorKind, ErrorType}; + + // Normally set in config.rs + const BMC_LINE: u8 = 7; + + #[derive(Debug, PartialEq, Eq, Clone, Copy)] + enum Call { + Assert(u8), + Deassert(u8), + } + + #[derive(Debug, PartialEq, Eq, Clone, Copy)] + struct MockError(ErrorKind); + + impl Error for MockError { + fn kind(&self) -> ErrorKind { + self.0 + } + } + + /// Mock HAL reset controller: records every call it receives. + struct MockResetController { + calls: Vec, + fail: Option, + } + + impl MockResetController { + fn new() -> Self { + Self { + calls: Vec::new(), + fail: None, + } + } + + fn failing(kind: ErrorKind) -> Self { + Self { + calls: Vec::new(), + fail: Some(kind), + } + } + + fn calls(&self) -> &[Call] { + &self.calls + } + } + + impl ErrorType for MockResetController { + type Error = MockError; + } + + impl ResetControl for MockResetController { + type ResetId = u8; // Reset line is GPIO here. Real driver should use Enum + + fn reset_assert(&mut self, reset_id: &u8) -> Result<(), MockError> { + if let Some(kind) = self.fail { + return Err(MockError(kind)); + } + self.calls.push(Call::Assert(*reset_id)); + Ok(()) + } + + fn reset_deassert(&mut self, reset_id: &u8) -> Result<(), MockError> { + if let Some(kind) = self.fail { + return Err(MockError(kind)); + } + self.calls.push(Call::Deassert(*reset_id)); + Ok(()) + } + + fn reset_pulse(&mut self, _: &u8, _: Duration) -> Result<(), MockError> { + panic!( + "BootControl must never pulse: hold and release are distinct orchestrator steps" + ); + } + + fn reset_is_asserted(&self, _: &u8) -> Result { + panic!("BootControl does not query line state"); + } + } + + // `hold_in_reset()` must assert exactly the configured line (BMC = 7) + // and nothing else. + #[test] + fn holding_a_device_in_reset_asserts_its_configured_line() { + let mut bmc = HalBootControl::new(MockResetController::new(), BMC_LINE); + + bmc.hold_in_reset().expect("hold_in_reset failed"); + + assert_eq!(bmc.controller().calls(), &[Call::Assert(BMC_LINE)]); + } + + #[test] + fn holding_a_device_in_reset_deasserts_its_configured_line() { + let mut bmc = HalBootControl::new(MockResetController::new(), BMC_LINE); + + bmc.hold_in_reset().expect("hold_in_reset failed"); + bmc.release().expect("release failed"); + + assert_eq!( + bmc.controller().calls(), + &[Call::Assert(BMC_LINE), Call::Deassert(BMC_LINE)] + ); + } + + #[test] + fn controller_error_propagates_through_boot_control() { + let mut bmc = HalBootControl::new( + MockResetController::failing(ErrorKind::InvalidResetId), + BMC_LINE, + ); + let err = bmc + .hold_in_reset() + .expect_err("expected the controller error to propagate"); + assert_eq!(err.kind(), ErrorKind::InvalidResetId); + } +} diff --git a/services/fwmanager/api/src/lib.rs b/services/fwmanager/api/src/lib.rs new file mode 100644 index 00000000..8ab488a2 --- /dev/null +++ b/services/fwmanager/api/src/lib.rs @@ -0,0 +1,15 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Device-facing capability traits for the Boot Orchestrator. +//! +//! `BootControl` is the actuation capability: the orchestrator drives a +//! single managed device's reset without knowing which controller line it +//! maps to. The binding of a HAL reset controller line to a device happens +//! once, in platform configuration, via [`HalBootControl`]. + +#![cfg_attr(not(test), no_std)] + +mod boot_control; + +pub use boot_control::{BootControl, HalBootControl}; From daa9aa7004ac3a33f6ee4f334c10f30b9c590a64 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Tue, 21 Jul 2026 11:21:00 +0200 Subject: [PATCH 2/4] Update services/fwmanager/api/src/boot_control.rs Co-authored-by: Marvin Gudel --- services/fwmanager/api/src/boot_control.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/fwmanager/api/src/boot_control.rs b/services/fwmanager/api/src/boot_control.rs index 6fce01de..e277c21f 100644 --- a/services/fwmanager/api/src/boot_control.rs +++ b/services/fwmanager/api/src/boot_control.rs @@ -180,7 +180,7 @@ mod tests { } #[test] - fn holding_a_device_in_reset_deasserts_its_configured_line() { + fn releasing_a_device_from_reset_deasserts_its_configured_line() { let mut bmc = HalBootControl::new(MockResetController::new(), BMC_LINE); bmc.hold_in_reset().expect("hold_in_reset failed"); From 5fb6c516252d6081942bc5a9514c23179c543de4 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Tue, 21 Jul 2026 14:37:46 +0200 Subject: [PATCH 3/4] fwmanager: Strengthen BootControl error contract Tighten BootControl::Error from Debug-only to `core::error::Error + system_control::Error` so the orchestrator gets Display and a source() cause chain instead of just the Debug dump, and so generic `BootControl` consumers can categorize failures via kind() without downcasting to a concrete error type. HAL reset controllers keep the existing Error/kind() pattern unchanged; the new BootError adapter supplies the core::error::Error machinery over any HAL error, so no per-implementation work is required. Cover the combined bound with a compile-time fence and tests for Display output, source(), dyn downcast, and kind() reached through a generic BootControl. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Christina Quast --- services/fwmanager/api/src/boot_control.rs | 119 +++++++++++++++++++-- 1 file changed, 112 insertions(+), 7 deletions(-) diff --git a/services/fwmanager/api/src/boot_control.rs b/services/fwmanager/api/src/boot_control.rs index e277c21f..de6e0777 100644 --- a/services/fwmanager/api/src/boot_control.rs +++ b/services/fwmanager/api/src/boot_control.rs @@ -3,7 +3,7 @@ //! Actuation capability: hold a managed device in reset and release it. -use openprot_hal_blocking::system_control::ResetControl; +use openprot_hal_blocking::system_control::{Error as HalError, ErrorKind, ResetControl}; /// Actuation capability: hold a managed device in reset and release it. /// @@ -42,7 +42,14 @@ use openprot_hal_blocking::system_control::ResetControl; /// ``` pub trait BootControl { /// The error type reported by this device's boot control. - type Error: core::fmt::Debug; + /// + /// Requires [`core::error::Error`] (in `core` since Rust 1.81) so the + /// orchestrator gets `Display` and a `source()` cause chain, not just the + /// `Debug` dump, *and* the HAL [`Error`](HalError) trait so generic + /// consumers can categorize failures via `kind()` without knowing the + /// concrete error type. HAL-backed implementations satisfy both through + /// [`BootError`]. + type Error: core::error::Error + HalError; /// Holds the device in reset. fn hold_in_reset(&mut self) -> Result<(), Self::Error>; @@ -51,6 +58,32 @@ pub trait BootControl { fn release(&mut self) -> Result<(), Self::Error>; } +/// Adapts any HAL system-control error into a [`core::error::Error`]. +/// +/// Reset controllers keep implementing the HAL `Error`/`kind()` pattern +/// unchanged; this wrapper supplies the `Display` and `core::error::Error` +/// machinery [`BootControl::Error`] requires, so no per-implementation work is +/// needed. The underlying category stays reachable via [`BootError::kind`]. +#[derive(Debug)] +pub struct BootError(pub E); + +impl core::fmt::Display for BootError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "boot control error: {:?}", self.0.kind()) + } +} + +impl core::error::Error for BootError {} + +impl HalError for BootError { + /// The wrapped error's category, so a generic `BootControl` consumer can + /// branch on it (retry `Busy`, escalate `HardwareFailure`, ...) without + /// knowing the concrete error type. + fn kind(&self) -> ErrorKind { + self.0.kind() + } +} + /// Binds one reset line of a HAL reset controller to one managed device. pub struct HalBootControl { controller: C, @@ -73,14 +106,18 @@ impl HalBootControl { } impl BootControl for HalBootControl { - type Error = C::Error; + type Error = BootError; fn hold_in_reset(&mut self) -> Result<(), Self::Error> { - self.controller.reset_assert(&self.reset_id) + self.controller + .reset_assert(&self.reset_id) + .map_err(BootError) } fn release(&mut self) -> Result<(), Self::Error> { - self.controller.reset_deassert(&self.reset_id) + self.controller + .reset_deassert(&self.reset_id) + .map_err(BootError) } } @@ -88,7 +125,7 @@ impl BootControl for HalBootControl { mod tests { use super::*; use core::time::Duration; - use openprot_hal_blocking::system_control::{Error, ErrorKind, ErrorType}; + use openprot_hal_blocking::system_control::{Error as HalError, ErrorKind, ErrorType}; // Normally set in config.rs const BMC_LINE: u8 = 7; @@ -102,7 +139,7 @@ mod tests { #[derive(Debug, PartialEq, Eq, Clone, Copy)] struct MockError(ErrorKind); - impl Error for MockError { + impl HalError for MockError { fn kind(&self) -> ErrorKind { self.0 } @@ -203,4 +240,72 @@ mod tests { .expect_err("expected the controller error to propagate"); assert_eq!(err.kind(), ErrorKind::InvalidResetId); } + + // ---- Error contract the orchestrator depends on ------------------------ + // + // `BootControl::Error` requires the modern `core::error::Error` (in `core` + // since Rust 1.81) instead of the old `Debug`-only bound. `HalBootControl` + // satisfies it through the `BootError` adapter, which supplies `Display` + + // the `core::error::Error` marker over any HAL error while passing `kind()` + // through for categorization. + + /// Compile-time fence: the error must satisfy `core::error::Error` + /// (Display + source) *and* the HAL `Error` (with `kind()`), so that + /// generic `BootControl` consumers get both. Fails to compile if either is + /// dropped. + fn _assert_error_contract() {} + + #[test] + fn boot_error_satisfies_the_full_contract() { + _assert_error_contract::>(); + } + + #[test] + fn boot_error_renders_a_human_readable_message() { + let err = BootError(MockError(ErrorKind::HardwareFailure)); + + // `Display`, not the `Debug` dump — this is what `core::error::Error` + // buys over the old `Debug`-only bound. + assert_eq!(err.to_string(), "boot control error: HardwareFailure"); + } + + #[test] + fn boot_error_is_a_leaf_with_no_source() { + let err = BootError(MockError(ErrorKind::Timeout)); + + // HAL errors carry no nested `core::error::Error` cause. + assert!(core::error::Error::source(&err).is_none()); + } + + #[test] + fn dyn_error_downcasts_back_to_the_concrete_type() { + let err = BootError(MockError(ErrorKind::PermissionDenied)); + + let dyn_err: &dyn core::error::Error = &err; + let recovered = dyn_err + .downcast_ref::>() + .expect("expected to recover the concrete error type"); + assert_eq!(recovered.kind(), ErrorKind::PermissionDenied); + } + + /// Categorize a failure from *any* `BootControl` — the whole point of the + /// `kind()` bound is that this compiles for a generic `D`, not just a + /// concrete error type. + fn categorize_hold_failure(dev: &mut D) -> Option { + dev.hold_in_reset().err().map(|e| e.kind()) + } + + #[test] + fn error_kind_is_reachable_through_generic_boot_control() { + let mut bmc = HalBootControl::new( + MockResetController::failing(ErrorKind::HardwareFailure), + BMC_LINE, + ); + + // No concrete error type in sight — `kind()` comes from the trait bound. + assert_eq!( + categorize_hold_failure(&mut bmc), + Some(ErrorKind::HardwareFailure) + ); + } } From 19942a96c58970ed6aa65bf16e8d41802d0bcd1a Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Wed, 22 Jul 2026 11:03:08 +0200 Subject: [PATCH 4/4] fwmanager: Move HalBootControl into hal-adapters crate Split the HAL-backed adapter out of fwmanager-api so the api crate is a dependency-free leaf holding only the BootControl capability contract, matching the layout of 9elements/openprot#20. HalBootControl and the BootError wrapper move to the new fwmanager-hal-adapters crate, which depends on //hal/blocking and the api crate. Drop the system_control::Error bound from BootControl::Error, keeping core::error::Error only: requiring the HAL error trait in the api crate would keep the HAL dependency the split is meant to remove. BootError still exposes kind(), and the generic-consumer test now recovers the error category by downcasting the dyn core::error::Error instead of relying on the removed bound. Add impl From for BootError so implementations forwarding HAL calls can use ? instead of .map_err(BootError), and switch HalBootControl over to it. Add api-side tests proving the contract stays implementable with no HAL in sight, and cover the release error path in the adapter. Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast --- services/fwmanager/api/BUILD.bazel | 3 - services/fwmanager/api/src/boot_control.rs | 272 +++--------------- services/fwmanager/api/src/lib.rs | 12 +- services/fwmanager/hal-adapters/BUILD.bazel | 24 ++ .../hal-adapters/src/hal_boot_control.rs | 265 +++++++++++++++++ services/fwmanager/hal-adapters/src/lib.rs | 17 ++ 6 files changed, 357 insertions(+), 236 deletions(-) create mode 100644 services/fwmanager/hal-adapters/BUILD.bazel create mode 100644 services/fwmanager/hal-adapters/src/hal_boot_control.rs create mode 100644 services/fwmanager/hal-adapters/src/lib.rs diff --git a/services/fwmanager/api/BUILD.bazel b/services/fwmanager/api/BUILD.bazel index d78ebb6a..e5653a61 100644 --- a/services/fwmanager/api/BUILD.bazel +++ b/services/fwmanager/api/BUILD.bazel @@ -11,9 +11,6 @@ rust_library( ], edition = "2024", visibility = ["//visibility:public"], - deps = [ - "//hal/blocking", - ], ) # Host tests: build on the host platform, no kernel/QEMU. diff --git a/services/fwmanager/api/src/boot_control.rs b/services/fwmanager/api/src/boot_control.rs index de6e0777..ae13cd57 100644 --- a/services/fwmanager/api/src/boot_control.rs +++ b/services/fwmanager/api/src/boot_control.rs @@ -1,9 +1,7 @@ // Licensed under the Apache-2.0 license // SPDX-License-Identifier: Apache-2.0 -//! Actuation capability: hold a managed device in reset and release it. - -use openprot_hal_blocking::system_control::{Error as HalError, ErrorKind, ResetControl}; +//! Home of the [`BootControl`] capability contract. /// Actuation capability: hold a managed device in reset and release it. /// @@ -44,12 +42,12 @@ pub trait BootControl { /// The error type reported by this device's boot control. /// /// Requires [`core::error::Error`] (in `core` since Rust 1.81) so the - /// orchestrator gets `Display` and a `source()` cause chain, not just the - /// `Debug` dump, *and* the HAL [`Error`](HalError) trait so generic - /// consumers can categorize failures via `kind()` without knowing the - /// concrete error type. HAL-backed implementations satisfy both through - /// [`BootError`]. - type Error: core::error::Error + HalError; + /// 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; /// Holds the device in reset. fn hold_in_reset(&mut self) -> Result<(), Self::Error>; @@ -58,254 +56,68 @@ pub trait BootControl { fn release(&mut self) -> Result<(), Self::Error>; } -/// Adapts any HAL system-control error into a [`core::error::Error`]. -/// -/// Reset controllers keep implementing the HAL `Error`/`kind()` pattern -/// unchanged; this wrapper supplies the `Display` and `core::error::Error` -/// machinery [`BootControl::Error`] requires, so no per-implementation work is -/// needed. The underlying category stays reachable via [`BootError::kind`]. -#[derive(Debug)] -pub struct BootError(pub E); - -impl core::fmt::Display for BootError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "boot control error: {:?}", self.0.kind()) - } -} - -impl core::error::Error for BootError {} - -impl HalError for BootError { - /// The wrapped error's category, so a generic `BootControl` consumer can - /// branch on it (retry `Busy`, escalate `HardwareFailure`, ...) without - /// knowing the concrete error type. - fn kind(&self) -> ErrorKind { - self.0.kind() - } -} - -/// Binds one reset line of a HAL reset controller to one managed device. -pub struct HalBootControl { - controller: C, - reset_id: C::ResetId, -} - -impl HalBootControl { - /// Creates the binding of `controller`'s line `reset_id` to a device. - pub fn new(controller: C, reset_id: C::ResetId) -> Self { - Self { - controller, - reset_id, - } - } - - /// Read access to the underlying controller. - pub fn controller(&self) -> &C { - &self.controller - } -} - -impl BootControl for HalBootControl { - type Error = BootError; - - fn hold_in_reset(&mut self) -> Result<(), Self::Error> { - self.controller - .reset_assert(&self.reset_id) - .map_err(BootError) - } - - fn release(&mut self) -> Result<(), Self::Error> { - self.controller - .reset_deassert(&self.reset_id) - .map_err(BootError) - } -} - #[cfg(test)] mod tests { use super::*; - use core::time::Duration; - use openprot_hal_blocking::system_control::{Error as HalError, ErrorKind, ErrorType}; - - // Normally set in config.rs - const BMC_LINE: u8 = 7; - #[derive(Debug, PartialEq, Eq, Clone, Copy)] - enum Call { - Assert(u8), - Deassert(u8), + // A BootControl implemented against no HAL at all — the contract must be + // satisfiable from any stack (mock, IPC proxy, simulator). Re-adding a + // HAL-flavored bound on `Error` breaks this compile. + struct MockDevice { + fail: bool, } - #[derive(Debug, PartialEq, Eq, Clone, Copy)] - struct MockError(ErrorKind); - - impl HalError for MockError { - fn kind(&self) -> ErrorKind { - self.0 - } - } - - /// Mock HAL reset controller: records every call it receives. - struct MockResetController { - calls: Vec, - fail: Option, - } - - impl MockResetController { - fn new() -> Self { - Self { - calls: Vec::new(), - fail: None, - } - } - - fn failing(kind: ErrorKind) -> Self { - Self { - calls: Vec::new(), - fail: Some(kind), - } - } + #[derive(Debug, PartialEq, Eq)] + struct MockFault; - fn calls(&self) -> &[Call] { - &self.calls + impl core::fmt::Display for MockFault { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("mock device fault") } } - impl ErrorType for MockResetController { - type Error = MockError; - } + impl core::error::Error for MockFault {} - impl ResetControl for MockResetController { - type ResetId = u8; // Reset line is GPIO here. Real driver should use Enum + impl BootControl for MockDevice { + type Error = MockFault; - fn reset_assert(&mut self, reset_id: &u8) -> Result<(), MockError> { - if let Some(kind) = self.fail { - return Err(MockError(kind)); + fn hold_in_reset(&mut self) -> Result<(), MockFault> { + if self.fail { + Err(MockFault) + } else { + Ok(()) } - self.calls.push(Call::Assert(*reset_id)); - Ok(()) } - fn reset_deassert(&mut self, reset_id: &u8) -> Result<(), MockError> { - if let Some(kind) = self.fail { - return Err(MockError(kind)); + fn release(&mut self) -> Result<(), MockFault> { + if self.fail { + Err(MockFault) + } else { + Ok(()) } - self.calls.push(Call::Deassert(*reset_id)); - Ok(()) - } - - fn reset_pulse(&mut self, _: &u8, _: Duration) -> Result<(), MockError> { - panic!( - "BootControl must never pulse: hold and release are distinct orchestrator steps" - ); - } - - fn reset_is_asserted(&self, _: &u8) -> Result { - panic!("BootControl does not query line state"); } } - // `hold_in_reset()` must assert exactly the configured line (BMC = 7) - // and nothing else. - #[test] - fn holding_a_device_in_reset_asserts_its_configured_line() { - let mut bmc = HalBootControl::new(MockResetController::new(), BMC_LINE); - - bmc.hold_in_reset().expect("hold_in_reset failed"); - - assert_eq!(bmc.controller().calls(), &[Call::Assert(BMC_LINE)]); + /// The doc example's orchestrator shape, generic over any `BootControl`. + fn hold_then_release(dev: &mut D) -> Result<(), D::Error> { + dev.hold_in_reset()?; + dev.release() } #[test] - fn releasing_a_device_from_reset_deasserts_its_configured_line() { - let mut bmc = HalBootControl::new(MockResetController::new(), BMC_LINE); + fn contract_is_implementable_without_the_hal() { + let mut dev = MockDevice { fail: false }; - bmc.hold_in_reset().expect("hold_in_reset failed"); - bmc.release().expect("release failed"); - - assert_eq!( - bmc.controller().calls(), - &[Call::Assert(BMC_LINE), Call::Deassert(BMC_LINE)] - ); + hold_then_release(&mut dev).expect("hold/release failed"); } #[test] - fn controller_error_propagates_through_boot_control() { - let mut bmc = HalBootControl::new( - MockResetController::failing(ErrorKind::InvalidResetId), - BMC_LINE, - ); - let err = bmc - .hold_in_reset() - .expect_err("expected the controller error to propagate"); - assert_eq!(err.kind(), ErrorKind::InvalidResetId); - } + fn errors_surface_through_the_generic_seam() { + let mut dev = MockDevice { fail: true }; - // ---- Error contract the orchestrator depends on ------------------------ - // - // `BootControl::Error` requires the modern `core::error::Error` (in `core` - // since Rust 1.81) instead of the old `Debug`-only bound. `HalBootControl` - // satisfies it through the `BootError` adapter, which supplies `Display` + - // the `core::error::Error` marker over any HAL error while passing `kind()` - // through for categorization. - - /// Compile-time fence: the error must satisfy `core::error::Error` - /// (Display + source) *and* the HAL `Error` (with `kind()`), so that - /// generic `BootControl` consumers get both. Fails to compile if either is - /// dropped. - fn _assert_error_contract() {} - - #[test] - fn boot_error_satisfies_the_full_contract() { - _assert_error_contract::>(); - } - - #[test] - fn boot_error_renders_a_human_readable_message() { - let err = BootError(MockError(ErrorKind::HardwareFailure)); - - // `Display`, not the `Debug` dump — this is what `core::error::Error` - // buys over the old `Debug`-only bound. - assert_eq!(err.to_string(), "boot control error: HardwareFailure"); - } - - #[test] - fn boot_error_is_a_leaf_with_no_source() { - let err = BootError(MockError(ErrorKind::Timeout)); - - // HAL errors carry no nested `core::error::Error` cause. - assert!(core::error::Error::source(&err).is_none()); - } - - #[test] - fn dyn_error_downcasts_back_to_the_concrete_type() { - let err = BootError(MockError(ErrorKind::PermissionDenied)); - - let dyn_err: &dyn core::error::Error = &err; - let recovered = dyn_err - .downcast_ref::>() - .expect("expected to recover the concrete error type"); - assert_eq!(recovered.kind(), ErrorKind::PermissionDenied); - } - - /// Categorize a failure from *any* `BootControl` — the whole point of the - /// `kind()` bound is that this compiles for a generic `D`, not just a - /// concrete error type. - fn categorize_hold_failure(dev: &mut D) -> Option { - dev.hold_in_reset().err().map(|e| e.kind()) - } - - #[test] - fn error_kind_is_reachable_through_generic_boot_control() { - let mut bmc = HalBootControl::new( - MockResetController::failing(ErrorKind::HardwareFailure), - BMC_LINE, - ); + let err = hold_then_release(&mut dev).expect_err("expected the device fault"); - // No concrete error type in sight — `kind()` comes from the trait bound. - assert_eq!( - categorize_hold_failure(&mut bmc), - Some(ErrorKind::HardwareFailure) - ); + // Display comes from the core::error::Error bound, not a Debug dump. + assert_eq!(err.to_string(), "mock device fault"); } } diff --git a/services/fwmanager/api/src/lib.rs b/services/fwmanager/api/src/lib.rs index 8ab488a2..8d577b97 100644 --- a/services/fwmanager/api/src/lib.rs +++ b/services/fwmanager/api/src/lib.rs @@ -5,11 +5,17 @@ //! //! `BootControl` is the actuation capability: the orchestrator drives a //! single managed device's reset without knowing which controller line it -//! maps to. The binding of a HAL reset controller line to a device happens -//! once, in platform configuration, via [`HalBootControl`]. +//! maps to. +//! +//! 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. #![cfg_attr(not(test), no_std)] mod boot_control; -pub use boot_control::{BootControl, HalBootControl}; +pub use boot_control::BootControl; diff --git a/services/fwmanager/hal-adapters/BUILD.bazel b/services/fwmanager/hal-adapters/BUILD.bazel new file mode 100644 index 00000000..aff19e34 --- /dev/null +++ b/services/fwmanager/hal-adapters/BUILD.bazel @@ -0,0 +1,24 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") + +rust_library( + name = "fwmanager_hal_adapters", + srcs = [ + "src/hal_boot_control.rs", + "src/lib.rs", + ], + edition = "2024", + visibility = ["//visibility:public"], + deps = [ + "//hal/blocking", + "//services/fwmanager/api:fwmanager_api", + ], +) + +# Host tests: build on the host platform, no kernel/QEMU. +rust_test( + name = "fwmanager_hal_adapters_test", + crate = ":fwmanager_hal_adapters", +) diff --git a/services/fwmanager/hal-adapters/src/hal_boot_control.rs b/services/fwmanager/hal-adapters/src/hal_boot_control.rs new file mode 100644 index 00000000..b4ef418a --- /dev/null +++ b/services/fwmanager/hal-adapters/src/hal_boot_control.rs @@ -0,0 +1,265 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! HAL-backed [`BootControl`]: bind one reset-controller line to a device. + +use fwmanager_api::BootControl; +use openprot_hal_blocking::system_control::{Error as HalError, ErrorKind, ResetControl}; + +/// Adapts any HAL system-control error into a [`core::error::Error`]. +/// +/// Reset controllers keep implementing the HAL `Error`/`kind()` pattern +/// unchanged; this wrapper supplies the `Display` and `core::error::Error` +/// machinery [`BootControl::Error`] requires, so no per-implementation work is +/// needed. The underlying category stays reachable via [`BootError::kind`]. +#[derive(Debug)] +pub struct BootError(pub E); + +impl core::fmt::Display for BootError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "boot control error: {:?}", self.0.kind()) + } +} + +impl core::error::Error for BootError {} + +impl HalError for BootError { + fn kind(&self) -> ErrorKind { + self.0.kind() + } +} + +impl From for BootError { + fn from(err: E) -> Self { + Self(err) + } +} + +/// Binds one reset line of a HAL reset controller to one managed device. +pub struct HalBootControl { + controller: C, + reset_id: C::ResetId, +} + +impl HalBootControl { + pub fn new(controller: C, reset_id: C::ResetId) -> Self { + Self { + controller, + reset_id, + } + } + + pub fn controller(&self) -> &C { + &self.controller + } +} + +impl BootControl for HalBootControl { + type Error = BootError; + + fn hold_in_reset(&mut self) -> Result<(), Self::Error> { + self.controller.reset_assert(&self.reset_id)?; + Ok(()) + } + + fn release(&mut self) -> Result<(), Self::Error> { + self.controller.reset_deassert(&self.reset_id)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use core::time::Duration; + use openprot_hal_blocking::system_control::{Error as HalError, ErrorKind, ErrorType}; + + // Normally set in config.rs + const BMC_LINE: u8 = 7; + + #[derive(Debug, PartialEq, Eq, Clone, Copy)] + enum Call { + Assert(u8), + Deassert(u8), + } + + #[derive(Debug, PartialEq, Eq, Clone, Copy)] + struct MockError(ErrorKind); + + impl HalError for MockError { + fn kind(&self) -> ErrorKind { + self.0 + } + } + + /// Mock HAL reset controller: records every call it receives. + struct MockResetController { + calls: Vec, + fail: Option, + } + + impl MockResetController { + fn new() -> Self { + Self { + calls: Vec::new(), + fail: None, + } + } + + fn failing(kind: ErrorKind) -> Self { + Self { + calls: Vec::new(), + fail: Some(kind), + } + } + + fn calls(&self) -> &[Call] { + &self.calls + } + } + + impl ErrorType for MockResetController { + type Error = MockError; + } + + impl ResetControl for MockResetController { + type ResetId = u8; // Reset line is GPIO here. Real driver should use Enum + + fn reset_assert(&mut self, reset_id: &u8) -> Result<(), MockError> { + if let Some(kind) = self.fail { + return Err(MockError(kind)); + } + self.calls.push(Call::Assert(*reset_id)); + Ok(()) + } + + fn reset_deassert(&mut self, reset_id: &u8) -> Result<(), MockError> { + if let Some(kind) = self.fail { + return Err(MockError(kind)); + } + self.calls.push(Call::Deassert(*reset_id)); + Ok(()) + } + + fn reset_pulse(&mut self, _: &u8, _: Duration) -> Result<(), MockError> { + panic!( + "BootControl must never pulse: hold and release are distinct orchestrator steps" + ); + } + + fn reset_is_asserted(&self, _: &u8) -> Result { + panic!("BootControl does not query line state"); + } + } + + // `hold_in_reset()` must assert exactly the configured line (BMC = 7) + // and nothing else. + #[test] + fn holding_a_device_in_reset_asserts_its_configured_line() { + let mut bmc = HalBootControl::new(MockResetController::new(), BMC_LINE); + + bmc.hold_in_reset().expect("hold_in_reset failed"); + + assert_eq!(bmc.controller().calls(), &[Call::Assert(BMC_LINE)]); + } + + #[test] + fn releasing_a_device_from_reset_deasserts_its_configured_line() { + let mut bmc = HalBootControl::new(MockResetController::new(), BMC_LINE); + + bmc.hold_in_reset().expect("hold_in_reset failed"); + bmc.release().expect("release failed"); + + assert_eq!( + bmc.controller().calls(), + &[Call::Assert(BMC_LINE), Call::Deassert(BMC_LINE)] + ); + } + + #[test] + fn controller_error_propagates_through_boot_control() { + let mut bmc = HalBootControl::new( + MockResetController::failing(ErrorKind::InvalidResetId), + BMC_LINE, + ); + let err = bmc + .hold_in_reset() + .expect_err("expected the controller error to propagate"); + assert_eq!(err.kind(), ErrorKind::InvalidResetId); + } + + #[test] + fn controller_error_propagates_through_release() { + let mut bmc = + HalBootControl::new(MockResetController::failing(ErrorKind::Timeout), BMC_LINE); + let err = bmc + .release() + .expect_err("expected the controller error to propagate"); + assert_eq!(err.kind(), ErrorKind::Timeout); + } + + /// Compile-time fence: the adapter error must satisfy `core::error::Error` + /// (Display + source) *and* the HAL `Error` (with `kind()`). Fails to + /// compile if either is dropped. + fn _assert_error_contract() {} + + #[test] + fn boot_error_satisfies_the_full_contract() { + _assert_error_contract::>(); + } + + #[test] + fn boot_error_renders_a_human_readable_message() { + let err = BootError(MockError(ErrorKind::HardwareFailure)); + + assert_eq!(err.to_string(), "boot control error: HardwareFailure"); + } + + #[test] + fn boot_error_is_a_leaf_with_no_source() { + let err = BootError(MockError(ErrorKind::Timeout)); + + // HAL errors carry no nested `core::error::Error` cause. + assert!(core::error::Error::source(&err).is_none()); + } + + #[test] + fn dyn_error_downcasts_back_to_the_concrete_type() { + let err = BootError(MockError(ErrorKind::PermissionDenied)); + + let dyn_err: &dyn core::error::Error = &err; + let recovered = dyn_err + .downcast_ref::>() + .expect("expected to recover the concrete error type"); + assert_eq!(recovered.kind(), ErrorKind::PermissionDenied); + } + + /// Categorize a failure from *any* `BootControl` — the trait bound only + /// promises `core::error::Error`, so a generic consumer recovers the HAL + /// category by downcasting to the adapter error it knows about. + fn categorize_hold_failure(dev: &mut D) -> Option + where + D::Error: 'static, + { + let err = dev.hold_in_reset().err()?; + let dyn_err: &dyn core::error::Error = &err; + dyn_err + .downcast_ref::>() + .map(|e| e.kind()) + } + + #[test] + fn error_kind_is_reachable_through_generic_boot_control() { + let mut bmc = HalBootControl::new( + MockResetController::failing(ErrorKind::HardwareFailure), + BMC_LINE, + ); + + // No concrete error type in the signature — the HAL category comes + // back through the `dyn Error` downcast. + assert_eq!( + categorize_hold_failure(&mut bmc), + Some(ErrorKind::HardwareFailure) + ); + } +} diff --git a/services/fwmanager/hal-adapters/src/lib.rs b/services/fwmanager/hal-adapters/src/lib.rs new file mode 100644 index 00000000..da8ebf30 --- /dev/null +++ b/services/fwmanager/hal-adapters/src/lib.rs @@ -0,0 +1,17 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! HAL-backed adapters for the Boot Orchestrator capability traits. +//! +//! 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 +//! `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 hal_boot_control; + +pub use hal_boot_control::{BootError, HalBootControl};