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:?}"); + } + } +} 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)); + } +}