orchestrator-sm: validated Chain newtype for construction Introduce Chain<N> with a TryFrom that validates the chain of trust at the boundary (non-empty, unique ids, depends_on exists and is strictly earlier, length fits u8). Orchestrator/Rot::new take Chain<N> instead of a raw heapless::Vec, so the storage representation is no longer part of the public constructor. Add ChainError tests.
diff --git a/services/orchestrator/sm/src/lib.rs b/services/orchestrator/sm/src/lib.rs index d527a80..d4338c7 100644 --- a/services/orchestrator/sm/src/lib.rs +++ b/services/orchestrator/sm/src/lib.rs
@@ -23,11 +23,11 @@ use core::marker::PhantomData; +use statig::Outcome; use statig::blocking::{ IntoStateMachine, IntoStateMachineExt as _, State as StatigState, StateMachine, Superstate as StatigSuperstate, }; -use statig::Outcome; // Internal capacities — these follow from how the machine works, not from the // deployment. The board owns `N` (chain length), `E` (effect-buffer size) and @@ -348,6 +348,101 @@ NotGated, } +/// A validated **chain of trust**: the ordered list of components the eRoT +/// walks, verifies, and supervises, in walk order. +/// +/// Build one with [`TryFrom`]/[`TryInto`] from a `heapless::Vec` of +/// `(ComponentId, ComponentAttrs)` pairs. The conversion is the single place +/// the reducer's structural invariants are enforced, so a malformed chain +/// fails closed at the boundary instead of misbehaving later: +/// +/// - the chain is non-empty, +/// - every [`ComponentId`] is unique, +/// - every `depends_on` names a component that exists and appears *strictly +/// earlier* in the chain (no dangling, forward, or self dependencies — a +/// dependency is always walked before its dependents), +/// - the length fits `u8`, the `cursor` index type. +/// +/// ```ignore +/// let mut v = heapless::Vec::<_, 4>::new(); +/// v.push((ComponentId::new(0), ComponentAttrs::passive_required())).unwrap(); +/// let chain: Chain<4> = v.try_into()?; +/// ``` +#[derive(Clone, Debug)] +pub struct Chain<const N: usize> { + entries: heapless::Vec<(ComponentId, ComponentAttrs), N>, +} + +/// Why a `heapless::Vec` of components is not a valid [`Chain`]. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ChainError { + /// The chain has no components. + Empty, + /// The chain is longer than `u8::MAX`, so `cursor` could not index it. + TooLong, + /// The same [`ComponentId`] appears more than once. + DuplicateId(ComponentId), + /// A `depends_on` names an id that is not in the chain. + UnknownDependency { + component: ComponentId, + depends_on: ComponentId, + }, + /// A `depends_on` names a component that does not appear strictly earlier + /// in the chain (a forward reference or a self-reference). A dependency + /// must be walked before its dependents. + ForwardDependency { + component: ComponentId, + depends_on: ComponentId, + }, +} + +impl<const N: usize> Chain<N> { + /// Consume the validated chain, yielding its components in walk order. + fn into_entries(self) -> heapless::Vec<(ComponentId, ComponentAttrs), N> { + self.entries + } +} + +impl<const N: usize> TryFrom<heapless::Vec<(ComponentId, ComponentAttrs), N>> for Chain<N> { + type Error = ChainError; + + fn try_from( + entries: heapless::Vec<(ComponentId, ComponentAttrs), N>, + ) -> Result<Self, ChainError> { + if entries.is_empty() { + return Err(ChainError::Empty); + } + if entries.len() > u8::MAX as usize { + return Err(ChainError::TooLong); + } + for (i, (id, _)) in entries.iter().enumerate() { + if entries[..i].iter().any(|(prev, _)| prev == id) { + return Err(ChainError::DuplicateId(*id)); + } + } + for (i, (id, attrs)) in entries.iter().enumerate() { + if let Some(dep) = attrs.depends_on { + match entries.iter().position(|(cid, _)| *cid == dep) { + None => { + return Err(ChainError::UnknownDependency { + component: *id, + depends_on: dep, + }); + } + Some(j) if j >= i => { + return Err(ChainError::ForwardDependency { + component: *id, + depends_on: dep, + }); + } + Some(_) => {} + } + } + } + Ok(Self { entries }) + } +} + /// Shared storage: data that persists across events. `N` is the chain capacity /// and `E` the effect-buffer size — both board choices; the core sets no /// default. `E` must be at least `N + 2` (enforced in [`Rot::new`]). @@ -382,15 +477,12 @@ /// Compile-time floor: the effect buffer must hold a full cascade (`N` /// `AssertReset`s) plus the destination `PreSupervision` entry's two /// effects. Forced by `new` below, so an under-sized `E` fails to build. - const EFFECT_CAP_OK: () = assert!( - E >= N + 2, - "effect buffer E must be >= chain length N + 2" - ); + const EFFECT_CAP_OK: () = assert!(E >= N + 2, "effect buffer E must be >= chain length N + 2"); - pub fn new(chain: heapless::Vec<(ComponentId, ComponentAttrs), N>, max_retry: u8) -> Self { + pub fn new(chain: Chain<N>, max_retry: u8) -> Self { let () = Self::EFFECT_CAP_OK; Self { - chain, + chain: chain.into_entries(), cursor: 0, gated: heapless::Vec::new(), failed: None, @@ -404,7 +496,10 @@ /// Look up a component's attributes by id. `None` if the id is not in the /// chain (should never happen for ids the core itself produced). fn attrs_of(&self, id: ComponentId) -> Option<ComponentAttrs> { - self.chain.iter().find(|(cid, _)| *cid == id).map(|(_, a)| *a) + self.chain + .iter() + .find(|(cid, _)| *cid == id) + .map(|(_, a)| *a) } fn is_gated(&self, id: ComponentId) -> bool { @@ -545,7 +640,12 @@ } impl<const N: usize, const E: usize> StatigState<Rot<N, E>> for State { - fn call_handler(&mut self, rot: &mut Rot<N, E>, event: &Event, ctx: &mut Sink<E>) -> Outcome<State> { + fn call_handler( + &mut self, + rot: &mut Rot<N, E>, + event: &Event, + ctx: &mut Sink<E>, + ) -> Outcome<State> { match self { State::PowerOnReset => match event { Event::PowerGood(PowerOnResult::Provisioned) => { @@ -660,7 +760,10 @@ // recovery, not a global budget (CSA: exhaustion is // per-device). `failed` is always `Some` while in // `Recovering`; treat a missing id as exhausted defensively. - let attempts = rot.failed.map(|id| rot.bump_retry(id)).unwrap_or(rot.max_retry); + let attempts = rot + .failed + .map(|id| rot.bump_retry(id)) + .unwrap_or(rot.max_retry); if attempts < rot.max_retry { Outcome::Transition(State::PreSupervision) } else { @@ -736,7 +839,12 @@ } impl<const N: usize, const E: usize> StatigSuperstate<Rot<N, E>> for Superstate<'_> { - fn call_handler(&mut self, rot: &mut Rot<N, E>, event: &Event, ctx: &mut Sink<E>) -> Outcome<State> { + fn call_handler( + &mut self, + rot: &mut Rot<N, E>, + event: &Event, + ctx: &mut Sink<E>, + ) -> Outcome<State> { match self { Superstate::SupervisingPlatform(_) => match event { Event::AttestationChallenge => { @@ -763,7 +871,7 @@ } impl<const N: usize, const E: usize> Orchestrator<N, E> { - pub fn new(chain: heapless::Vec<(ComponentId, ComponentAttrs), N>, max_retry: u8) -> Self { + pub fn new(chain: Chain<N>, max_retry: u8) -> Self { Self { machine: Rot::new(chain, max_retry).state_machine(), }
diff --git a/services/orchestrator/sm/src/tests.rs b/services/orchestrator/sm/src/tests.rs index ba303e9..f17c118 100644 --- a/services/orchestrator/sm/src/tests.rs +++ b/services/orchestrator/sm/src/tests.rs
@@ -56,7 +56,8 @@ chain: heapless::Vec<(ComponentId, ComponentAttrs), CAPACITY>, script: &[Event], ) -> (Vec<Effect>, State) { - let mut orch = Orchestrator::<CAPACITY, ECAP>::new(chain, MAX_RETRY); + let mut orch = + Orchestrator::<CAPACITY, ECAP>::new(chain.try_into().expect("valid chain"), MAX_RETRY); let mut platform = Recorder::new(); for &event in script { orch.dispatch(&mut platform, event); @@ -238,7 +239,7 @@ let mut c = heapless::Vec::<(ComponentId, ComponentAttrs), CAPACITY>::new(); c.push((C0, ComponentAttrs::passive_required())) .expect("fits"); - let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c, 2); + let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c.try_into().expect("valid chain"), 2); let mut effects = Vec::new(); for ev in [ @@ -277,7 +278,7 @@ .expect("fits"); c.push((C1, ComponentAttrs::passive_required())) .expect("fits"); - let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c, 2); + let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c.try_into().expect("valid chain"), 2); let mut effects = Vec::new(); for ev in [ @@ -304,7 +305,7 @@ let mut c = heapless::Vec::<(ComponentId, ComponentAttrs), CAPACITY>::new(); c.push((C0, ComponentAttrs::passive_required())) .expect("fits"); - let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c, 1); + let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c.try_into().expect("valid chain"), 1); let mut effects = Vec::new(); for ev in [ BOOT, @@ -326,7 +327,7 @@ c.push((id, ComponentAttrs::passive_required())) .expect("3 fits"); } - let mut orch = Orchestrator::<3, 5>::new(c, MAX_RETRY); + let mut orch = Orchestrator::<3, 5>::new(c.try_into().expect("valid chain"), MAX_RETRY); let mut effects = Vec::new(); for ev in [ BOOT, @@ -726,7 +727,7 @@ let mut c: heapless::Vec<(ComponentId, ComponentAttrs), CAPACITY> = heapless::Vec::new(); c.push((C0, ComponentAttrs::passive_required())).unwrap(); // max_retry = 1 so the first failed restore latches immediately. - let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c, 1); + let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c.try_into().expect("valid chain"), 1); let mut effects: Vec<Effect> = Vec::new(); for ev in [BOOT, Event::VerificationFailed(C0), Event::Restored(C0)] { @@ -786,7 +787,9 @@ chain(&[ (C0, ComponentAttrs::active_required()), (C1, ComponentAttrs::passive_required()), - ]), + ]) + .try_into() + .expect("valid chain"), MAX_RETRY, ); let mut effects: Vec<Effect> = Vec::new(); @@ -831,3 +834,74 @@ ], ); } + +/// An empty component list is not a valid chain of trust. +#[test] +fn chain_rejects_empty() { + let empty = heapless::Vec::<(ComponentId, ComponentAttrs), CAPACITY>::new(); + assert_eq!(Chain::try_from(empty).unwrap_err(), ChainError::Empty); +} + +/// A repeated `ComponentId` is rejected: the reducer's linear id lookups would +/// otherwise be ambiguous. +#[test] +fn chain_rejects_duplicate_id() { + let v = chain(&[ + (C0, ComponentAttrs::passive_required()), + (C0, ComponentAttrs::passive_required()), + ]); + assert_eq!(Chain::try_from(v).unwrap_err(), ChainError::DuplicateId(C0),); +} + +/// A `depends_on` that names a component not in the chain is rejected. +#[test] +fn chain_rejects_unknown_dependency() { + let v = chain(&[(C1, ComponentAttrs::passive_required().with_depends_on(C0))]); + assert_eq!( + Chain::try_from(v).unwrap_err(), + ChainError::UnknownDependency { + component: C1, + depends_on: C0, + }, + ); +} + +/// A dependency must appear strictly earlier in the walk than its dependent; +/// a forward reference is rejected. +#[test] +fn chain_rejects_forward_dependency() { + let v = chain(&[ + (C0, ComponentAttrs::passive_required().with_depends_on(C1)), + (C1, ComponentAttrs::passive_cascading()), + ]); + assert_eq!( + Chain::try_from(v).unwrap_err(), + ChainError::ForwardDependency { + component: C0, + depends_on: C1, + }, + ); +} + +/// A component may not depend on itself. +#[test] +fn chain_rejects_self_dependency() { + let v = chain(&[(C0, ComponentAttrs::passive_required().with_depends_on(C0))]); + assert_eq!( + Chain::try_from(v).unwrap_err(), + ChainError::ForwardDependency { + component: C0, + depends_on: C0, + }, + ); +} + +/// A well-formed chain with a backward dependency validates successfully. +#[test] +fn chain_accepts_valid_dependency() { + let v = chain(&[ + (C0, ComponentAttrs::passive_cascading()), + (C1, ComponentAttrs::passive_required().with_depends_on(C0)), + ]); + assert!(Chain::try_from(v).is_ok()); +}