From 198fc9fafc6b05c507a11dfb835bf4197a8813f2 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Fri, 14 Aug 2026 18:14:30 +0200 Subject: [PATCH 1/4] orchestrator: Add the shell's board seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageSource is board-supplied access to a component's active image (interposed flash, PLDM transfer, test double); Verifier judges it, and its error means the check could not run — a bad image is Verdict::Rejected, never an error. BoardTypes and Board bundle one board's types and wired instances; a new seam adds an associated type and a field, not another parameter. Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast --- services/orchestrator/shell/BUILD.bazel | 25 +++ services/orchestrator/shell/README.md | 17 ++ services/orchestrator/shell/src/board.rs | 121 ++++++++++++ services/orchestrator/shell/src/lib.rs | 16 ++ services/orchestrator/shell/src/shell.rs | 241 +++++++++++++++++++++++ services/orchestrator/shell/src/tests.rs | 233 ++++++++++++++++++++++ 6 files changed, 653 insertions(+) create mode 100644 services/orchestrator/shell/BUILD.bazel create mode 100644 services/orchestrator/shell/README.md create mode 100644 services/orchestrator/shell/src/board.rs create mode 100644 services/orchestrator/shell/src/lib.rs create mode 100644 services/orchestrator/shell/src/shell.rs create mode 100644 services/orchestrator/shell/src/tests.rs diff --git a/services/orchestrator/shell/BUILD.bazel b/services/orchestrator/shell/BUILD.bazel new file mode 100644 index 00000000..7db4124f --- /dev/null +++ b/services/orchestrator/shell/BUILD.bazel @@ -0,0 +1,25 @@ +# 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 = "orchestrator_shell", + srcs = [ + "src/board.rs", + "src/lib.rs", + ], + crate_name = "openprot_orchestrator_shell", + edition = "2024", + visibility = ["//visibility:public"], + deps = [ + "//services/orchestrator/sm:orchestrator_sm", + ], +) + +# Host tests: build on the host platform, no kernel/QEMU. +rust_test( + name = "orchestrator_shell_test", + crate = ":orchestrator_shell", + edition = "2024", +) diff --git a/services/orchestrator/shell/README.md b/services/orchestrator/shell/README.md new file mode 100644 index 00000000..b5374baa --- /dev/null +++ b/services/orchestrator/shell/README.md @@ -0,0 +1,17 @@ + + + +# orchestrator shell (`openprot_orchestrator_shell`) + +The effect-executing layer around the orchestrator state machine. `Shell` +implements the SM's `Platform` seam with one method per `Effect`, each doc +comment stating its obligation from the platform-boundary contract +(`docs/src/design/orchestrator/orchestrator-model.md` §6). + +Executors are filled in one at a time; the rest return +`ShellError::NotImplemented`, and the SM fail-closes on any effect the shell +cannot yet perform. The shell is board-blind: everything device-specific +arrives through the seams in `board.rs` — `ImageSource` (interposed flash, a +PLDM/MCTP transfer, a test double) and `Verifier` (what counts as authentic). +Verdicts and other executor-produced events flow back to the SM through +`Shell::take_event`. diff --git a/services/orchestrator/shell/src/board.rs b/services/orchestrator/shell/src/board.rs new file mode 100644 index 00000000..1b193fb2 --- /dev/null +++ b/services/orchestrator/shell/src/board.rs @@ -0,0 +1,121 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! What the board supplies to the shell: traits and wiring data only. +//! Boards (or test mocks) implement these. + +use openprot_orchestrator_sm::ComponentId; + +/// Access to one component's active firmware image, however it is reached — +/// interposed flash, a PLDM/MCTP transfer, a RAM copy in tests. +pub trait ImageSource { + /// The error type reported by this source. + type Error: core::error::Error; + + /// Makes the image readable (claim the flash, open the transfer). + /// Idempotent; a later `open` re-stages the image. + fn open(&mut self) -> Result<(), Self::Error>; + + /// Image length in bytes. + fn size(&mut self) -> Result; + + /// Reads `buf.len()` bytes starting at byte `offset` of the image. + fn read_at(&mut self, offset: usize, buf: &mut [u8]) -> Result<(), Self::Error>; +} + +impl ImageSource for &mut S { + type Error = S::Error; + + #[inline(always)] + fn open(&mut self) -> Result<(), Self::Error> { + (**self).open() + } + + #[inline(always)] + fn size(&mut self) -> Result { + (**self).size() + } + + #[inline(always)] + fn read_at(&mut self, offset: usize, buf: &mut [u8]) -> Result<(), Self::Error> { + (**self).read_at(offset, buf) + } +} + +/// Judges a component's firmware image; board wiring decides what +/// "authentic" means. +pub trait Verifier { + /// The error type reported by this verifier. + type Error: core::error::Error; + + /// Judges `id`'s image, reading it from `image`. + /// + /// # Errors + /// + /// Only when the check could not be performed (crypto fault, missing + /// key, unreadable source). A checked-and-bad image is + /// `Ok(Verdict::Rejected)` — an actuation fault must not forge a + /// verdict. + fn verify( + &mut self, + id: ComponentId, + image: &mut impl ImageSource, + ) -> Result; +} + +impl Verifier for &mut V { + type Error = V::Error; + + #[inline(always)] + fn verify( + &mut self, + id: ComponentId, + image: &mut impl ImageSource, + ) -> Result { + (**self).verify(id, image) + } +} + +/// A [`Verifier`]'s judgment of one image. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + /// Reported as `Event::VerificationPassed`. + Authentic, + /// Reported as `Event::VerificationFailed`. + Rejected, +} + +/// One board's type choices, named by a marker type. A new seam adds an +/// associated type here and a field on [`Board`] — never another parameter. +pub trait BoardTypes { + /// Image access for the managed components. + type Image: ImageSource; + /// Judges images for every component. + type Verifier: Verifier; + // Later seams: Reset (release/assert_reset), Evidence (checkpoint + // walk), Recovery, Staging. +} + +/// Everything the board supplies, built once at bring-up and handed to +/// `Shell::new`. Fields are public: executors may need two parts at once +/// (disjoint borrows). +/// +/// ```ignore +/// struct Ast1060Board; +/// impl BoardTypes for Ast1060Board { +/// type Image = SpiFlashImage; // interposed flash, offsets from the slot layout +/// type Verifier = ManifestVerifier; // signature + SVN via the crypto engine +/// } +/// let board = Board:: { +/// images: [bmc_image, cpld_image], +/// verifier, +/// }; +/// ``` +pub struct Board { + /// `images[i]` belongs to `ComponentId(i)` — device index = chain + /// position = table declaration order. + pub images: [B::Image; N], + /// Judges images for every component. + pub verifier: B::Verifier, + // Later seams add fields, e.g. resets: [B::Reset; N]. +} diff --git a/services/orchestrator/shell/src/lib.rs b/services/orchestrator/shell/src/lib.rs new file mode 100644 index 00000000..024fc3ce --- /dev/null +++ b/services/orchestrator/shell/src/lib.rs @@ -0,0 +1,16 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! `openprot_orchestrator_shell` — the effect-executing layer around the +//! orchestrator state machine. +//! +//! Everything device-specific arrives through the seams in [`board`]: +//! image access ([`ImageSource`]) and image judgment ([`Verifier`]), +//! bundled in one [`Board`] built by the board's composition crate. + +#![no_std] +#![forbid(unsafe_code)] + +mod board; + +pub use board::{Board, BoardTypes, ImageSource, Verdict, Verifier}; diff --git a/services/orchestrator/shell/src/shell.rs b/services/orchestrator/shell/src/shell.rs new file mode 100644 index 00000000..f0347c06 --- /dev/null +++ b/services/orchestrator/shell/src/shell.rs @@ -0,0 +1,241 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The [`Shell`] and its effect executors, one method per [`Effect`] variant, +//! routed from the SM through the [`Platform`] impl. + +use openprot_orchestrator_sm::{ComponentId, Effect, EffectError, Event, Platform}; + +use crate::board::{Board, BoardTypes, ImageSource, Verdict, Verifier}; + +/// Bound on events queued between two driver rounds. Executors produce at +/// most one event per effect and the driver drains after every dispatch. +const EVENT_CAP: usize = 4; + +/// Why the shell could not carry out an effect. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ShellError { + /// The executor for this effect has not been written yet. + NotImplemented, + /// The effect names a component the shell has no device for. + UnknownComponent, + /// The component's image source could not be opened. + ImageUnavailable, + /// Verify was asked for a component whose image was never staged. + NoImage, + /// The verifier could not perform the check (not a failed image — that + /// is a [`Verdict`], reported as an event). + VerifierFault, + /// The event queue overflowed; the verdict would have been lost, and + /// dropping events breaks the SM's honest-feedback contract. + QueueFull, +} + +impl core::fmt::Display for ShellError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(match self { + ShellError::NotImplemented => "executor not implemented", + ShellError::UnknownComponent => "no device for this component id", + ShellError::ImageUnavailable => "image source could not be opened", + ShellError::NoImage => "no image staged for this component", + ShellError::VerifierFault => "verifier could not perform the check", + ShellError::QueueFull => "event queue full", + }) + } +} + +impl core::error::Error for ShellError {} + +/// The effect executors, one method per [`Effect`] variant. Everything +/// device-specific lives in the [`Board`] bundle the composition crate hands +/// to [`new`](Self::new); the shell's own fields are pure bookkeeping. +pub struct Shell { + board: Board, + /// Which component's image is staged (its source opened) for the + /// verification that follows. + staged: Option, + /// Events produced by executors, awaiting [`take_event`](Self::take_event). + pending: heapless::Deque, +} + +impl Shell { + pub fn new(board: Board) -> Self { + Self { + board, + staged: None, + pending: heapless::Deque::new(), + } + } + + /// Next event owed to the SM, if any. The driver loop drains this with + /// `dispatch` after each event settles. + pub fn take_event(&mut self) -> Option { + self.pending.pop_front() + } + + fn enqueue(&mut self, event: Event) -> Result<(), ShellError> { + self.pending + .push_back(event) + .map_err(|_| ShellError::QueueFull) + } + + /// Stage `id`'s active image: open its source (claim the interposed + /// flash, start the transfer session) so + /// [`verify_firmware`](Self::verify_firmware) can stream it. + pub fn read_firmware(&mut self, id: ComponentId) -> Result<(), ShellError> { + self.staged = None; + let source = self + .board + .images + .get_mut(id.get() as usize) + .ok_or(ShellError::UnknownComponent)?; + source.open().map_err(|_| ShellError::ImageUnavailable)?; + self.staged = Some(id); + Ok(()) + } + + /// Have the [`Verifier`] judge the image staged for `id` and queue the + /// verdict — `Event::VerificationPassed(id)` or + /// `Event::VerificationFailed(id)`. An image the shell could not stage or + /// check is a failed *actuation* (an error here, latching the SM), never + /// a forged verdict. + pub fn verify_firmware(&mut self, id: ComponentId) -> Result<(), ShellError> { + if self.staged != Some(id) { + return Err(ShellError::NoImage); + } + let source = self + .board + .images + .get_mut(id.get() as usize) + .ok_or(ShellError::UnknownComponent)?; + let verdict = self + .board + .verifier + .verify(id, source) + .map_err(|_| ShellError::VerifierFault)?; + self.enqueue(match verdict { + Verdict::Authentic => Event::VerificationPassed(id), + Verdict::Rejected => Event::VerificationFailed(id), + }) + } + + /// Release `id` from reset, then supervise its boot: walk the device's + /// checkpoints and feed back one `Event::ComponentReady(id)` (Active) or + /// `Event::Booted(id)` (Passive), or `Event::Timeout(id)` when a + /// checkpoint window expires. Aggregation is the shell's job — the SM + /// sees a single readiness event per component. + pub fn release_reset(&mut self, _id: ComponentId) -> Result<(), ShellError> { + Err(ShellError::NotImplemented) + } + + /// Hold `id` in reset — durable quiesce, not a pulse: `id` must not + /// execute until its next release. At-rest verification and the recovery + /// re-walk depend on this. + pub fn assert_reset(&mut self, _id: ComponentId) -> Result<(), ShellError> { + Err(ShellError::NotImplemented) + } + + /// Restore `id` from its configured recovery source (golden image, A/B + /// slot, streamed image — a config decision, not the SM's); feed back + /// `Event::Restored(id)`, or `Event::RecoveryFailed` when restore fails. + pub fn recover_component(&mut self, _id: ComponentId) -> Result<(), ShellError> { + Err(ShellError::NotImplemented) + } + + /// Authenticate the staged update image; feed back + /// `Event::UpdateVerified` or `Event::UpdateRejected`. + pub fn authenticate_update(&mut self) -> Result<(), ShellError> { + Err(ShellError::NotImplemented) + } + + /// Write the incoming update image into the staging region. + pub fn stage_update(&mut self) -> Result<(), ShellError> { + Err(ShellError::NotImplemented) + } + + /// Boot the staged image tentatively (trial boot) and arm the commit + /// watchdog; feed back `Event::BootConfirmed(id)` on proven health or + /// `Event::CommitTimeout` when the policy window expires. + pub fn activate_update(&mut self) -> Result<(), ShellError> { + Err(ShellError::NotImplemented) + } + + /// Discard the staged image (rejected or orphaned by recovery). + pub fn discard_staged(&mut self) -> Result<(), ShellError> { + Err(ShellError::NotImplemented) + } + + /// Advance the anti-rollback (SVN) floor past `id`'s now-confirmed image + /// and cancel the commit watchdog armed by + /// [`activate_update`](Self::activate_update). + pub fn commit_svn_floor(&mut self, _id: ComponentId) -> Result<(), ShellError> { + Err(ShellError::NotImplemented) + } + + /// Produce a signed attestation for the pending challenge. + pub fn sign_attestation(&mut self) -> Result<(), ShellError> { + Err(ShellError::NotImplemented) + } + + /// Report through platform management that `id` is gated and the + /// platform runs degraded (CSA degraded-mode clause). + pub fn report_isolated(&mut self, _id: ComponentId) -> Result<(), ShellError> { + Err(ShellError::NotImplemented) + } + + /// Report that `id` exhausted recovery and forced the halt, immediately + /// before the machine latches `Locked`. + pub fn report_recovery_failed(&mut self, _id: ComponentId) -> Result<(), ShellError> { + Err(ShellError::NotImplemented) + } + + /// Answer the requester that its update was declined because the machine + /// is busy (e.g. a PLDM "retry later" completion code). + pub fn report_update_deferred(&mut self) -> Result<(), ShellError> { + Err(ShellError::NotImplemented) + } + + /// Answer the requester that its in-flight update was superseded by + /// recovery and may be retried once the platform is whole. + pub fn report_update_aborted(&mut self) -> Result<(), ShellError> { + Err(ShellError::NotImplemented) + } + + /// Latch the terminal safe state. A failure here is a hard fault: the SM + /// has nothing stronger to emit and will believe it is `Locked`, so the + /// real executor must treat failure as terminal (halt), not recoverable. + pub fn latch_lockdown(&mut self) -> Result<(), ShellError> { + Err(ShellError::NotImplemented) + } +} + +impl Platform for Shell { + /// Routes each effect to its executor. Exhaustive on purpose: a new + /// [`Effect`] variant must be given an executor before this compiles. + /// Any executor error is reported as [`EffectError`] — the SM treats + /// every actuation failure the same, fail-closed. + fn execute(&mut self, effect: Effect) -> Result<(), EffectError> { + match effect { + Effect::ReadFirmware(id) => self.read_firmware(id), + Effect::VerifyFirmware(id) => self.verify_firmware(id), + Effect::ReleaseReset(id) => self.release_reset(id), + Effect::AssertReset(id) => self.assert_reset(id), + Effect::RecoverComponent(id) => self.recover_component(id), + Effect::AuthenticateUpdate => self.authenticate_update(), + Effect::StageUpdate => self.stage_update(), + Effect::ActivateUpdate => self.activate_update(), + Effect::DiscardStaged => self.discard_staged(), + Effect::CommitSvnFloor(id) => self.commit_svn_floor(id), + Effect::SignAttestation => self.sign_attestation(), + Effect::ReportIsolated(id) => self.report_isolated(id), + Effect::ReportRecoveryFailed(id) => self.report_recovery_failed(id), + Effect::ReportUpdateDeferred => self.report_update_deferred(), + Effect::ReportUpdateAborted => self.report_update_aborted(), + Effect::LatchLockdown => self.latch_lockdown(), + // The orchestrator consumes `Emit` internally; receiving one is a + // driver bug — fail closed. + Effect::Emit(_) => return Err(EffectError), + } + .map_err(|_| EffectError) + } +} diff --git a/services/orchestrator/shell/src/tests.rs b/services/orchestrator/shell/src/tests.rs new file mode 100644 index 00000000..bfb285a0 --- /dev/null +++ b/services/orchestrator/shell/src/tests.rs @@ -0,0 +1,233 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +extern crate std; + +use crate::*; +use openprot_orchestrator_sm::{ + ComponentAttrs, ComponentId, Event, Orchestrator, PowerOnResult, State, +}; + +const C0: ComponentId = ComponentId::new(0); + +// Test image convention, shared with the fwmanager itests: 4 magic bytes, +// payload, and a final byte making the XOR over the whole image zero. A +// board-side stand-in for signature + SVN verification — deliberately +// defined here, not in the shell. +const IMAGE_MAGIC: [u8; 4] = *b"OPRT"; +const IMAGE_LEN: usize = 16; + +fn valid_image() -> std::vec::Vec { + let mut image = std::vec![0u8; IMAGE_LEN]; + image[..4].copy_from_slice(&IMAGE_MAGIC); + image[4..IMAGE_LEN - 1].fill(0xAB); + image[IMAGE_LEN - 1] = image[..IMAGE_LEN - 1].iter().fold(0, |acc, b| acc ^ b); + image +} + +#[derive(Debug)] +struct MemFault; + +impl core::fmt::Display for MemFault { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("mem image fault") + } +} + +impl core::error::Error for MemFault {} + +/// RAM-backed image — the source seam must be satisfiable without a HAL, +/// exactly as a PLDM-stream-backed source would satisfy it without flash. +struct MemImage { + data: std::vec::Vec, + fail_open: bool, + fail_read: bool, +} + +impl MemImage { + fn holding(data: std::vec::Vec) -> Self { + Self { + data, + fail_open: false, + fail_read: false, + } + } +} + +impl ImageSource for MemImage { + type Error = MemFault; + + fn open(&mut self) -> Result<(), MemFault> { + if self.fail_open { + return Err(MemFault); + } + Ok(()) + } + + fn size(&mut self) -> Result { + Ok(self.data.len()) + } + + fn read_at(&mut self, offset: usize, buf: &mut [u8]) -> Result<(), MemFault> { + if self.fail_read { + return Err(MemFault); + } + buf.copy_from_slice(&self.data[offset..offset + buf.len()]); + Ok(()) + } +} + +#[derive(Debug)] +struct VerifierBroken; + +impl core::fmt::Display for VerifierBroken { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("verifier broken") + } +} + +impl core::error::Error for VerifierBroken {} + +/// The magic + XOR-zero check as a board-supplied verifier, streaming the +/// image from its source in chunks. +struct XorVerifier { + fault: bool, +} + +impl Verifier for XorVerifier { + type Error = VerifierBroken; + + fn verify( + &mut self, + _id: ComponentId, + image: &mut impl ImageSource, + ) -> Result { + if self.fault { + return Err(VerifierBroken); + } + let len = image.size().map_err(|_| VerifierBroken)?; + let mut magic = [0u8; 4]; + let mut xor = 0u8; + let mut offset = 0; + let mut chunk = [0u8; 4]; + while offset < len { + let take = chunk.len().min(len - offset); + image + .read_at(offset, &mut chunk[..take]) + .map_err(|_| VerifierBroken)?; + if offset == 0 && take >= 4 { + magic.copy_from_slice(&chunk[..4]); + } + xor = chunk[..take].iter().fold(xor, |acc, b| acc ^ b); + offset += take; + } + let ok = len > IMAGE_MAGIC.len() && magic == IMAGE_MAGIC && xor == 0; + Ok(if ok { + Verdict::Authentic + } else { + Verdict::Rejected + }) + } +} + +/// The test board, naming its type choices once. +struct MockBoard; + +impl BoardTypes for MockBoard { + type Image = MemImage; + type Verifier = XorVerifier; +} + +fn shell(images: [MemImage; 1]) -> Shell { + Shell::new(Board { + images, + verifier: XorVerifier { fault: false }, + }) +} + +fn orchestrator() -> Orchestrator<1, 4> { + let mut chain = heapless::Vec::<_, 1>::new(); + chain + .push((C0, ComponentAttrs::passive_required())) + .unwrap(); + Orchestrator::new(chain.try_into().unwrap(), 3) +} + +// Power-on drives the SM's ReadFirmware + VerifyFirmware into the shell; +// the shell owes the verdict back as an event. +#[test] +fn boot_verifies_the_first_component() { + let mut orch = orchestrator(); + let mut shell = shell([MemImage::holding(valid_image())]); + + orch.dispatch(&mut shell, Event::PowerGood(PowerOnResult::Provisioned)); + + assert_eq!(shell.take_event(), Some(Event::VerificationPassed(C0))); + assert_eq!(shell.take_event(), None); + assert_eq!(orch.state(), State::PreSupervision); +} + +#[test] +fn corrupt_image_fails_verification() { + let mut corrupt = valid_image(); + corrupt[7] ^= 0x01; + let mut shell = shell([MemImage::holding(corrupt)]); + + shell.read_firmware(C0).unwrap(); + shell.verify_firmware(C0).unwrap(); + + assert_eq!(shell.take_event(), Some(Event::VerificationFailed(C0))); +} + +#[test] +fn verify_without_read_is_refused() { + let mut shell = shell([MemImage::holding(valid_image())]); + + assert_eq!(shell.verify_firmware(C0), Err(ShellError::NoImage)); + assert_eq!(shell.take_event(), None); +} + +// A source that cannot be opened is a failed actuation, not a verdict: the +// SM latches Locked instead of receiving a forged VerificationFailed. +#[test] +fn unopenable_source_fails_closed() { + let mut orch = orchestrator(); + let mut image = MemImage::holding(valid_image()); + image.fail_open = true; + let mut shell = shell([image]); + + orch.dispatch(&mut shell, Event::PowerGood(PowerOnResult::Provisioned)); + + assert_eq!(orch.state(), State::Locked); + assert_eq!(shell.take_event(), None); +} + +// Same for a source that opens but cannot be streamed: the verifier reports +// it could not perform the check. +#[test] +fn unreadable_source_fails_closed() { + let mut orch = orchestrator(); + let mut image = MemImage::holding(valid_image()); + image.fail_read = true; + let mut shell = shell([image]); + + orch.dispatch(&mut shell, Event::PowerGood(PowerOnResult::Provisioned)); + + assert_eq!(orch.state(), State::Locked); + assert_eq!(shell.take_event(), None); +} + +// And for a verifier that cannot perform its check at all. +#[test] +fn verifier_fault_fails_closed() { + let mut orch = orchestrator(); + let mut shell = Shell::::new(Board { + images: [MemImage::holding(valid_image())], + verifier: XorVerifier { fault: true }, + }); + + orch.dispatch(&mut shell, Event::PowerGood(PowerOnResult::Provisioned)); + + assert_eq!(orch.state(), State::Locked); + assert_eq!(shell.take_event(), None); +} From 4556f0f3af568e4dc3bc3e7e4c38eac898f18b32 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Fri, 14 Aug 2026 20:17:32 +0200 Subject: [PATCH 2/4] orchestrator: Add the shell skeleton with firmware verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One executor method per SM effect, each documenting its obligation from the platform-boundary contract (orchestrator-model.md §6); unimplemented executors fail closed. read_firmware opens the image source; verify_firmware has the verifier judge it and queues the verdict, which the driver loop feeds back via take_event. Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast --- services/orchestrator/shell/BUILD.bazel | 3 + services/orchestrator/shell/README.md | 30 ++++-- services/orchestrator/shell/src/lib.rs | 15 +++ services/orchestrator/shell/src/shell.rs | 114 +++++++++++------------ services/orchestrator/shell/src/tests.rs | 90 +++++++++++++++--- 5 files changed, 166 insertions(+), 86 deletions(-) diff --git a/services/orchestrator/shell/BUILD.bazel b/services/orchestrator/shell/BUILD.bazel index 7db4124f..254b0a80 100644 --- a/services/orchestrator/shell/BUILD.bazel +++ b/services/orchestrator/shell/BUILD.bazel @@ -8,12 +8,15 @@ rust_library( srcs = [ "src/board.rs", "src/lib.rs", + "src/shell.rs", + "src/tests.rs", ], crate_name = "openprot_orchestrator_shell", edition = "2024", visibility = ["//visibility:public"], deps = [ "//services/orchestrator/sm:orchestrator_sm", + "@rust_crates//:heapless", ], ) diff --git a/services/orchestrator/shell/README.md b/services/orchestrator/shell/README.md index b5374baa..ae56a731 100644 --- a/services/orchestrator/shell/README.md +++ b/services/orchestrator/shell/README.md @@ -4,14 +4,24 @@ # orchestrator shell (`openprot_orchestrator_shell`) The effect-executing layer around the orchestrator state machine. `Shell` -implements the SM's `Platform` seam with one method per `Effect`, each doc -comment stating its obligation from the platform-boundary contract -(`docs/src/design/orchestrator/orchestrator-model.md` §6). +implements the SM's `Platform` seam: one method per `Effect`, each +documenting its obligation from the platform-boundary contract +([orchestrator-model.md §6](../../../docs/src/design/orchestrator/orchestrator-model.md)). Unimplemented executors return +`ShellError::NotImplemented`; the SM fail-closes on them. -Executors are filled in one at a time; the rest return -`ShellError::NotImplemented`, and the SM fail-closes on any effect the shell -cannot yet perform. The shell is board-blind: everything device-specific -arrives through the seams in `board.rs` — `ImageSource` (interposed flash, a -PLDM/MCTP transfer, a test double) and `Verifier` (what counts as authentic). -Verdicts and other executor-produced events flow back to the SM through -`Shell::take_event`. +Everything device-specific arrives through the seams in `board.rs` +(`ImageSource`, `Verifier`, bundled in `Board`); executor-produced events +return to the SM via `Shell::take_event`. The driver loop dispatches an +outside event, then keeps dispatching what the executors produced until +`take_event` returns `None`: + +```rust +orch.dispatch(&mut shell, event); +while let Some(ev) = shell.take_event() { + orch.dispatch(&mut shell, ev); +} +``` + +Implemented executors: `read_firmware`, `verify_firmware`. Everything else +returns `NotImplemented` until its pillar lands (boot walk, recovery, +update path, attestation, reporting). diff --git a/services/orchestrator/shell/src/lib.rs b/services/orchestrator/shell/src/lib.rs index 024fc3ce..93fc6e0d 100644 --- a/services/orchestrator/shell/src/lib.rs +++ b/services/orchestrator/shell/src/lib.rs @@ -4,13 +4,28 @@ //! `openprot_orchestrator_shell` — the effect-executing layer around the //! orchestrator state machine. //! +//! [`Shell`] implements the SM's [`Platform`] seam: one method per `Effect`, +//! each documenting its obligation from the platform-boundary contract +//! (`docs/src/design/orchestrator/orchestrator-model.md` §6). Unimplemented +//! executors return [`ShellError::NotImplemented`]; the SM fail-closes on +//! them. +//! +//! Executor-produced events queue in the shell; the driver loop drains them +//! via [`Shell::take_event`] and dispatches each. +//! //! Everything device-specific arrives through the seams in [`board`]: //! image access ([`ImageSource`]) and image judgment ([`Verifier`]), //! bundled in one [`Board`] built by the board's composition crate. +//! +//! [`Platform`]: openprot_orchestrator_sm::Platform #![no_std] #![forbid(unsafe_code)] mod board; +mod shell; +#[cfg(test)] +mod tests; pub use board::{Board, BoardTypes, ImageSource, Verdict, Verifier}; +pub use shell::{Shell, ShellError}; diff --git a/services/orchestrator/shell/src/shell.rs b/services/orchestrator/shell/src/shell.rs index f0347c06..ecdc7c53 100644 --- a/services/orchestrator/shell/src/shell.rs +++ b/services/orchestrator/shell/src/shell.rs @@ -1,15 +1,18 @@ // Licensed under the Apache-2.0 license // SPDX-License-Identifier: Apache-2.0 -//! The [`Shell`] and its effect executors, one method per [`Effect`] variant, -//! routed from the SM through the [`Platform`] impl. +//! The [`Shell`]: one executor method per [`Effect`] variant, routed from +//! the SM through the [`Platform`] impl. use openprot_orchestrator_sm::{ComponentId, Effect, EffectError, Event, Platform}; use crate::board::{Board, BoardTypes, ImageSource, Verdict, Verifier}; -/// Bound on events queued between two driver rounds. Executors produce at -/// most one event per effect and the driver drains after every dispatch. +/// Queue bound. Executors produce at most one event per effect, the driver +/// drains after every dispatch, and the largest SM effect batch today is +/// two (ReadFirmware + VerifyFirmware) — 4 is that worst case with +/// headroom. Overflow is reported ([`ShellError::QueueFull`]), never +/// silent loss. const EVENT_CAP: usize = 4; /// Why the shell could not carry out an effect. @@ -23,11 +26,11 @@ pub enum ShellError { ImageUnavailable, /// Verify was asked for a component whose image was never staged. NoImage, - /// The verifier could not perform the check (not a failed image — that - /// is a [`Verdict`], reported as an event). + /// The verifier could not perform the check (a failed image is a + /// [`Verdict`], not an error). VerifierFault, - /// The event queue overflowed; the verdict would have been lost, and - /// dropping events breaks the SM's honest-feedback contract. + /// The event queue overflowed; dropping events breaks the SM's + /// honest-feedback contract. QueueFull, } @@ -46,15 +49,13 @@ impl core::fmt::Display for ShellError { impl core::error::Error for ShellError {} -/// The effect executors, one method per [`Effect`] variant. Everything -/// device-specific lives in the [`Board`] bundle the composition crate hands -/// to [`new`](Self::new); the shell's own fields are pure bookkeeping. +/// The effect executors. Everything device-specific lives in the [`Board`]; +/// the shell's own fields are bookkeeping. pub struct Shell { board: Board, - /// Which component's image is staged (its source opened) for the - /// verification that follows. + /// Component whose image is staged (source opened) for verification. staged: Option, - /// Events produced by executors, awaiting [`take_event`](Self::take_event). + /// Events awaiting [`take_event`](Self::take_event). pending: heapless::Deque, } @@ -67,8 +68,8 @@ impl Shell { } } - /// Next event owed to the SM, if any. The driver loop drains this with - /// `dispatch` after each event settles. + /// Next event owed to the SM; the driver loop drains this after each + /// dispatch. pub fn take_event(&mut self) -> Option { self.pending.pop_front() } @@ -79,9 +80,8 @@ impl Shell { .map_err(|_| ShellError::QueueFull) } - /// Stage `id`'s active image: open its source (claim the interposed - /// flash, start the transfer session) so - /// [`verify_firmware`](Self::verify_firmware) can stream it. + /// Stage `id`'s image: open its source so + /// [`verify_firmware`](Self::verify_firmware) can read it. pub fn read_firmware(&mut self, id: ComponentId) -> Result<(), ShellError> { self.staged = None; let source = self @@ -94,11 +94,8 @@ impl Shell { Ok(()) } - /// Have the [`Verifier`] judge the image staged for `id` and queue the - /// verdict — `Event::VerificationPassed(id)` or - /// `Event::VerificationFailed(id)`. An image the shell could not stage or - /// check is a failed *actuation* (an error here, latching the SM), never - /// a forged verdict. + /// Judge the staged image via the [`Verifier`] and queue the verdict: + /// `Event::VerificationPassed(id)` or `Event::VerificationFailed(id)`. pub fn verify_firmware(&mut self, id: ComponentId) -> Result<(), ShellError> { if self.staged != Some(id) { return Err(ShellError::NoImage); @@ -119,31 +116,28 @@ impl Shell { }) } - /// Release `id` from reset, then supervise its boot: walk the device's - /// checkpoints and feed back one `Event::ComponentReady(id)` (Active) or - /// `Event::Booted(id)` (Passive), or `Event::Timeout(id)` when a - /// checkpoint window expires. Aggregation is the shell's job — the SM - /// sees a single readiness event per component. + /// Release `id` from reset, then walk its boot checkpoints; feed back + /// one `Event::ComponentReady(id)` (Active) or `Event::Booted(id)` + /// (Passive), or `Event::Timeout(id)` on window expiry. pub fn release_reset(&mut self, _id: ComponentId) -> Result<(), ShellError> { Err(ShellError::NotImplemented) } - /// Hold `id` in reset — durable quiesce, not a pulse: `id` must not - /// execute until its next release. At-rest verification and the recovery - /// re-walk depend on this. + /// Hold `id` in reset — a durable quiesce, not a pulse; at-rest + /// verification and the recovery re-walk depend on it. pub fn assert_reset(&mut self, _id: ComponentId) -> Result<(), ShellError> { Err(ShellError::NotImplemented) } - /// Restore `id` from its configured recovery source (golden image, A/B - /// slot, streamed image — a config decision, not the SM's); feed back - /// `Event::Restored(id)`, or `Event::RecoveryFailed` when restore fails. + /// Restore `id` from its configured recovery source (the mechanism is + /// board config); feed back `Event::Restored(id)` or + /// `Event::RecoveryFailed`. pub fn recover_component(&mut self, _id: ComponentId) -> Result<(), ShellError> { Err(ShellError::NotImplemented) } - /// Authenticate the staged update image; feed back - /// `Event::UpdateVerified` or `Event::UpdateRejected`. + /// Authenticate the staged update; feed back `Event::UpdateVerified` or + /// `Event::UpdateRejected`. pub fn authenticate_update(&mut self) -> Result<(), ShellError> { Err(ShellError::NotImplemented) } @@ -153,21 +147,19 @@ impl Shell { Err(ShellError::NotImplemented) } - /// Boot the staged image tentatively (trial boot) and arm the commit - /// watchdog; feed back `Event::BootConfirmed(id)` on proven health or - /// `Event::CommitTimeout` when the policy window expires. + /// Trial-boot the staged image and arm the commit watchdog; feed back + /// `Event::BootConfirmed(id)` or `Event::CommitTimeout`. pub fn activate_update(&mut self) -> Result<(), ShellError> { Err(ShellError::NotImplemented) } - /// Discard the staged image (rejected or orphaned by recovery). + /// Discard the staged image. pub fn discard_staged(&mut self) -> Result<(), ShellError> { Err(ShellError::NotImplemented) } - /// Advance the anti-rollback (SVN) floor past `id`'s now-confirmed image - /// and cancel the commit watchdog armed by - /// [`activate_update`](Self::activate_update). + /// Advance the SVN floor past `id`'s confirmed image; cancels the + /// commit watchdog armed by [`activate_update`](Self::activate_update). pub fn commit_svn_floor(&mut self, _id: ComponentId) -> Result<(), ShellError> { Err(ShellError::NotImplemented) } @@ -177,43 +169,43 @@ impl Shell { Err(ShellError::NotImplemented) } - /// Report through platform management that `id` is gated and the - /// platform runs degraded (CSA degraded-mode clause). + /// Report `id` gated and the platform degraded (CSA degraded-mode + /// clause). pub fn report_isolated(&mut self, _id: ComponentId) -> Result<(), ShellError> { Err(ShellError::NotImplemented) } - /// Report that `id` exhausted recovery and forced the halt, immediately - /// before the machine latches `Locked`. + /// Report that `id` exhausted recovery, immediately before the machine + /// latches `Locked`. pub fn report_recovery_failed(&mut self, _id: ComponentId) -> Result<(), ShellError> { Err(ShellError::NotImplemented) } - /// Answer the requester that its update was declined because the machine - /// is busy (e.g. a PLDM "retry later" completion code). + /// Answer the requester: update declined, machine busy (e.g. a PLDM + /// "retry later" completion code). pub fn report_update_deferred(&mut self) -> Result<(), ShellError> { Err(ShellError::NotImplemented) } - /// Answer the requester that its in-flight update was superseded by - /// recovery and may be retried once the platform is whole. + /// Answer the requester: its in-flight update was superseded by + /// recovery. pub fn report_update_aborted(&mut self) -> Result<(), ShellError> { Err(ShellError::NotImplemented) } - /// Latch the terminal safe state. A failure here is a hard fault: the SM - /// has nothing stronger to emit and will believe it is `Locked`, so the - /// real executor must treat failure as terminal (halt), not recoverable. + /// Latch the terminal safe state. A failure here is a hard fault: the + /// SM believes it is `Locked`, so the real executor must halt, not + /// recover. pub fn latch_lockdown(&mut self) -> Result<(), ShellError> { Err(ShellError::NotImplemented) } } impl Platform for Shell { - /// Routes each effect to its executor. Exhaustive on purpose: a new - /// [`Effect`] variant must be given an executor before this compiles. - /// Any executor error is reported as [`EffectError`] — the SM treats - /// every actuation failure the same, fail-closed. + /// Routes each effect to its executor. Exhaustive: a new [`Effect`] + /// variant must get an executor before this compiles. Every executor + /// error reports as [`EffectError`] — the SM treats all actuation + /// failures the same, fail-closed. fn execute(&mut self, effect: Effect) -> Result<(), EffectError> { match effect { Effect::ReadFirmware(id) => self.read_firmware(id), @@ -232,8 +224,8 @@ impl Platform for Shell { Effect::ReportUpdateDeferred => self.report_update_deferred(), Effect::ReportUpdateAborted => self.report_update_aborted(), Effect::LatchLockdown => self.latch_lockdown(), - // The orchestrator consumes `Emit` internally; receiving one is a - // driver bug — fail closed. + // Emit is consumed by the orchestrator; receiving one is a + // driver bug. Effect::Emit(_) => return Err(EffectError), } .map_err(|_| EffectError) diff --git a/services/orchestrator/shell/src/tests.rs b/services/orchestrator/shell/src/tests.rs index bfb285a0..ad37c44b 100644 --- a/services/orchestrator/shell/src/tests.rs +++ b/services/orchestrator/shell/src/tests.rs @@ -11,9 +11,8 @@ use openprot_orchestrator_sm::{ const C0: ComponentId = ComponentId::new(0); // Test image convention, shared with the fwmanager itests: 4 magic bytes, -// payload, and a final byte making the XOR over the whole image zero. A -// board-side stand-in for signature + SVN verification — deliberately -// defined here, not in the shell. +// payload, final byte makes the XOR over the image zero. A board-side +// stand-in for signature + SVN verification. const IMAGE_MAGIC: [u8; 4] = *b"OPRT"; const IMAGE_LEN: usize = 16; @@ -36,8 +35,7 @@ impl core::fmt::Display for MemFault { impl core::error::Error for MemFault {} -/// RAM-backed image — the source seam must be satisfiable without a HAL, -/// exactly as a PLDM-stream-backed source would satisfy it without flash. +/// RAM-backed image source — the seam satisfied without a HAL. struct MemImage { data: std::vec::Vec, fail_open: bool, @@ -88,8 +86,8 @@ impl core::fmt::Display for VerifierBroken { impl core::error::Error for VerifierBroken {} -/// The magic + XOR-zero check as a board-supplied verifier, streaming the -/// image from its source in chunks. +/// The magic + XOR-zero check as a board-supplied verifier, reading in +/// chunks. struct XorVerifier { fault: bool, } @@ -130,7 +128,7 @@ impl Verifier for XorVerifier { } } -/// The test board, naming its type choices once. +/// The test board's type choices. struct MockBoard; impl BoardTypes for MockBoard { @@ -153,8 +151,8 @@ fn orchestrator() -> Orchestrator<1, 4> { Orchestrator::new(chain.try_into().unwrap(), 3) } -// Power-on drives the SM's ReadFirmware + VerifyFirmware into the shell; -// the shell owes the verdict back as an event. +// PowerGood drives ReadFirmware + VerifyFirmware into the shell; the +// verdict comes back as an event. #[test] fn boot_verifies_the_first_component() { let mut orch = orchestrator(); @@ -187,8 +185,8 @@ fn verify_without_read_is_refused() { assert_eq!(shell.take_event(), None); } -// A source that cannot be opened is a failed actuation, not a verdict: the -// SM latches Locked instead of receiving a forged VerificationFailed. +// An unopenable source is a failed actuation, not a verdict: the SM +// latches Locked instead of getting a forged VerificationFailed. #[test] fn unopenable_source_fails_closed() { let mut orch = orchestrator(); @@ -202,8 +200,8 @@ fn unopenable_source_fails_closed() { assert_eq!(shell.take_event(), None); } -// Same for a source that opens but cannot be streamed: the verifier reports -// it could not perform the check. +// A source that opens but cannot be read fails the same way, via the +// verifier's error. #[test] fn unreadable_source_fails_closed() { let mut orch = orchestrator(); @@ -217,7 +215,7 @@ fn unreadable_source_fails_closed() { assert_eq!(shell.take_event(), None); } -// And for a verifier that cannot perform its check at all. +// So does a verifier that cannot run its check. #[test] fn verifier_fault_fails_closed() { let mut orch = orchestrator(); @@ -231,3 +229,65 @@ fn verifier_fault_fails_closed() { assert_eq!(orch.state(), State::Locked); assert_eq!(shell.take_event(), None); } + +const C1: ComponentId = ComponentId::new(1); + +#[test] +fn verify_for_a_different_component_is_refused() { + let mut shell = Shell::::new(Board { + images: [ + MemImage::holding(valid_image()), + MemImage::holding(valid_image()), + ], + verifier: XorVerifier { fault: false }, + }); + + shell.read_firmware(C0).unwrap(); + + assert_eq!(shell.verify_firmware(C1), Err(ShellError::NoImage)); +} + +#[test] +fn unknown_component_is_refused() { + let mut shell = shell([MemImage::holding(valid_image())]); + + assert_eq!( + shell.read_firmware(ComponentId::new(9)), + Err(ShellError::UnknownComponent) + ); +} + +// Undrained verdicts eventually fill the queue; the overflow is reported, +// not silently dropped. +#[test] +fn event_queue_overflow_is_reported() { + let mut shell = shell([MemImage::holding(valid_image())]); + + let mut queued = 0; + loop { + shell.read_firmware(C0).unwrap(); + match shell.verify_firmware(C0) { + Ok(()) => queued += 1, + Err(e) => { + assert_eq!(e, ShellError::QueueFull); + break; + } + } + assert!(queued < 64, "queue never filled"); + } +} + +// Effect::Emit is the orchestrator's internal channel and must never reach +// a Platform; the shell refuses it rather than acting on it. +#[test] +fn emit_is_refused() { + use openprot_orchestrator_sm::{Effect, EffectError, Platform}; + + let mut shell = shell([MemImage::holding(valid_image())]); + + assert_eq!( + shell.execute(Effect::Emit(Event::UpdateRequest)), + Err(EffectError) + ); + assert_eq!(shell.take_event(), None); +} From 9c874bbfadeb3fec90cb36bd5ad12b3d3d89c13b Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Mon, 17 Aug 2026 17:16:06 +0200 Subject: [PATCH 3/4] orchestrator: Rename the shell to platform driver PR #357 already settled this word: 'shell' reads as bash, and the docs say platform driver. The crate follows: PlatformDriver in driver.rs, DriverError, openprot_orchestrator_driver. The outer pump is now the event loop, freeing 'driver' for the struct. Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast --- .../{shell => driver}/BUILD.bazel | 10 +- .../orchestrator/{shell => driver}/README.md | 14 +-- .../{shell => driver}/src/board.rs | 4 +- .../src/shell.rs => driver/src/driver.rs} | 114 +++++++++--------- .../orchestrator/{shell => driver}/src/lib.rs | 14 +-- .../{shell => driver}/src/tests.rs | 74 ++++++------ 6 files changed, 115 insertions(+), 115 deletions(-) rename services/orchestrator/{shell => driver}/BUILD.bazel (73%) rename services/orchestrator/{shell => driver}/README.md (71%) rename services/orchestrator/{shell => driver}/src/board.rs (95%) rename services/orchestrator/{shell/src/shell.rs => driver/src/driver.rs} (69%) rename services/orchestrator/{shell => driver}/src/lib.rs (62%) rename services/orchestrator/{shell => driver}/src/tests.rs (74%) diff --git a/services/orchestrator/shell/BUILD.bazel b/services/orchestrator/driver/BUILD.bazel similarity index 73% rename from services/orchestrator/shell/BUILD.bazel rename to services/orchestrator/driver/BUILD.bazel index 254b0a80..b9ee0aa4 100644 --- a/services/orchestrator/shell/BUILD.bazel +++ b/services/orchestrator/driver/BUILD.bazel @@ -4,14 +4,14 @@ load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") rust_library( - name = "orchestrator_shell", + name = "orchestrator_driver", srcs = [ "src/board.rs", + "src/driver.rs", "src/lib.rs", - "src/shell.rs", "src/tests.rs", ], - crate_name = "openprot_orchestrator_shell", + crate_name = "openprot_orchestrator_driver", edition = "2024", visibility = ["//visibility:public"], deps = [ @@ -22,7 +22,7 @@ rust_library( # Host tests: build on the host platform, no kernel/QEMU. rust_test( - name = "orchestrator_shell_test", - crate = ":orchestrator_shell", + name = "orchestrator_driver_test", + crate = ":orchestrator_driver", edition = "2024", ) diff --git a/services/orchestrator/shell/README.md b/services/orchestrator/driver/README.md similarity index 71% rename from services/orchestrator/shell/README.md rename to services/orchestrator/driver/README.md index ae56a731..51e03b3c 100644 --- a/services/orchestrator/shell/README.md +++ b/services/orchestrator/driver/README.md @@ -1,24 +1,24 @@ -# orchestrator shell (`openprot_orchestrator_shell`) +# orchestrator platform driver (`openprot_orchestrator_driver`) -The effect-executing layer around the orchestrator state machine. `Shell` +The effect-executing layer around the orchestrator state machine. `PlatformDriver` implements the SM's `Platform` seam: one method per `Effect`, each documenting its obligation from the platform-boundary contract ([orchestrator-model.md §6](../../../docs/src/design/orchestrator/orchestrator-model.md)). Unimplemented executors return -`ShellError::NotImplemented`; the SM fail-closes on them. +`DriverError::NotImplemented`; the SM fail-closes on them. Everything device-specific arrives through the seams in `board.rs` (`ImageSource`, `Verifier`, bundled in `Board`); executor-produced events -return to the SM via `Shell::take_event`. The driver loop dispatches an +return to the SM via `PlatformDriver::take_event`. The event loop dispatches an outside event, then keeps dispatching what the executors produced until `take_event` returns `None`: ```rust -orch.dispatch(&mut shell, event); -while let Some(ev) = shell.take_event() { - orch.dispatch(&mut shell, ev); +orch.dispatch(&mut driver, event); +while let Some(ev) = driver.take_event() { + orch.dispatch(&mut driver, ev); } ``` diff --git a/services/orchestrator/shell/src/board.rs b/services/orchestrator/driver/src/board.rs similarity index 95% rename from services/orchestrator/shell/src/board.rs rename to services/orchestrator/driver/src/board.rs index 1b193fb2..d4bd0665 100644 --- a/services/orchestrator/shell/src/board.rs +++ b/services/orchestrator/driver/src/board.rs @@ -1,7 +1,7 @@ // Licensed under the Apache-2.0 license // SPDX-License-Identifier: Apache-2.0 -//! What the board supplies to the shell: traits and wiring data only. +//! What the board supplies to the driver: traits and wiring data only. //! Boards (or test mocks) implement these. use openprot_orchestrator_sm::ComponentId; @@ -97,7 +97,7 @@ pub trait BoardTypes { } /// Everything the board supplies, built once at bring-up and handed to -/// `Shell::new`. Fields are public: executors may need two parts at once +/// `PlatformDriver::new`. Fields are public: executors may need two parts at once /// (disjoint borrows). /// /// ```ignore diff --git a/services/orchestrator/shell/src/shell.rs b/services/orchestrator/driver/src/driver.rs similarity index 69% rename from services/orchestrator/shell/src/shell.rs rename to services/orchestrator/driver/src/driver.rs index ecdc7c53..95fd2387 100644 --- a/services/orchestrator/shell/src/shell.rs +++ b/services/orchestrator/driver/src/driver.rs @@ -1,26 +1,26 @@ // Licensed under the Apache-2.0 license // SPDX-License-Identifier: Apache-2.0 -//! The [`Shell`]: one executor method per [`Effect`] variant, routed from +//! The [`PlatformDriver`]: one executor method per [`Effect`] variant, routed from //! the SM through the [`Platform`] impl. use openprot_orchestrator_sm::{ComponentId, Effect, EffectError, Event, Platform}; use crate::board::{Board, BoardTypes, ImageSource, Verdict, Verifier}; -/// Queue bound. Executors produce at most one event per effect, the driver -/// drains after every dispatch, and the largest SM effect batch today is +/// Queue bound. Executors produce at most one event per effect, the event loop +/// drains it after every dispatch, and the largest SM effect batch today is /// two (ReadFirmware + VerifyFirmware) — 4 is that worst case with -/// headroom. Overflow is reported ([`ShellError::QueueFull`]), never +/// headroom. Overflow is reported ([`DriverError::QueueFull`]), never /// silent loss. const EVENT_CAP: usize = 4; -/// Why the shell could not carry out an effect. +/// Why the driver could not carry out an effect. #[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum ShellError { +pub enum DriverError { /// The executor for this effect has not been written yet. NotImplemented, - /// The effect names a component the shell has no device for. + /// The effect names a component the driver has no device for. UnknownComponent, /// The component's image source could not be opened. ImageUnavailable, @@ -34,24 +34,24 @@ pub enum ShellError { QueueFull, } -impl core::fmt::Display for ShellError { +impl core::fmt::Display for DriverError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.write_str(match self { - ShellError::NotImplemented => "executor not implemented", - ShellError::UnknownComponent => "no device for this component id", - ShellError::ImageUnavailable => "image source could not be opened", - ShellError::NoImage => "no image staged for this component", - ShellError::VerifierFault => "verifier could not perform the check", - ShellError::QueueFull => "event queue full", + DriverError::NotImplemented => "executor not implemented", + DriverError::UnknownComponent => "no device for this component id", + DriverError::ImageUnavailable => "image source could not be opened", + DriverError::NoImage => "no image staged for this component", + DriverError::VerifierFault => "verifier could not perform the check", + DriverError::QueueFull => "event queue full", }) } } -impl core::error::Error for ShellError {} +impl core::error::Error for DriverError {} /// The effect executors. Everything device-specific lives in the [`Board`]; -/// the shell's own fields are bookkeeping. -pub struct Shell { +/// the driver's own fields are bookkeeping. +pub struct PlatformDriver { board: Board, /// Component whose image is staged (source opened) for verification. staged: Option, @@ -59,7 +59,7 @@ pub struct Shell { pending: heapless::Deque, } -impl Shell { +impl PlatformDriver { pub fn new(board: Board) -> Self { Self { board, @@ -68,48 +68,48 @@ impl Shell { } } - /// Next event owed to the SM; the driver loop drains this after each + /// Next event owed to the SM; the event loop drains this after each /// dispatch. pub fn take_event(&mut self) -> Option { self.pending.pop_front() } - fn enqueue(&mut self, event: Event) -> Result<(), ShellError> { + fn enqueue(&mut self, event: Event) -> Result<(), DriverError> { self.pending .push_back(event) - .map_err(|_| ShellError::QueueFull) + .map_err(|_| DriverError::QueueFull) } /// Stage `id`'s image: open its source so /// [`verify_firmware`](Self::verify_firmware) can read it. - pub fn read_firmware(&mut self, id: ComponentId) -> Result<(), ShellError> { + pub fn read_firmware(&mut self, id: ComponentId) -> Result<(), DriverError> { self.staged = None; let source = self .board .images .get_mut(id.get() as usize) - .ok_or(ShellError::UnknownComponent)?; - source.open().map_err(|_| ShellError::ImageUnavailable)?; + .ok_or(DriverError::UnknownComponent)?; + source.open().map_err(|_| DriverError::ImageUnavailable)?; self.staged = Some(id); Ok(()) } /// Judge the staged image via the [`Verifier`] and queue the verdict: /// `Event::VerificationPassed(id)` or `Event::VerificationFailed(id)`. - pub fn verify_firmware(&mut self, id: ComponentId) -> Result<(), ShellError> { + pub fn verify_firmware(&mut self, id: ComponentId) -> Result<(), DriverError> { if self.staged != Some(id) { - return Err(ShellError::NoImage); + return Err(DriverError::NoImage); } let source = self .board .images .get_mut(id.get() as usize) - .ok_or(ShellError::UnknownComponent)?; + .ok_or(DriverError::UnknownComponent)?; let verdict = self .board .verifier .verify(id, source) - .map_err(|_| ShellError::VerifierFault)?; + .map_err(|_| DriverError::VerifierFault)?; self.enqueue(match verdict { Verdict::Authentic => Event::VerificationPassed(id), Verdict::Rejected => Event::VerificationFailed(id), @@ -119,89 +119,89 @@ impl Shell { /// Release `id` from reset, then walk its boot checkpoints; feed back /// one `Event::ComponentReady(id)` (Active) or `Event::Booted(id)` /// (Passive), or `Event::Timeout(id)` on window expiry. - pub fn release_reset(&mut self, _id: ComponentId) -> Result<(), ShellError> { - Err(ShellError::NotImplemented) + pub fn release_reset(&mut self, _id: ComponentId) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) } /// Hold `id` in reset — a durable quiesce, not a pulse; at-rest /// verification and the recovery re-walk depend on it. - pub fn assert_reset(&mut self, _id: ComponentId) -> Result<(), ShellError> { - Err(ShellError::NotImplemented) + pub fn assert_reset(&mut self, _id: ComponentId) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) } /// Restore `id` from its configured recovery source (the mechanism is /// board config); feed back `Event::Restored(id)` or /// `Event::RecoveryFailed`. - pub fn recover_component(&mut self, _id: ComponentId) -> Result<(), ShellError> { - Err(ShellError::NotImplemented) + pub fn recover_component(&mut self, _id: ComponentId) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) } /// Authenticate the staged update; feed back `Event::UpdateVerified` or /// `Event::UpdateRejected`. - pub fn authenticate_update(&mut self) -> Result<(), ShellError> { - Err(ShellError::NotImplemented) + pub fn authenticate_update(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) } /// Write the incoming update image into the staging region. - pub fn stage_update(&mut self) -> Result<(), ShellError> { - Err(ShellError::NotImplemented) + pub fn stage_update(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) } /// Trial-boot the staged image and arm the commit watchdog; feed back /// `Event::BootConfirmed(id)` or `Event::CommitTimeout`. - pub fn activate_update(&mut self) -> Result<(), ShellError> { - Err(ShellError::NotImplemented) + pub fn activate_update(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) } /// Discard the staged image. - pub fn discard_staged(&mut self) -> Result<(), ShellError> { - Err(ShellError::NotImplemented) + pub fn discard_staged(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) } /// Advance the SVN floor past `id`'s confirmed image; cancels the /// commit watchdog armed by [`activate_update`](Self::activate_update). - pub fn commit_svn_floor(&mut self, _id: ComponentId) -> Result<(), ShellError> { - Err(ShellError::NotImplemented) + pub fn commit_svn_floor(&mut self, _id: ComponentId) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) } /// Produce a signed attestation for the pending challenge. - pub fn sign_attestation(&mut self) -> Result<(), ShellError> { - Err(ShellError::NotImplemented) + pub fn sign_attestation(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) } /// Report `id` gated and the platform degraded (CSA degraded-mode /// clause). - pub fn report_isolated(&mut self, _id: ComponentId) -> Result<(), ShellError> { - Err(ShellError::NotImplemented) + pub fn report_isolated(&mut self, _id: ComponentId) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) } /// Report that `id` exhausted recovery, immediately before the machine /// latches `Locked`. - pub fn report_recovery_failed(&mut self, _id: ComponentId) -> Result<(), ShellError> { - Err(ShellError::NotImplemented) + pub fn report_recovery_failed(&mut self, _id: ComponentId) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) } /// Answer the requester: update declined, machine busy (e.g. a PLDM /// "retry later" completion code). - pub fn report_update_deferred(&mut self) -> Result<(), ShellError> { - Err(ShellError::NotImplemented) + pub fn report_update_deferred(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) } /// Answer the requester: its in-flight update was superseded by /// recovery. - pub fn report_update_aborted(&mut self) -> Result<(), ShellError> { - Err(ShellError::NotImplemented) + pub fn report_update_aborted(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) } /// Latch the terminal safe state. A failure here is a hard fault: the /// SM believes it is `Locked`, so the real executor must halt, not /// recover. - pub fn latch_lockdown(&mut self) -> Result<(), ShellError> { - Err(ShellError::NotImplemented) + pub fn latch_lockdown(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) } } -impl Platform for Shell { +impl Platform for PlatformDriver { /// Routes each effect to its executor. Exhaustive: a new [`Effect`] /// variant must get an executor before this compiles. Every executor /// error reports as [`EffectError`] — the SM treats all actuation diff --git a/services/orchestrator/shell/src/lib.rs b/services/orchestrator/driver/src/lib.rs similarity index 62% rename from services/orchestrator/shell/src/lib.rs rename to services/orchestrator/driver/src/lib.rs index 93fc6e0d..6be41b8f 100644 --- a/services/orchestrator/shell/src/lib.rs +++ b/services/orchestrator/driver/src/lib.rs @@ -1,17 +1,17 @@ // Licensed under the Apache-2.0 license // SPDX-License-Identifier: Apache-2.0 -//! `openprot_orchestrator_shell` — the effect-executing layer around the +//! `openprot_orchestrator_driver` — the effect-executing layer around the //! orchestrator state machine. //! -//! [`Shell`] implements the SM's [`Platform`] seam: one method per `Effect`, +//! [`PlatformDriver`] implements the SM's [`Platform`] seam: one method per `Effect`, //! each documenting its obligation from the platform-boundary contract //! (`docs/src/design/orchestrator/orchestrator-model.md` §6). Unimplemented -//! executors return [`ShellError::NotImplemented`]; the SM fail-closes on +//! executors return [`DriverError::NotImplemented`]; the SM fail-closes on //! them. //! -//! Executor-produced events queue in the shell; the driver loop drains them -//! via [`Shell::take_event`] and dispatches each. +//! Executor-produced events queue in the driver; the event loop drains them +//! via [`PlatformDriver::take_event`] and dispatches each. //! //! Everything device-specific arrives through the seams in [`board`]: //! image access ([`ImageSource`]) and image judgment ([`Verifier`]), @@ -23,9 +23,9 @@ #![forbid(unsafe_code)] mod board; -mod shell; +mod driver; #[cfg(test)] mod tests; pub use board::{Board, BoardTypes, ImageSource, Verdict, Verifier}; -pub use shell::{Shell, ShellError}; +pub use driver::{DriverError, PlatformDriver}; diff --git a/services/orchestrator/shell/src/tests.rs b/services/orchestrator/driver/src/tests.rs similarity index 74% rename from services/orchestrator/shell/src/tests.rs rename to services/orchestrator/driver/src/tests.rs index ad37c44b..f797e744 100644 --- a/services/orchestrator/shell/src/tests.rs +++ b/services/orchestrator/driver/src/tests.rs @@ -136,8 +136,8 @@ impl BoardTypes for MockBoard { type Verifier = XorVerifier; } -fn shell(images: [MemImage; 1]) -> Shell { - Shell::new(Board { +fn driver(images: [MemImage; 1]) -> PlatformDriver { + PlatformDriver::new(Board { images, verifier: XorVerifier { fault: false }, }) @@ -151,17 +151,17 @@ fn orchestrator() -> Orchestrator<1, 4> { Orchestrator::new(chain.try_into().unwrap(), 3) } -// PowerGood drives ReadFirmware + VerifyFirmware into the shell; the +// PowerGood drives ReadFirmware + VerifyFirmware into the driver; the // verdict comes back as an event. #[test] fn boot_verifies_the_first_component() { let mut orch = orchestrator(); - let mut shell = shell([MemImage::holding(valid_image())]); + let mut driver = driver([MemImage::holding(valid_image())]); - orch.dispatch(&mut shell, Event::PowerGood(PowerOnResult::Provisioned)); + orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); - assert_eq!(shell.take_event(), Some(Event::VerificationPassed(C0))); - assert_eq!(shell.take_event(), None); + assert_eq!(driver.take_event(), Some(Event::VerificationPassed(C0))); + assert_eq!(driver.take_event(), None); assert_eq!(orch.state(), State::PreSupervision); } @@ -169,20 +169,20 @@ fn boot_verifies_the_first_component() { fn corrupt_image_fails_verification() { let mut corrupt = valid_image(); corrupt[7] ^= 0x01; - let mut shell = shell([MemImage::holding(corrupt)]); + let mut driver = driver([MemImage::holding(corrupt)]); - shell.read_firmware(C0).unwrap(); - shell.verify_firmware(C0).unwrap(); + driver.read_firmware(C0).unwrap(); + driver.verify_firmware(C0).unwrap(); - assert_eq!(shell.take_event(), Some(Event::VerificationFailed(C0))); + assert_eq!(driver.take_event(), Some(Event::VerificationFailed(C0))); } #[test] fn verify_without_read_is_refused() { - let mut shell = shell([MemImage::holding(valid_image())]); + let mut driver = driver([MemImage::holding(valid_image())]); - assert_eq!(shell.verify_firmware(C0), Err(ShellError::NoImage)); - assert_eq!(shell.take_event(), None); + assert_eq!(driver.verify_firmware(C0), Err(DriverError::NoImage)); + assert_eq!(driver.take_event(), None); } // An unopenable source is a failed actuation, not a verdict: the SM @@ -192,12 +192,12 @@ fn unopenable_source_fails_closed() { let mut orch = orchestrator(); let mut image = MemImage::holding(valid_image()); image.fail_open = true; - let mut shell = shell([image]); + let mut driver = driver([image]); - orch.dispatch(&mut shell, Event::PowerGood(PowerOnResult::Provisioned)); + orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); assert_eq!(orch.state(), State::Locked); - assert_eq!(shell.take_event(), None); + assert_eq!(driver.take_event(), None); } // A source that opens but cannot be read fails the same way, via the @@ -207,34 +207,34 @@ fn unreadable_source_fails_closed() { let mut orch = orchestrator(); let mut image = MemImage::holding(valid_image()); image.fail_read = true; - let mut shell = shell([image]); + let mut driver = driver([image]); - orch.dispatch(&mut shell, Event::PowerGood(PowerOnResult::Provisioned)); + orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); assert_eq!(orch.state(), State::Locked); - assert_eq!(shell.take_event(), None); + assert_eq!(driver.take_event(), None); } // So does a verifier that cannot run its check. #[test] fn verifier_fault_fails_closed() { let mut orch = orchestrator(); - let mut shell = Shell::::new(Board { + let mut driver = PlatformDriver::::new(Board { images: [MemImage::holding(valid_image())], verifier: XorVerifier { fault: true }, }); - orch.dispatch(&mut shell, Event::PowerGood(PowerOnResult::Provisioned)); + orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); assert_eq!(orch.state(), State::Locked); - assert_eq!(shell.take_event(), None); + assert_eq!(driver.take_event(), None); } const C1: ComponentId = ComponentId::new(1); #[test] fn verify_for_a_different_component_is_refused() { - let mut shell = Shell::::new(Board { + let mut driver = PlatformDriver::::new(Board { images: [ MemImage::holding(valid_image()), MemImage::holding(valid_image()), @@ -242,18 +242,18 @@ fn verify_for_a_different_component_is_refused() { verifier: XorVerifier { fault: false }, }); - shell.read_firmware(C0).unwrap(); + driver.read_firmware(C0).unwrap(); - assert_eq!(shell.verify_firmware(C1), Err(ShellError::NoImage)); + assert_eq!(driver.verify_firmware(C1), Err(DriverError::NoImage)); } #[test] fn unknown_component_is_refused() { - let mut shell = shell([MemImage::holding(valid_image())]); + let mut driver = driver([MemImage::holding(valid_image())]); assert_eq!( - shell.read_firmware(ComponentId::new(9)), - Err(ShellError::UnknownComponent) + driver.read_firmware(ComponentId::new(9)), + Err(DriverError::UnknownComponent) ); } @@ -261,15 +261,15 @@ fn unknown_component_is_refused() { // not silently dropped. #[test] fn event_queue_overflow_is_reported() { - let mut shell = shell([MemImage::holding(valid_image())]); + let mut driver = driver([MemImage::holding(valid_image())]); let mut queued = 0; loop { - shell.read_firmware(C0).unwrap(); - match shell.verify_firmware(C0) { + driver.read_firmware(C0).unwrap(); + match driver.verify_firmware(C0) { Ok(()) => queued += 1, Err(e) => { - assert_eq!(e, ShellError::QueueFull); + assert_eq!(e, DriverError::QueueFull); break; } } @@ -278,16 +278,16 @@ fn event_queue_overflow_is_reported() { } // Effect::Emit is the orchestrator's internal channel and must never reach -// a Platform; the shell refuses it rather than acting on it. +// a Platform; the driver refuses it rather than acting on it. #[test] fn emit_is_refused() { use openprot_orchestrator_sm::{Effect, EffectError, Platform}; - let mut shell = shell([MemImage::holding(valid_image())]); + let mut driver = driver([MemImage::holding(valid_image())]); assert_eq!( - shell.execute(Effect::Emit(Event::UpdateRequest)), + driver.execute(Effect::Emit(Event::UpdateRequest)), Err(EffectError) ); - assert_eq!(shell.take_event(), None); + assert_eq!(driver.take_event(), None); } From 33cead9c07689b895acf1ce817845908c9125f87 Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Mon, 17 Aug 2026 19:10:43 +0200 Subject: [PATCH 4/4] orchestrator: Rename read_firmware to stage_firmware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The executor stages the image (opens the source, marks it staged); it does not read it. Review on #418 flagged the mismatch. The SM effect stays ReadFirmware — only the driver method is renamed. Assisted-by: Claude:claude-fable-5 Signed-off-by: Christina Quast Claude-Session: https://claude.ai/code/session_018vLnXX7edp1R5YxQA3FM3h --- services/orchestrator/driver/src/driver.rs | 4 ++-- services/orchestrator/driver/src/tests.rs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/services/orchestrator/driver/src/driver.rs b/services/orchestrator/driver/src/driver.rs index 95fd2387..94908c39 100644 --- a/services/orchestrator/driver/src/driver.rs +++ b/services/orchestrator/driver/src/driver.rs @@ -82,7 +82,7 @@ impl PlatformDriver { /// Stage `id`'s image: open its source so /// [`verify_firmware`](Self::verify_firmware) can read it. - pub fn read_firmware(&mut self, id: ComponentId) -> Result<(), DriverError> { + pub fn stage_firmware(&mut self, id: ComponentId) -> Result<(), DriverError> { self.staged = None; let source = self .board @@ -208,7 +208,7 @@ impl Platform for PlatformDriver { /// failures the same, fail-closed. fn execute(&mut self, effect: Effect) -> Result<(), EffectError> { match effect { - Effect::ReadFirmware(id) => self.read_firmware(id), + Effect::ReadFirmware(id) => self.stage_firmware(id), Effect::VerifyFirmware(id) => self.verify_firmware(id), Effect::ReleaseReset(id) => self.release_reset(id), Effect::AssertReset(id) => self.assert_reset(id), diff --git a/services/orchestrator/driver/src/tests.rs b/services/orchestrator/driver/src/tests.rs index f797e744..f203c010 100644 --- a/services/orchestrator/driver/src/tests.rs +++ b/services/orchestrator/driver/src/tests.rs @@ -171,7 +171,7 @@ fn corrupt_image_fails_verification() { corrupt[7] ^= 0x01; let mut driver = driver([MemImage::holding(corrupt)]); - driver.read_firmware(C0).unwrap(); + driver.stage_firmware(C0).unwrap(); driver.verify_firmware(C0).unwrap(); assert_eq!(driver.take_event(), Some(Event::VerificationFailed(C0))); @@ -242,7 +242,7 @@ fn verify_for_a_different_component_is_refused() { verifier: XorVerifier { fault: false }, }); - driver.read_firmware(C0).unwrap(); + driver.stage_firmware(C0).unwrap(); assert_eq!(driver.verify_firmware(C1), Err(DriverError::NoImage)); } @@ -252,7 +252,7 @@ fn unknown_component_is_refused() { let mut driver = driver([MemImage::holding(valid_image())]); assert_eq!( - driver.read_firmware(ComponentId::new(9)), + driver.stage_firmware(ComponentId::new(9)), Err(DriverError::UnknownComponent) ); } @@ -265,7 +265,7 @@ fn event_queue_overflow_is_reported() { let mut queued = 0; loop { - driver.read_firmware(C0).unwrap(); + driver.stage_firmware(C0).unwrap(); match driver.verify_firmware(C0) { Ok(()) => queued += 1, Err(e) => {