From 1e2150985d8970b900811854d624717eaec27f8d Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Tue, 22 Sep 2026 15:38:30 +0200 Subject: [PATCH 1/2] pldm: Add IPC API crate (wire format, types, transport seam) Defines the binary protocol the orchestrator uses to talk to the PLDM Firmware Device over IPC. Twelve operations covering the full update lifecycle: offer accept/reject, verify/apply grant/deny, activation, SVN commit, cancel ack, and status query. Wire format: fixed 8-byte headers for both request and response, with manual byte encoding (no zerocopy dep) for no_std compatibility. The doc's response diagram adds to 6 bytes; the prose says "Fixed 8-byte header," so we pad 2 reserved bytes to match the stated intent. The doc field `gen` is renamed to `generation` because `gen` is a reserved keyword in Rust 2024 edition. All four Deny* ops carry a DenyReason byte for uniformity (the doc pins DenySvnCommit to PolicyViolation, but sending the reason on the wire keeps the decode path identical to the other denials). The FdStatus enum encodes the FD's current condition as a QueryStatus response payload, covering all DSP0267 states plus pending decisions. OfferPending carries target, total, transfer mode, and the SVN delayed flag from the UA's UpdateComponent request, so the orchestrator can validate against the floor. The Transport trait is the seam the client crate will be generic over, split into start/poll/cancel rather than one blocking round-trip: the orchestrator client runs in the event loop, which must not block. In production it is backed by util/ipc's AsyncTransaction; host tests use LoopbackTransport. Host-buildable with no kernel dependencies. All encode/decode paths have roundtrip tests. Assisted-by: Claude Code --- services/pldm/ipc-api/BUILD.bazel | 16 + services/pldm/ipc-api/src/error.rs | 138 ++++++ services/pldm/ipc-api/src/lib.rs | 23 + services/pldm/ipc-api/src/status.rs | 348 ++++++++++++++ services/pldm/ipc-api/src/transport.rs | 66 +++ services/pldm/ipc-api/src/wire.rs | 620 +++++++++++++++++++++++++ 6 files changed, 1211 insertions(+) create mode 100644 services/pldm/ipc-api/BUILD.bazel create mode 100644 services/pldm/ipc-api/src/error.rs create mode 100644 services/pldm/ipc-api/src/lib.rs create mode 100644 services/pldm/ipc-api/src/status.rs create mode 100644 services/pldm/ipc-api/src/transport.rs create mode 100644 services/pldm/ipc-api/src/wire.rs diff --git a/services/pldm/ipc-api/BUILD.bazel b/services/pldm/ipc-api/BUILD.bazel new file mode 100644 index 00000000..076874b2 --- /dev/null +++ b/services/pldm/ipc-api/BUILD.bazel @@ -0,0 +1,16 @@ +# 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 = "pldm_ipc_api", + srcs = glob(["src/**/*.rs"]), + edition = "2024", + visibility = ["//visibility:public"], +) + +rust_test( + name = "pldm_ipc_api_test", + crate = ":pldm_ipc_api", +) diff --git a/services/pldm/ipc-api/src/error.rs b/services/pldm/ipc-api/src/error.rs new file mode 100644 index 00000000..50311c56 --- /dev/null +++ b/services/pldm/ipc-api/src/error.rs @@ -0,0 +1,138 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Error types for the PLDM IPC wire protocol. + +use core::fmt; + +/// Wire-level decode/encode error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WireError { + /// Output buffer too small for the encoded message. + BufferTooSmall, + /// Payload exceeds the maximum allowed size. + PayloadTooLarge, + /// Unrecognized operation code. + InvalidOpcode(u8), + /// Input buffer too short for a complete header or payload. + Truncated, + /// Unrecognized enum value (status discriminant, deny reason, etc). + InvalidValue(u8), +} + +impl fmt::Display for WireError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BufferTooSmall => f.write_str("buffer too small"), + Self::PayloadTooLarge => f.write_str("payload too large"), + Self::InvalidOpcode(op) => write!(f, "invalid opcode 0x{op:02x}"), + Self::Truncated => f.write_str("truncated"), + Self::InvalidValue(v) => write!(f, "invalid value 0x{v:02x}"), + } + } +} + +/// On-wire response code from the FD. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum ResponseCode { + /// Operation completed successfully. + Success = 0, + /// Internal server error. + InternalError = 1, + /// Unrecognized opcode (InvalidOp on the wire). + InvalidOp = 2, + /// Operation not valid in the FD's current phase. + WrongPhase = 3, +} + +impl ResponseCode { + pub const fn is_success(self) -> bool { + matches!(self, Self::Success) + } + + pub const fn from_u8(val: u8) -> Option { + match val { + 0 => Some(Self::Success), + 1 => Some(Self::InternalError), + 2 => Some(Self::InvalidOp), + 3 => Some(Self::WrongPhase), + _ => None, + } + } +} + +impl fmt::Display for ResponseCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Success => f.write_str("success"), + Self::InternalError => f.write_str("internal error"), + Self::InvalidOp => f.write_str("invalid op"), + Self::WrongPhase => f.write_str("wrong phase"), + } + } +} + +/// Reason the orchestrator denied an operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum DenyReason { + /// Component is isolated (compromise detected). + Isolated = 0, + /// Update policy violation. + PolicyViolation = 1, + /// Component identifier not recognized. + UnknownTarget = 2, + /// Another operation is in progress. + Busy = 3, +} + +impl DenyReason { + pub const fn from_u8(val: u8) -> Option { + match val { + 0 => Some(Self::Isolated), + 1 => Some(Self::PolicyViolation), + 2 => Some(Self::UnknownTarget), + 3 => Some(Self::Busy), + _ => None, + } + } +} + +impl fmt::Display for DenyReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Isolated => f.write_str("isolated"), + Self::PolicyViolation => f.write_str("policy violation"), + Self::UnknownTarget => f.write_str("unknown target"), + Self::Busy => f.write_str("busy"), + } + } +} + +/// Error returned to the orchestrator's client layer. +/// +/// Wraps the on-wire `ResponseCode`, the way `MctpError` wraps +/// `mctp_api::ResponseCode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PldmIpcError { + pub code: ResponseCode, +} + +impl PldmIpcError { + pub const fn from_code(code: ResponseCode) -> Self { + Self { code } + } +} + +impl fmt::Display for PldmIpcError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "pldm ipc error: {}", self.code) + } +} + +impl From for PldmIpcError { + fn from(code: ResponseCode) -> Self { + Self::from_code(code) + } +} diff --git a/services/pldm/ipc-api/src/lib.rs b/services/pldm/ipc-api/src/lib.rs new file mode 100644 index 00000000..03a56097 --- /dev/null +++ b/services/pldm/ipc-api/src/lib.rs @@ -0,0 +1,23 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! PLDM IPC API: wire format, types, and transport seam. +//! +//! Defines the binary protocol the orchestrator uses to talk to the +//! PLDM Firmware Device over IPC. Host-buildable, no kernel +//! dependencies. The server and client crates depend on this for +//! shared types; neither re-invents the encoding. + +#![no_std] + +pub mod error; +pub mod status; +pub mod transport; +pub mod wire; + +pub use error::{DenyReason, PldmIpcError, ResponseCode, WireError}; +pub use status::{FdStatus, TransferMode}; +pub use transport::{Transport, TransportError}; +pub use wire::{ + PldmOp, RequestHeader, ResponseHeader, MAX_PAYLOAD_SIZE, MAX_REQUEST_SIZE, MAX_RESPONSE_SIZE, +}; diff --git a/services/pldm/ipc-api/src/status.rs b/services/pldm/ipc-api/src/status.rs new file mode 100644 index 00000000..48959fdb --- /dev/null +++ b/services/pldm/ipc-api/src/status.rs @@ -0,0 +1,348 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! FD status for QueryStatus responses. +//! +//! The status payload follows the response header and carries the FD's +//! current condition. The orchestrator always follows a nudge with +//! QueryStatus to learn what happened, so the status is the primary +//! communication channel from the FD. + +use crate::error::WireError; + +/// In-transport vs out-of-transport image transfer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum TransferMode { + /// FD pulls firmware chunks from the UA over MCTP. + InTransport = 0, + /// A third party writes the image to staging before verify. + OutOfTransport = 1, +} + +impl TransferMode { + pub const fn from_u8(val: u8) -> Option { + match val { + 0 => Some(Self::InTransport), + 1 => Some(Self::OutOfTransport), + _ => None, + } + } +} + +/// Current condition of the FD, returned by QueryStatus. +/// +/// Some variants map to DSP0267 states (Idle, ReadyXfer), some to +/// pending decisions the orchestrator owes the FD (OfferPending, +/// VerifyPending, ApplyPending, ActivationPending, SvnCommitPending), +/// and PhaseFailed is a verify/apply failure the UA has not yet +/// cancelled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FdStatus { + /// No update in progress. `reason` is the DSP0267 + /// GetStatusReasonCode (0 = Initialization, others per spec). + Idle { reason: u8 }, + + /// UA has sent UpdateComponent, FD is ready for transfer. + ReadyXfer, + + /// FD has an offer the orchestrator has not yet accepted or + /// rejected. `target` is the PLDM component identifier, `total` + /// is the image size in bytes. `svn_delayed` is true when the UA + /// requested delayed SVN update (DSP0267 bit 9). + OfferPending { + target: u16, + total: u32, + mode: TransferMode, + svn_delayed: bool, + }, + + /// Transfer complete, FD waiting for GrantVerify. + VerifyPending, + + /// Verify complete, FD waiting for GrantApply. + ApplyPending, + + /// Apply complete, FD waiting for GrantActivate. + ActivationPending, + + /// UA sent UpdateSecurityRevision, FD waiting for + /// GrantSvnCommit. `component` is the target identifier. + SvnCommitPending { component: u16 }, + + /// Verify or apply failed. `phase` and `result_code` are the + /// DSP0267 values the FD already sent the UA. + PhaseFailed { phase: u8, result_code: u8 }, + + /// UA sent CancelUpdate, FD waiting for AckCancel. + Cancelled, +} + +// Wire discriminants. +const IDLE: u8 = 0; +const READY_XFER: u8 = 1; +const OFFER_PENDING: u8 = 2; +const VERIFY_PENDING: u8 = 3; +const APPLY_PENDING: u8 = 4; +const ACTIVATION_PENDING: u8 = 5; +const SVN_COMMIT_PENDING: u8 = 6; +const PHASE_FAILED: u8 = 7; +const CANCELLED: u8 = 8; + +impl FdStatus { + /// Maximum encoded size of a status payload (OfferPending: 9 bytes). + pub const MAX_SIZE: usize = 9; + + /// Encode into `buf`, returning the number of bytes written. + pub fn encode(&self, buf: &mut [u8]) -> Result { + match *self { + Self::Idle { reason } => { + if buf.len() < 2 { + return Err(WireError::BufferTooSmall); + } + buf[0] = IDLE; + buf[1] = reason; + Ok(2) + } + Self::ReadyXfer => { + if buf.is_empty() { + return Err(WireError::BufferTooSmall); + } + buf[0] = READY_XFER; + Ok(1) + } + Self::OfferPending { + target, + total, + mode, + svn_delayed, + } => { + if buf.len() < 9 { + return Err(WireError::BufferTooSmall); + } + buf[0] = OFFER_PENDING; + let t = target.to_le_bytes(); + buf[1] = t[0]; + buf[2] = t[1]; + let s = total.to_le_bytes(); + buf[3] = s[0]; + buf[4] = s[1]; + buf[5] = s[2]; + buf[6] = s[3]; + buf[7] = mode as u8; + buf[8] = svn_delayed as u8; + Ok(9) + } + Self::VerifyPending => { + if buf.is_empty() { + return Err(WireError::BufferTooSmall); + } + buf[0] = VERIFY_PENDING; + Ok(1) + } + Self::ApplyPending => { + if buf.is_empty() { + return Err(WireError::BufferTooSmall); + } + buf[0] = APPLY_PENDING; + Ok(1) + } + Self::ActivationPending => { + if buf.is_empty() { + return Err(WireError::BufferTooSmall); + } + buf[0] = ACTIVATION_PENDING; + Ok(1) + } + Self::SvnCommitPending { component } => { + if buf.len() < 3 { + return Err(WireError::BufferTooSmall); + } + buf[0] = SVN_COMMIT_PENDING; + let c = component.to_le_bytes(); + buf[1] = c[0]; + buf[2] = c[1]; + Ok(3) + } + Self::PhaseFailed { phase, result_code } => { + if buf.len() < 3 { + return Err(WireError::BufferTooSmall); + } + buf[0] = PHASE_FAILED; + buf[1] = phase; + buf[2] = result_code; + Ok(3) + } + Self::Cancelled => { + if buf.is_empty() { + return Err(WireError::BufferTooSmall); + } + buf[0] = CANCELLED; + Ok(1) + } + } + } + + /// Decode from `buf`. + pub fn decode(buf: &[u8]) -> Result { + if buf.is_empty() { + return Err(WireError::Truncated); + } + match buf[0] { + IDLE => { + if buf.len() < 2 { + return Err(WireError::Truncated); + } + Ok(Self::Idle { reason: buf[1] }) + } + READY_XFER => Ok(Self::ReadyXfer), + OFFER_PENDING => { + if buf.len() < 9 { + return Err(WireError::Truncated); + } + let target = u16::from_le_bytes([buf[1], buf[2]]); + let total = u32::from_le_bytes([buf[3], buf[4], buf[5], buf[6]]); + let mode = TransferMode::from_u8(buf[7]).ok_or(WireError::InvalidValue(buf[7]))?; + let svn_delayed = buf[8] != 0; + Ok(Self::OfferPending { + target, + total, + mode, + svn_delayed, + }) + } + VERIFY_PENDING => Ok(Self::VerifyPending), + APPLY_PENDING => Ok(Self::ApplyPending), + ACTIVATION_PENDING => Ok(Self::ActivationPending), + SVN_COMMIT_PENDING => { + if buf.len() < 3 { + return Err(WireError::Truncated); + } + let component = u16::from_le_bytes([buf[1], buf[2]]); + Ok(Self::SvnCommitPending { component }) + } + PHASE_FAILED => { + if buf.len() < 3 { + return Err(WireError::Truncated); + } + Ok(Self::PhaseFailed { + phase: buf[1], + result_code: buf[2], + }) + } + CANCELLED => Ok(Self::Cancelled), + other => Err(WireError::InvalidValue(other)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn idle_roundtrip() { + let s = FdStatus::Idle { reason: 0x03 }; + let mut buf = [0u8; 16]; + let len = s.encode(&mut buf).unwrap(); + assert_eq!(len, 2); + assert_eq!(FdStatus::decode(&buf[..len]), Ok(s)); + } + + #[test] + fn offer_pending_roundtrip() { + let s = FdStatus::OfferPending { + target: 0x1234, + total: 0x0010_0000, + mode: TransferMode::InTransport, + svn_delayed: false, + }; + let mut buf = [0u8; 16]; + let len = s.encode(&mut buf).unwrap(); + assert_eq!(len, 9); + assert_eq!(FdStatus::decode(&buf[..len]), Ok(s)); + } + + #[test] + fn offer_pending_svn_delayed() { + let s = FdStatus::OfferPending { + target: 1, + total: 4096, + mode: TransferMode::OutOfTransport, + svn_delayed: true, + }; + let mut buf = [0u8; 16]; + let len = s.encode(&mut buf).unwrap(); + assert_eq!(FdStatus::decode(&buf[..len]), Ok(s)); + } + + #[test] + fn simple_variants_roundtrip() { + for s in [ + FdStatus::ReadyXfer, + FdStatus::VerifyPending, + FdStatus::ApplyPending, + FdStatus::ActivationPending, + FdStatus::Cancelled, + ] { + let mut buf = [0u8; 16]; + let len = s.encode(&mut buf).unwrap(); + assert_eq!(len, 1); + assert_eq!(FdStatus::decode(&buf[..len]), Ok(s)); + } + } + + #[test] + fn svn_commit_pending_roundtrip() { + let s = FdStatus::SvnCommitPending { component: 0x00FF }; + let mut buf = [0u8; 16]; + let len = s.encode(&mut buf).unwrap(); + assert_eq!(len, 3); + assert_eq!(FdStatus::decode(&buf[..len]), Ok(s)); + } + + #[test] + fn phase_failed_roundtrip() { + let s = FdStatus::PhaseFailed { + phase: 2, + result_code: 0x0A, + }; + let mut buf = [0u8; 16]; + let len = s.encode(&mut buf).unwrap(); + assert_eq!(len, 3); + assert_eq!(FdStatus::decode(&buf[..len]), Ok(s)); + } + + #[test] + fn decode_empty_is_truncated() { + assert_eq!(FdStatus::decode(&[]), Err(WireError::Truncated)); + } + + #[test] + fn decode_unknown_discriminant() { + assert_eq!( + FdStatus::decode(&[0xFF]), + Err(WireError::InvalidValue(0xFF)) + ); + } + + #[test] + fn decode_offer_pending_truncated() { + assert_eq!( + FdStatus::decode(&[OFFER_PENDING, 0, 0]), + Err(WireError::Truncated) + ); + } + + #[test] + fn encode_offer_pending_buffer_too_small() { + let s = FdStatus::OfferPending { + target: 1, + total: 1, + mode: TransferMode::InTransport, + svn_delayed: false, + }; + let mut buf = [0u8; 4]; + assert_eq!(s.encode(&mut buf), Err(WireError::BufferTooSmall)); + } +} diff --git a/services/pldm/ipc-api/src/transport.rs b/services/pldm/ipc-api/src/transport.rs new file mode 100644 index 00000000..d2917e97 --- /dev/null +++ b/services/pldm/ipc-api/src/transport.rs @@ -0,0 +1,66 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The transport seam. +//! +//! Bytes in, bytes out, one round-trip, split into a start and a poll so +//! the caller never blocks. The orchestrator's client layer encodes a +//! request, starts it, and polls for the response from its event loop. +//! Swapping the transport is a wiring choice: +//! +//! - `IpcTransport` (in `orchestrator-pldm-client-ipc`): production path +//! over a kernel channel, backed by `util/ipc`'s `AsyncTransaction`. +//! That wrapper lends the kernel `'static` buffers, so the impl copies +//! the request in and the response out of buffers it owns. +//! - `LoopbackTransport` (in `pldm-ipc-server`): calls dispatch directly +//! in-process and has the response ready on the first poll. +//! Host-buildable, so the same client encoders/decoders exercise the +//! real dispatch with no kernel. + +/// Why a transport round-trip failed. Small and transport-neutral; +/// PLDM-level status travels inside the response payload, not here. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportError { + /// The underlying channel or loopback call failed. + Failed, + /// `start` was called while a round-trip was still in flight, or + /// `poll`/`cancel` was called with nothing in flight. + WrongState, + /// The request does not fit the transport's request buffer, or the + /// response does not fit the caller's. + TooLarge, +} + +impl core::fmt::Display for TransportError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Failed => f.write_str("pldm ipc transport round-trip failed"), + Self::WrongState => f.write_str("pldm ipc transport is in the wrong state"), + Self::TooLarge => f.write_str("pldm ipc message does not fit the buffer"), + } + } +} + +impl core::error::Error for TransportError {} + +/// Bytes-in, bytes-out, exactly one round-trip at a time. +/// +/// `start` takes one fully serialized `pldm_ipc_api` request and returns +/// immediately; the transport copies what it needs, so `req` is free +/// afterwards. `poll` returns `Ok(None)` while the response is still +/// outstanding and `Ok(Some(len))` once `resp[..len]` holds one fully +/// serialized reply. No fragmentation, no state between round-trips. +/// +/// A transport carries one round-trip at a time: `start` while another +/// is in flight, or `poll`/`cancel` with none, is `WrongState`. Any +/// error from `poll` ends the round-trip, so the next call is `start`. +pub trait Transport { + fn start(&mut self, req: &[u8]) -> Result<(), TransportError>; + + fn poll(&mut self, resp: &mut [u8]) -> Result, TransportError>; + + /// Abandon the round-trip in flight. The response, if one arrives, is + /// discarded. + fn cancel(&mut self) -> Result<(), TransportError>; +} diff --git a/services/pldm/ipc-api/src/wire.rs b/services/pldm/ipc-api/src/wire.rs new file mode 100644 index 00000000..79ee2be8 --- /dev/null +++ b/services/pldm/ipc-api/src/wire.rs @@ -0,0 +1,620 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! PLDM IPC wire protocol. +//! +//! Binary wire protocol for orchestrator-to-FD operations over IPC +//! channels. Uses manual byte encoding for `no_std` compatibility. +//! +//! ```text +//! Request (8-byte header + optional args): +//! +----+-------+-----+----------+ +//! | op | flags | gen | reserved | + [args] +//! | 1B | 1B | 2B | 4B | +//! +----+-------+-----+----------+ +//! +//! Response (8-byte header + optional payload): +//! +------+-------+-----+-------------+----------+ +//! | code | flags | gen | payload_len | reserved | + [payload] +//! | 1B | 1B | 2B | 2B LE | 2B | +//! +------+-------+-----+-------------+----------+ +//! ``` + +use crate::error::{DenyReason, ResponseCode, WireError}; +use crate::status::FdStatus; + +// ============================================================================ +// Operation codes +// ============================================================================ + +/// Orchestrator-to-FD IPC operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum PldmOp { + /// Approve the pending offer, provide staging flash base address. + AcceptOffer = 0, + /// Reject the pending offer, FD sends TransferComplete. + RejectOffer = 1, + /// Authorize the FD to run FdOps::verify. + GrantVerify = 2, + /// Block verify (e.g. isolated component). + DenyVerify = 3, + /// Authorize the FD to run FdOps::apply. + GrantApply = 4, + /// Block apply. + DenyApply = 5, + /// Read the FD's current state (phase, result, error). + QueryStatus = 6, + /// Authorize activation ahead of the UA's request. + GrantActivate = 7, + /// Refuse activation; FD answers the UA with INCOMPLETE_UPDATE. + DenyActivate = 8, + /// Acknowledge cancel, release orchestrator-side resources. + AckCancel = 9, + /// Tell the FD the SVN floor is raised so it can answer the UA. + GrantSvnCommit = 10, + /// Block the SVN commit. + DenySvnCommit = 11, +} + +impl PldmOp { + pub fn from_u8(val: u8) -> Option { + match val { + 0 => Some(Self::AcceptOffer), + 1 => Some(Self::RejectOffer), + 2 => Some(Self::GrantVerify), + 3 => Some(Self::DenyVerify), + 4 => Some(Self::GrantApply), + 5 => Some(Self::DenyApply), + 6 => Some(Self::QueryStatus), + 7 => Some(Self::GrantActivate), + 8 => Some(Self::DenyActivate), + 9 => Some(Self::AckCancel), + 10 => Some(Self::GrantSvnCommit), + 11 => Some(Self::DenySvnCommit), + _ => None, + } + } +} + +// ============================================================================ +// Request header +// ============================================================================ + +/// Request header (8 bytes). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RequestHeader { + pub op: u8, + pub flags: u8, + /// Phase generation. Reserved, set to 0. + pub generation: u16, +} + +impl RequestHeader { + pub const SIZE: usize = 8; + + pub fn to_bytes(&self) -> [u8; Self::SIZE] { + let g = self.generation.to_le_bytes(); + [self.op, self.flags, g[0], g[1], 0, 0, 0, 0] + } + + pub fn from_bytes(bytes: &[u8]) -> Option { + if bytes.len() < Self::SIZE { + return None; + } + Some(Self { + op: bytes[0], + flags: bytes[1], + generation: u16::from_le_bytes([bytes[2], bytes[3]]), + }) + } + + pub fn operation(&self) -> Option { + PldmOp::from_u8(self.op) + } +} + +// ============================================================================ +// Response header +// ============================================================================ + +/// Response header (8 bytes). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResponseHeader { + pub code: u8, + pub flags: u8, + /// Phase generation. Reserved, set to 0. + pub generation: u16, + pub payload_len: u16, +} + +impl ResponseHeader { + pub const SIZE: usize = 8; + + pub const fn success() -> Self { + Self { + code: ResponseCode::Success as u8, + flags: 0, + generation: 0, + payload_len: 0, + } + } + + pub const fn error(code: ResponseCode) -> Self { + Self { + code: code as u8, + flags: 0, + generation: 0, + payload_len: 0, + } + } + + pub fn is_success(&self) -> bool { + self.code == ResponseCode::Success as u8 + } + + pub fn response_code(&self) -> ResponseCode { + ResponseCode::from_u8(self.code).unwrap_or(ResponseCode::InternalError) + } + + pub fn to_bytes(&self) -> [u8; Self::SIZE] { + let g = self.generation.to_le_bytes(); + let pl = self.payload_len.to_le_bytes(); + [self.code, self.flags, g[0], g[1], pl[0], pl[1], 0, 0] + } + + pub fn from_bytes(bytes: &[u8]) -> Option { + if bytes.len() < Self::SIZE { + return None; + } + Some(Self { + code: bytes[0], + flags: bytes[1], + generation: u16::from_le_bytes([bytes[2], bytes[3]]), + payload_len: u16::from_le_bytes([bytes[4], bytes[5]]), + }) + } +} + +// ============================================================================ +// Constants +// ============================================================================ + +/// Maximum status payload (OfferPending: 9 bytes). +pub const MAX_PAYLOAD_SIZE: usize = FdStatus::MAX_SIZE; + +/// Maximum total request size (header + AcceptOffer args). +pub const MAX_REQUEST_SIZE: usize = RequestHeader::SIZE + 4; + +/// Maximum total response size (header + QueryStatus payload). +pub const MAX_RESPONSE_SIZE: usize = ResponseHeader::SIZE + MAX_PAYLOAD_SIZE; + +// ============================================================================ +// Request encoding +// ============================================================================ + +fn encode_header_only(buf: &mut [u8], op: PldmOp) -> Result { + if buf.len() < RequestHeader::SIZE { + return Err(WireError::BufferTooSmall); + } + let h = RequestHeader { + op: op as u8, + flags: 0, + generation: 0, + }; + buf[..RequestHeader::SIZE].copy_from_slice(&h.to_bytes()); + Ok(RequestHeader::SIZE) +} + +/// Encode AcceptOffer. `staging_base` is the flash address the FD +/// should stage firmware to. +pub fn encode_accept_offer(buf: &mut [u8], staging_base: u32) -> Result { + let total = RequestHeader::SIZE + 4; + if buf.len() < total { + return Err(WireError::BufferTooSmall); + } + let h = RequestHeader { + op: PldmOp::AcceptOffer as u8, + flags: 0, + generation: 0, + }; + buf[..RequestHeader::SIZE].copy_from_slice(&h.to_bytes()); + buf[RequestHeader::SIZE..total].copy_from_slice(&staging_base.to_le_bytes()); + Ok(total) +} + +pub fn encode_reject_offer(buf: &mut [u8]) -> Result { + encode_header_only(buf, PldmOp::RejectOffer) +} + +pub fn encode_grant_verify(buf: &mut [u8]) -> Result { + encode_header_only(buf, PldmOp::GrantVerify) +} + +/// Encode DenyVerify with the reason the orchestrator is blocking. +pub fn encode_deny_verify(buf: &mut [u8], reason: DenyReason) -> Result { + let total = RequestHeader::SIZE + 1; + if buf.len() < total { + return Err(WireError::BufferTooSmall); + } + let h = RequestHeader { + op: PldmOp::DenyVerify as u8, + flags: 0, + generation: 0, + }; + buf[..RequestHeader::SIZE].copy_from_slice(&h.to_bytes()); + buf[RequestHeader::SIZE] = reason as u8; + Ok(total) +} + +pub fn encode_grant_apply(buf: &mut [u8]) -> Result { + encode_header_only(buf, PldmOp::GrantApply) +} + +/// Encode DenyApply with the reason the orchestrator is blocking. +pub fn encode_deny_apply(buf: &mut [u8], reason: DenyReason) -> Result { + let total = RequestHeader::SIZE + 1; + if buf.len() < total { + return Err(WireError::BufferTooSmall); + } + let h = RequestHeader { + op: PldmOp::DenyApply as u8, + flags: 0, + generation: 0, + }; + buf[..RequestHeader::SIZE].copy_from_slice(&h.to_bytes()); + buf[RequestHeader::SIZE] = reason as u8; + Ok(total) +} + +pub fn encode_query_status(buf: &mut [u8]) -> Result { + encode_header_only(buf, PldmOp::QueryStatus) +} + +pub fn encode_grant_activate(buf: &mut [u8]) -> Result { + encode_header_only(buf, PldmOp::GrantActivate) +} + +pub fn encode_deny_activate(buf: &mut [u8], reason: DenyReason) -> Result { + let total = RequestHeader::SIZE + 1; + if buf.len() < total { + return Err(WireError::BufferTooSmall); + } + let h = RequestHeader { + op: PldmOp::DenyActivate as u8, + flags: 0, + generation: 0, + }; + buf[..RequestHeader::SIZE].copy_from_slice(&h.to_bytes()); + buf[RequestHeader::SIZE] = reason as u8; + Ok(total) +} + +pub fn encode_ack_cancel(buf: &mut [u8]) -> Result { + encode_header_only(buf, PldmOp::AckCancel) +} + +pub fn encode_grant_svn_commit(buf: &mut [u8]) -> Result { + encode_header_only(buf, PldmOp::GrantSvnCommit) +} + +/// Encode DenySvnCommit with the reason the orchestrator is blocking. +pub fn encode_deny_svn_commit(buf: &mut [u8], reason: DenyReason) -> Result { + let total = RequestHeader::SIZE + 1; + if buf.len() < total { + return Err(WireError::BufferTooSmall); + } + let h = RequestHeader { + op: PldmOp::DenySvnCommit as u8, + flags: 0, + generation: 0, + }; + buf[..RequestHeader::SIZE].copy_from_slice(&h.to_bytes()); + buf[RequestHeader::SIZE] = reason as u8; + Ok(total) +} + +// ============================================================================ +// Response encoding (server side) +// ============================================================================ + +/// Encode a success response with no payload. +pub fn encode_success_response(buf: &mut [u8]) -> Result { + if buf.len() < ResponseHeader::SIZE { + return Err(WireError::BufferTooSmall); + } + buf[..ResponseHeader::SIZE].copy_from_slice(&ResponseHeader::success().to_bytes()); + Ok(ResponseHeader::SIZE) +} + +/// Encode an error response. +pub fn encode_error_response(buf: &mut [u8], code: ResponseCode) -> Result { + if buf.len() < ResponseHeader::SIZE { + return Err(WireError::BufferTooSmall); + } + buf[..ResponseHeader::SIZE].copy_from_slice(&ResponseHeader::error(code).to_bytes()); + Ok(ResponseHeader::SIZE) +} + +/// Encode a QueryStatus success response with the FD's current status. +pub fn encode_status_response(buf: &mut [u8], status: &FdStatus) -> Result { + let mut payload_buf = [0u8; FdStatus::MAX_SIZE]; + let payload_len = status.encode(&mut payload_buf)?; + let total = ResponseHeader::SIZE + payload_len; + if buf.len() < total { + return Err(WireError::BufferTooSmall); + } + let mut h = ResponseHeader::success(); + h.payload_len = payload_len as u16; + buf[..ResponseHeader::SIZE].copy_from_slice(&h.to_bytes()); + buf[ResponseHeader::SIZE..total].copy_from_slice(&payload_buf[..payload_len]); + Ok(total) +} + +// ============================================================================ +// Response decoding (client side) +// ============================================================================ + +/// Decode a response header. +pub fn decode_response_header(buf: &[u8]) -> Result { + ResponseHeader::from_bytes(buf).ok_or(WireError::Truncated) +} + +/// Extract the response payload bytes (after the header). +pub fn get_response_payload<'a>( + buf: &'a [u8], + header: &ResponseHeader, +) -> Result<&'a [u8], WireError> { + let end = ResponseHeader::SIZE + header.payload_len as usize; + if buf.len() < end { + return Err(WireError::Truncated); + } + Ok(&buf[ResponseHeader::SIZE..end]) +} + +/// Decode a request header. +pub fn decode_request_header(buf: &[u8]) -> Result { + RequestHeader::from_bytes(buf).ok_or(WireError::Truncated) +} + +/// Get the request args (bytes after the header). +pub fn get_request_args(buf: &[u8]) -> &[u8] { + if buf.len() > RequestHeader::SIZE { + &buf[RequestHeader::SIZE..] + } else { + &[] + } +} + +/// Extract the staging base address from an AcceptOffer request's args. +pub fn get_accept_offer_base(args: &[u8]) -> Result { + if args.len() < 4 { + return Err(WireError::Truncated); + } + Ok(u32::from_le_bytes([args[0], args[1], args[2], args[3]])) +} + +/// Extract the deny reason from a Deny* request's args. +pub fn get_deny_reason(args: &[u8]) -> Result { + if args.is_empty() { + return Err(WireError::Truncated); + } + DenyReason::from_u8(args[0]).ok_or(WireError::InvalidValue(args[0])) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_header_roundtrip() { + let h = RequestHeader { + op: PldmOp::QueryStatus as u8, + flags: 0, + generation: 0x1234, + }; + let bytes = h.to_bytes(); + let decoded = RequestHeader::from_bytes(&bytes).unwrap(); + assert_eq!(decoded.op, PldmOp::QueryStatus as u8); + assert_eq!(decoded.generation, 0x1234); + } + + #[test] + fn response_header_roundtrip() { + let h = ResponseHeader { + code: ResponseCode::Success as u8, + flags: 0, + generation: 0, + payload_len: 8, + }; + let bytes = h.to_bytes(); + let decoded = ResponseHeader::from_bytes(&bytes).unwrap(); + assert!(decoded.is_success()); + assert_eq!(decoded.payload_len, 8); + } + + #[test] + fn encode_accept_offer_roundtrip() { + let mut buf = [0u8; 16]; + let len = encode_accept_offer(&mut buf, 0x2000_0000).unwrap(); + assert_eq!(len, 12); + let h = decode_request_header(&buf).unwrap(); + assert_eq!(h.operation(), Some(PldmOp::AcceptOffer)); + let args = get_request_args(&buf[..len]); + assert_eq!(get_accept_offer_base(args).unwrap(), 0x2000_0000); + } + + #[test] + fn encode_deny_verify_roundtrip() { + let mut buf = [0u8; 16]; + let len = encode_deny_verify(&mut buf, DenyReason::Isolated).unwrap(); + assert_eq!(len, 9); + let h = decode_request_header(&buf).unwrap(); + assert_eq!(h.operation(), Some(PldmOp::DenyVerify)); + let args = get_request_args(&buf[..len]); + assert_eq!(get_deny_reason(args).unwrap(), DenyReason::Isolated); + } + + #[test] + fn encode_deny_apply_roundtrip() { + let mut buf = [0u8; 16]; + let len = encode_deny_apply(&mut buf, DenyReason::PolicyViolation).unwrap(); + let args = get_request_args(&buf[..len]); + assert_eq!(get_deny_reason(args).unwrap(), DenyReason::PolicyViolation); + } + + #[test] + fn encode_deny_svn_commit_roundtrip() { + let mut buf = [0u8; 16]; + let len = encode_deny_svn_commit(&mut buf, DenyReason::PolicyViolation).unwrap(); + assert_eq!(len, 9); + let h = decode_request_header(&buf).unwrap(); + assert_eq!(h.operation(), Some(PldmOp::DenySvnCommit)); + let args = get_request_args(&buf[..len]); + assert_eq!(get_deny_reason(args).unwrap(), DenyReason::PolicyViolation); + } + + #[test] + fn encode_deny_activate_roundtrip() { + let mut buf = [0u8; 16]; + let len = encode_deny_activate(&mut buf, DenyReason::UnknownTarget).unwrap(); + let h = decode_request_header(&buf).unwrap(); + assert_eq!(h.operation(), Some(PldmOp::DenyActivate)); + let args = get_request_args(&buf[..len]); + assert_eq!(get_deny_reason(args).unwrap(), DenyReason::UnknownTarget); + } + + #[test] + fn header_only_ops_roundtrip() { + let ops = [ + ( + encode_reject_offer as fn(&mut [u8]) -> _, + PldmOp::RejectOffer, + ), + (encode_grant_verify, PldmOp::GrantVerify), + (encode_grant_apply, PldmOp::GrantApply), + (encode_query_status, PldmOp::QueryStatus), + (encode_grant_activate, PldmOp::GrantActivate), + (encode_ack_cancel, PldmOp::AckCancel), + (encode_grant_svn_commit, PldmOp::GrantSvnCommit), + ]; + for (encode_fn, expected_op) in ops { + let mut buf = [0u8; 16]; + let len = encode_fn(&mut buf).unwrap(); + assert_eq!(len, RequestHeader::SIZE); + let h = decode_request_header(&buf).unwrap(); + assert_eq!(h.operation(), Some(expected_op)); + } + } + + #[test] + fn status_response_roundtrip() { + let status = FdStatus::OfferPending { + target: 0x0001, + total: 0x0008_0000, + mode: crate::status::TransferMode::InTransport, + svn_delayed: true, + }; + let mut buf = [0u8; 32]; + let len = encode_status_response(&mut buf, &status).unwrap(); + let h = decode_response_header(&buf).unwrap(); + assert!(h.is_success()); + assert_eq!(h.payload_len, 9); + let payload = get_response_payload(&buf[..len], &h).unwrap(); + let decoded = FdStatus::decode(payload).unwrap(); + assert_eq!(decoded, status); + } + + #[test] + fn error_response_roundtrip() { + let mut buf = [0u8; 16]; + let len = encode_error_response(&mut buf, ResponseCode::WrongPhase).unwrap(); + assert_eq!(len, ResponseHeader::SIZE); + let h = decode_response_header(&buf).unwrap(); + assert!(!h.is_success()); + assert_eq!(h.response_code(), ResponseCode::WrongPhase); + } + + #[test] + fn success_response_roundtrip() { + let mut buf = [0u8; 16]; + let len = encode_success_response(&mut buf).unwrap(); + assert_eq!(len, ResponseHeader::SIZE); + let h = decode_response_header(&buf).unwrap(); + assert!(h.is_success()); + assert_eq!(h.payload_len, 0); + } + + #[test] + fn unknown_opcode() { + assert_eq!(PldmOp::from_u8(0xFF), None); + } + + #[test] + fn decode_request_truncated() { + assert_eq!(decode_request_header(&[0u8; 4]), Err(WireError::Truncated)); + } + + #[test] + fn decode_response_truncated() { + assert_eq!(decode_response_header(&[0u8; 4]), Err(WireError::Truncated)); + } + + #[test] + fn get_response_payload_truncated() { + let mut h = ResponseHeader::success(); + h.payload_len = 100; + let mut buf = [0u8; 16]; + buf[..ResponseHeader::SIZE].copy_from_slice(&h.to_bytes()); + assert_eq!( + get_response_payload(&buf[..ResponseHeader::SIZE], &h), + Err(WireError::Truncated) + ); + } + + #[test] + fn buffer_too_small_errors() { + let mut buf = [0u8; 4]; + assert_eq!( + encode_accept_offer(&mut buf, 0), + Err(WireError::BufferTooSmall) + ); + assert_eq!( + encode_reject_offer(&mut buf), + Err(WireError::BufferTooSmall) + ); + assert_eq!( + encode_deny_verify(&mut buf, DenyReason::Busy), + Err(WireError::BufferTooSmall) + ); + assert_eq!( + encode_success_response(&mut buf), + Err(WireError::BufferTooSmall) + ); + assert_eq!( + encode_error_response(&mut buf, ResponseCode::InternalError), + Err(WireError::BufferTooSmall) + ); + } + + #[test] + fn get_request_args_empty_for_header_only() { + let mut buf = [0u8; 16]; + encode_query_status(&mut buf).unwrap(); + assert_eq!(get_request_args(&buf[..RequestHeader::SIZE]), &[]); + } + + #[test] + fn get_accept_offer_base_truncated() { + assert_eq!(get_accept_offer_base(&[0, 0]), Err(WireError::Truncated)); + } + + #[test] + fn get_deny_reason_truncated() { + assert_eq!(get_deny_reason(&[]), Err(WireError::Truncated)); + } +} From 153223f4d3903667ab94f4e40196e157d2d1f50d Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Fri, 25 Sep 2026 18:20:01 +0200 Subject: [PATCH 2/2] pldm: Add the always-grant update gate The orchestrator answers each parked FD phase with one operation. This is that mapping, and the one policy that permits every phase. It exists so the update path runs end to end before a real policy is written, and so a test can drive every phase without one. It checks nothing: not isolation, not the SVN floor, not whether the component is one this orchestrator manages, and it must not ship on a device. A test pins the property that no status produces a refusal. Decision carries each operation's arguments rather than a bare PldmOp, because AcceptOffer needs the staging base and GrantSvnCommit the component. The refusing variants arrive with the first policy that refuses something, and so does the trait the two will share; one implementation does not need one. Assisted-by: Claude Opus 5 --- services/pldm/gate/BUILD.bazel | 19 +++ services/pldm/gate/README.md | 43 +++++++ services/pldm/gate/src/lib.rs | 224 +++++++++++++++++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 services/pldm/gate/BUILD.bazel create mode 100644 services/pldm/gate/README.md create mode 100644 services/pldm/gate/src/lib.rs diff --git a/services/pldm/gate/BUILD.bazel b/services/pldm/gate/BUILD.bazel new file mode 100644 index 00000000..2c7f3d87 --- /dev/null +++ b/services/pldm/gate/BUILD.bazel @@ -0,0 +1,19 @@ +# 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 = "pldm_gate", + srcs = glob(["src/**/*.rs"]), + edition = "2024", + visibility = ["//visibility:public"], + deps = [ + "//services/pldm/ipc-api:pldm_ipc_api", + ], +) + +rust_test( + name = "pldm_gate_test", + crate = ":pldm_gate", +) diff --git a/services/pldm/gate/README.md b/services/pldm/gate/README.md new file mode 100644 index 00000000..dca4fda9 --- /dev/null +++ b/services/pldm/gate/README.md @@ -0,0 +1,43 @@ +# pldm_gate + +The orchestrator's side of the PLDM update gate. `#![no_std]`, +host-buildable, depends only on `pldm_ipc_api`. + +The firmware device asks before it acts. It parks at each phase, raises a +nudge, and the orchestrator reads `FdStatus` and answers with one operation. +This crate turns a status into that answer. + +## `AlwaysGrant` + +The policy that permits everything. + +```rust +let gate = AlwaysGrant::new(0x2000_0000); // staging base, board wiring + +match gate.decide(&status) { + Decision::Idle => {} // nothing to answer, wait + decision => send(decision), +} +``` + +| status | answer | +|---|---| +| `OfferPending` | `AcceptOffer { staging_base }` | +| `VerifyPending` | `GrantVerify` | +| `ApplyPending` | `GrantApply` | +| `ActivationPending` | `GrantActivate` | +| `SvnCommitPending` | `GrantSvnCommit` | +| `Cancelled` | `AckCancel` | +| `Idle`, `ReadyXfer`, `PhaseFailed` | `Decision::Idle` | + +It exists so the update path runs end to end before any real policy is +written, and so a test can drive every phase without one. + +**It is not a policy and must not ship on a device.** It makes no checks: not +component isolation, not the SVN floor, not whether the component is one this +orchestrator manages. A test pins the property that it never refuses +anything, which is the whole of what it does. + +A real gate replaces it and brings the refusing decisions with it. When a +second policy exists, the two share a trait; one implementation does not +need one. diff --git a/services/pldm/gate/src/lib.rs b/services/pldm/gate/src/lib.rs new file mode 100644 index 00000000..69a1b0e4 --- /dev/null +++ b/services/pldm/gate/src/lib.rs @@ -0,0 +1,224 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The orchestrator's side of the PLDM update gate. +//! +//! The firmware device asks before it acts: it parks at each phase and +//! raises a nudge, the orchestrator reads [`FdStatus`] and answers with +//! one operation. This crate turns a status into that answer. +//! +//! [`AlwaysGrant`] is the policy that permits everything. It exists so the +//! update path can run end to end before any real policy is written, and +//! so tests have a gate that never blocks. It makes no checks: no +//! isolation, no SVN floor, no component identity. Nothing here belongs on +//! a shipping device. + +#![no_std] + +use pldm_ipc_api::{FdStatus, PldmOp}; + +/// What the orchestrator sends next. +/// +/// One variant per operation a decision can produce, carrying that +/// operation's arguments. `Idle` is not an operation: it means this status +/// needs no answer, so the orchestrator sends nothing and waits for the +/// next nudge. +/// +/// Only the permitting operations are here. The refusals (`RejectOffer`, +/// `DenyVerify` and the rest) arrive with the first policy that refuses +/// something. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decision { + /// Nothing to answer: the FD is not waiting on the orchestrator. + Idle, + /// Approve the offer and name the staging region to write into. + AcceptOffer { staging_base: u32 }, + /// Let the FD verify the staged image. + GrantVerify, + /// Let the FD apply the verified image. + GrantApply, + /// Let the FD activate. + GrantActivate, + /// Tell the FD the SVN floor is raised. + GrantSvnCommit { component: u16 }, + /// Release the FD from a cancel it is parked on. + AckCancel, +} + +impl Decision { + /// The operation this decision sends, or `None` for [`Decision::Idle`]. + pub fn op(&self) -> Option { + match self { + Self::Idle => None, + Self::AcceptOffer { .. } => Some(PldmOp::AcceptOffer), + Self::GrantVerify => Some(PldmOp::GrantVerify), + Self::GrantApply => Some(PldmOp::GrantApply), + Self::GrantActivate => Some(PldmOp::GrantActivate), + Self::GrantSvnCommit { .. } => Some(PldmOp::GrantSvnCommit), + Self::AckCancel => Some(PldmOp::AckCancel), + } + } +} + +/// A gate that permits every phase. +/// +/// Answers each waiting status with its permitting operation and every +/// other status with [`Decision::Idle`]. The staging base it hands out at +/// `AcceptOffer` is the one it was built with, because that address is +/// board wiring rather than a decision. +/// +/// This is a stand-in, not a policy. A real gate refuses an isolated +/// component, an image below the SVN floor, and an offer for a component +/// it does not manage. This one refuses nothing, so the update path runs +/// unattended and a test can drive every phase without writing a policy +/// first. +pub struct AlwaysGrant { + staging_base: u32, +} + +impl AlwaysGrant { + /// Build a gate that stages every image at `staging_base`. + pub const fn new(staging_base: u32) -> Self { + Self { staging_base } + } + + /// Answer one status. + /// + /// `PhaseFailed` is `Idle`: verify or apply already failed and the FD + /// has told the update agent, so there is nothing left to permit. + pub fn decide(&self, status: &FdStatus) -> Decision { + match status { + FdStatus::OfferPending { .. } => Decision::AcceptOffer { + staging_base: self.staging_base, + }, + FdStatus::VerifyPending => Decision::GrantVerify, + FdStatus::ApplyPending => Decision::GrantApply, + FdStatus::ActivationPending => Decision::GrantActivate, + FdStatus::SvnCommitPending { component } => Decision::GrantSvnCommit { + component: *component, + }, + FdStatus::Cancelled => Decision::AckCancel, + FdStatus::Idle { .. } | FdStatus::ReadyXfer | FdStatus::PhaseFailed { .. } => { + Decision::Idle + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pldm_ipc_api::status::TransferMode; + + const STAGING: u32 = 0x2000_0000; + + fn gate() -> AlwaysGrant { + AlwaysGrant::new(STAGING) + } + + #[test] + fn an_offer_is_accepted_at_the_configured_staging_base() { + let offer = FdStatus::OfferPending { + target: 1, + total: 0x10_0000, + mode: TransferMode::InTransport, + svn_delayed: false, + }; + + assert_eq!( + gate().decide(&offer), + Decision::AcceptOffer { + staging_base: STAGING + } + ); + } + + #[test] + fn every_waiting_phase_is_permitted() { + let g = gate(); + + assert_eq!(g.decide(&FdStatus::VerifyPending), Decision::GrantVerify); + assert_eq!(g.decide(&FdStatus::ApplyPending), Decision::GrantApply); + assert_eq!( + g.decide(&FdStatus::ActivationPending), + Decision::GrantActivate + ); + assert_eq!( + g.decide(&FdStatus::SvnCommitPending { component: 7 }), + Decision::GrantSvnCommit { component: 7 } + ); + } + + #[test] + fn a_cancel_is_acknowledged() { + assert_eq!(gate().decide(&FdStatus::Cancelled), Decision::AckCancel); + } + + #[test] + fn a_status_that_is_not_waiting_gets_no_answer() { + let g = gate(); + + assert_eq!(g.decide(&FdStatus::Idle { reason: 0 }), Decision::Idle); + assert_eq!(g.decide(&FdStatus::ReadyXfer), Decision::Idle); + assert_eq!( + g.decide(&FdStatus::PhaseFailed { + phase: 6, + result_code: 2 + }), + Decision::Idle + ); + } + + #[test] + fn a_decision_names_the_operation_it_sends() { + assert_eq!(Decision::Idle.op(), None); + assert_eq!( + Decision::AcceptOffer { staging_base: 0 }.op(), + Some(PldmOp::AcceptOffer) + ); + assert_eq!(Decision::GrantVerify.op(), Some(PldmOp::GrantVerify)); + assert_eq!(Decision::AckCancel.op(), Some(PldmOp::AckCancel)); + } + + /// The gate never refuses: no status produces a Reject or Deny + /// operation. This is the property that makes it a stand-in and not a + /// policy, so it is worth pinning. + #[test] + fn no_status_produces_a_refusal() { + let g = gate(); + let every_status = [ + FdStatus::Idle { reason: 0 }, + FdStatus::ReadyXfer, + FdStatus::OfferPending { + target: 1, + total: 16, + mode: TransferMode::InTransport, + svn_delayed: true, + }, + FdStatus::VerifyPending, + FdStatus::ApplyPending, + FdStatus::ActivationPending, + FdStatus::SvnCommitPending { component: 0 }, + FdStatus::PhaseFailed { + phase: 6, + result_code: 1, + }, + FdStatus::Cancelled, + ]; + + for status in every_status { + let refused = matches!( + g.decide(&status).op(), + Some( + PldmOp::RejectOffer + | PldmOp::DenyVerify + | PldmOp::DenyApply + | PldmOp::DenyActivate + | PldmOp::DenySvnCommit + ) + ); + assert!(!refused, "refused {status:?}"); + } + } +}