diff --git a/services/fwmanager/api/BUILD.bazel b/services/fwmanager/api/BUILD.bazel new file mode 100644 index 00000000..e5653a61 --- /dev/null +++ b/services/fwmanager/api/BUILD.bazel @@ -0,0 +1,20 @@ +# 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"], +) + +# 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..ae13cd57 --- /dev/null +++ b/services/fwmanager/api/src/boot_control.rs @@ -0,0 +1,123 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Home of the [`BootControl`] capability contract. + +/// 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. + /// + /// 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; + + /// 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>; +} + +#[cfg(test)] +mod tests { + use super::*; + + // 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)] + struct MockFault; + + impl core::fmt::Display for MockFault { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("mock device fault") + } + } + + impl core::error::Error for MockFault {} + + impl BootControl for MockDevice { + type Error = MockFault; + + fn hold_in_reset(&mut self) -> Result<(), MockFault> { + if self.fail { + Err(MockFault) + } else { + Ok(()) + } + } + + fn release(&mut self) -> Result<(), MockFault> { + if self.fail { + Err(MockFault) + } else { + Ok(()) + } + } + } + + /// 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 contract_is_implementable_without_the_hal() { + let mut dev = MockDevice { fail: false }; + + hold_then_release(&mut dev).expect("hold/release failed"); + } + + #[test] + fn errors_surface_through_the_generic_seam() { + let mut dev = MockDevice { fail: true }; + + let err = hold_then_release(&mut dev).expect_err("expected the device fault"); + + // 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 new file mode 100644 index 00000000..8d577b97 --- /dev/null +++ b/services/fwmanager/api/src/lib.rs @@ -0,0 +1,21 @@ +// 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. +//! +//! 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; 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};