diff --git a/services/ocp-recovery/server/BUILD.bazel b/services/ocp-recovery/server/BUILD.bazel new file mode 100644 index 000000000..5b7c85cf1 --- /dev/null +++ b/services/ocp-recovery/server/BUILD.bazel @@ -0,0 +1,27 @@ +# 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 = "ocp_recovery_server", + srcs = [ + "src/dispatch.rs", + "src/lib.rs", + "src/registers.rs", + "src/staging.rs", + ], + crate_name = "openprot_ocp_recovery_server", + edition = "2024", + visibility = ["//visibility:public"], + deps = [ + "//services/ocp-recovery/api:ocp_recovery_api", + "@rust_crates//:zerocopy", + ], +) + +# Host tests: build on the host platform, no kernel/QEMU. +rust_test( + name = "ocp_recovery_server_test", + crate = ":ocp_recovery_server", +) diff --git a/services/ocp-recovery/server/src/dispatch.rs b/services/ocp-recovery/server/src/dispatch.rs new file mode 100644 index 000000000..d7740ef01 --- /dev/null +++ b/services/ocp-recovery/server/src/dispatch.rs @@ -0,0 +1,361 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! One agent access in, one answer out. + +use openprot_ocp_recovery_api::command::RecoveryCommand; +use openprot_ocp_recovery_api::error::WireError; +use openprot_ocp_recovery_api::regs::{DeviceReset, DeviceStatus, IndirectFifoCtrl, RecoveryCtrl}; +use zerocopy::IntoBytes; + +use crate::registers::RecoveryRegisterFile; +use crate::staging::{FifoSink, StagingError}; + +/// Serves one access from the Recovery Agent. +/// +/// `command_byte` is the command of the transaction, `write` carries the +/// payload of a write and is `None` for a read, and `transport_max` is the +/// largest payload one access of the transport carries +/// ([`SMBUS_MAX_PAYLOAD`](openprot_ocp_recovery_api::SMBUS_MAX_PAYLOAD) on +/// SMBus). The return value is how many bytes of `resp` a read filled; a +/// write fills none. +/// +/// A refused access latches its [`ProtocolError`] into DEVICE_STATUS byte 1 +/// and changes no other state, so the agent reads the error on its next +/// DEVICE_STATUS poll, where the read clears it. The dispatch never decides +/// policy: an accepted `RECOVERY_CTRL` or `DEVICE_RESET` write only lands in +/// the register, and the runtime finds it through the event takes. +pub fn dispatch( + file: &mut RecoveryRegisterFile, + sink: &mut S, + command_byte: u8, + write: Option<&[u8]>, + resp: &mut [u8], + transport_max: usize, +) -> usize { + let result = match RecoveryCommand::try_from(command_byte) { + Ok(command) => match write { + None => serve_read(file, command, resp), + Some(bytes) => serve_write(file, sink, command, bytes, transport_max).map(|()| 0), + }, + Err(e) => Err(e), + }; + match result { + Ok(len) => len, + Err(e) => { + file.device_status_mut().set_protocol_error(e.into()); + 0 + } + } +} + +/// Copies register bytes into `resp`, or reports why the command serves none. +fn serve_read( + file: &mut RecoveryRegisterFile, + command: RecoveryCommand, + resp: &mut [u8], +) -> Result { + match command { + RecoveryCommand::ProtCap => copy(file.prot_cap().as_bytes(), resp), + RecoveryCommand::DeviceId => copy(file.device_id(), resp), + RecoveryCommand::DeviceStatus => { + // The register serves its bytes with the latched protocol error, + // and the read clears the error: the one clear-on-read of the + // interface, done here so no other path forgets it. + let status = file.device_status_mut(); + let len = copy(status.as_bytes(), resp)?; + status.take_protocol_error(); + Ok(len) + } + RecoveryCommand::DeviceReset => copy(file.device_reset_mut().as_bytes(), resp), + RecoveryCommand::RecoveryCtrl => copy(file.recovery_ctrl_mut().as_bytes(), resp), + RecoveryCommand::RecoveryStatus => copy(file.recovery_status().as_bytes(), resp), + RecoveryCommand::HwStatus => copy(file.hw_status().as_bytes(), resp), + RecoveryCommand::IndirectFifoCtrl => copy(file.fifo_ctrl_mut().as_bytes(), resp), + RecoveryCommand::IndirectFifoStatus => copy(file.fifo_status_mut().as_bytes(), resp), + // The eRoT serves the FIFO code region and no indirect memory window + // or vendor command; reading the write aperture back is not defined + // either. PROT_CAP advertises what exists, and an agent that asks + // anyway reads the unsupported-command error from DEVICE_STATUS. + RecoveryCommand::IndirectCtrl + | RecoveryCommand::IndirectStatus + | RecoveryCommand::IndirectData + | RecoveryCommand::Vendor + | RecoveryCommand::IndirectFifoData => Err(WireError::UnknownCommand(command as u8)), + } +} + +/// Applies one agent write, or reports why the register refused it. +fn serve_write( + file: &mut RecoveryRegisterFile, + sink: &mut S, + command: RecoveryCommand, + bytes: &[u8], + transport_max: usize, +) -> Result<(), WireError> { + match command { + RecoveryCommand::DeviceReset => { + let sized = DeviceReset::from_wire(bytes, transport_max)?; + file.device_reset_mut().apply_agent_write(sized) + } + RecoveryCommand::RecoveryCtrl => { + let sized = RecoveryCtrl::from_wire(bytes, transport_max)?; + file.recovery_ctrl_mut().apply_agent_write(sized) + } + RecoveryCommand::IndirectFifoCtrl => { + let sized = IndirectFifoCtrl::from_wire(bytes, transport_max)?; + let ctrl = file.fifo_ctrl_mut(); + ctrl.apply_agent_write(sized)?; + if ctrl.reset_requested() { + let size = ctrl.image_size_bytes(); + sink.begin_image(size).map_err(staging_refusal(command))?; + // The reset ran, so the device clears the field and zeroes + // the indices, which is what the agent polls for. + ctrl.clear_reset(); + file.fifo_status_mut().reset_indices(); + } + Ok(()) + } + RecoveryCommand::IndirectFifoData => { + command.check_write(bytes.len(), transport_max)?; + sink.append(bytes).map_err(staging_refusal(command))?; + file.fifo_status_mut().record_drained_write(bytes.len()); + Ok(()) + } + // ReadWrite in the spec, not served by the eRoT: no indirect memory + // window, no vendor command. + RecoveryCommand::IndirectCtrl | RecoveryCommand::IndirectData | RecoveryCommand::Vendor => { + Err(WireError::UnknownCommand(command as u8)) + } + // Read-only commands; check_write names the refusal. + RecoveryCommand::ProtCap + | RecoveryCommand::DeviceId + | RecoveryCommand::DeviceStatus + | RecoveryCommand::RecoveryStatus + | RecoveryCommand::HwStatus + | RecoveryCommand::IndirectStatus + | RecoveryCommand::IndirectFifoStatus => command.check_write(bytes.len(), transport_max), + } +} + +/// A staging refusal reaches the agent as an unsupported parameter: the +/// write was well-formed and the announced size or the region capacity is +/// what refused it. +fn staging_refusal(command: RecoveryCommand) -> impl Fn(StagingError) -> WireError { + move |_| WireError::UnsupportedParameter { + command, + offset: 0, + value: 0, + } +} + +fn copy(src: &[u8], resp: &mut [u8]) -> Result { + let dst = resp + .get_mut(..src.len()) + .ok_or(WireError::UnknownCommand(0))?; + dst.copy_from_slice(src); + Ok(src.len()) +} + +const _: () = { + // serve_read copies at most a DEVICE_ID block; the dispatch caller sizes + // resp to the largest register, which the api command table caps at 255. + assert!(DeviceStatus::SIZE <= 255); +}; + +#[cfg(test)] +mod tests { + use super::*; + use crate::registers::RecoveryRegisterFile; + use crate::staging::RamStaging; + use openprot_ocp_recovery_api::regs::{ + capability, DeviceState, ImageIndex, ProtCap, RecoveryReason, RecoveryState, + ACTIVATE_RECOVERY_IMAGE, FIFO_RESET, + }; + use openprot_ocp_recovery_api::{ProtocolError, SMBUS_MAX_PAYLOAD}; + + const DEVICE_ID: &[u8] = &[0u8; 24]; + + fn file() -> RecoveryRegisterFile { + let prot_cap = ProtCap::new( + capability::IDENTIFICATION | capability::PUSH_C_IMAGE | capability::FIFO_CMS, + 1, + 0, + 0, + ); + let fifo_status = + openprot_ocp_recovery_api::regs::IndirectFifoStatus::code_region(0x100, 252) + .expect("sizes divide by 4"); + RecoveryRegisterFile::new(prot_cap, DEVICE_ID, fifo_status) + } + + fn read( + file: &mut RecoveryRegisterFile, + sink: &mut RamStaging<0x100>, + command: RecoveryCommand, + resp: &mut [u8], + ) -> usize { + dispatch(file, sink, command as u8, None, resp, SMBUS_MAX_PAYLOAD) + } + + fn write( + file: &mut RecoveryRegisterFile, + sink: &mut RamStaging<0x100>, + command: RecoveryCommand, + bytes: &[u8], + ) { + let mut resp = [0u8; 0]; + dispatch( + file, + sink, + command as u8, + Some(bytes), + &mut resp, + SMBUS_MAX_PAYLOAD, + ); + } + + /// Byte 1 of a DEVICE_STATUS read is the latched protocol error. + fn protocol_error_byte(file: &mut RecoveryRegisterFile, sink: &mut RamStaging<0x100>) -> u8 { + let mut resp = [0u8; 64]; + let len = read(file, sink, RecoveryCommand::DeviceStatus, &mut resp); + assert!(len >= 2); + resp[1] + } + + #[test] + fn an_unknown_command_latches_unsupported_and_the_read_clears_it() { + let (mut file, mut sink) = (file(), RamStaging::new()); + let mut resp = [0u8; 8]; + let len = dispatch( + &mut file, + &mut sink, + 0x21, + None, + &mut resp, + SMBUS_MAX_PAYLOAD, + ); + assert_eq!(len, 0); + assert_eq!( + protocol_error_byte(&mut file, &mut sink), + ProtocolError::UnsupportedCommand as u8 + ); + // The DEVICE_STATUS read cleared the latch. + assert_eq!( + protocol_error_byte(&mut file, &mut sink), + ProtocolError::None as u8 + ); + } + + #[test] + fn a_write_to_a_read_only_command_latches_unsupported_command() { + let (mut file, mut sink) = (file(), RamStaging::new()); + write(&mut file, &mut sink, RecoveryCommand::ProtCap, &[0u8; 15]); + assert_eq!( + protocol_error_byte(&mut file, &mut sink), + ProtocolError::UnsupportedCommand as u8 + ); + } + + #[test] + fn the_streaming_boot_sequence_lands_the_image_and_raises_the_activation() { + let (mut file, mut sink) = (file(), RamStaging::new()); + file.set_device_state( + DeviceState::RecoveryModeReady, + RecoveryReason::FlashlessStreamingBoot, + ); + file.set_recovery_status(RecoveryState::AwaitingImage, ImageIndex::FIRST); + + // The agent reads PROT_CAP and finds the push capability. + let mut resp = [0u8; 64]; + let len = read(&mut file, &mut sink, RecoveryCommand::ProtCap, &mut resp); + assert_eq!(len, ProtCap::SIZE); + assert_eq!(&resp[..8], b"OCP RECV"); + + // The agent programs the FIFO: CMS 0, reset, 8-byte image. + let mut ctrl = [0u8; 6]; + ctrl[1] = FIFO_RESET; + ctrl[2..].copy_from_slice(&2u32.to_le_bytes()); // 2 dwords + write( + &mut file, + &mut sink, + RecoveryCommand::IndirectFifoCtrl, + &ctrl, + ); + assert_eq!( + protocol_error_byte(&mut file, &mut sink), + ProtocolError::None as u8 + ); + + // The reset ran and the device cleared the field. + let len = read( + &mut file, + &mut sink, + RecoveryCommand::IndirectFifoCtrl, + &mut resp, + ); + assert_eq!(len, 6); + assert_eq!(resp[1], 0x0); + + // The image arrives in two chunks and the indices follow. + write( + &mut file, + &mut sink, + RecoveryCommand::IndirectFifoData, + &[1, 2, 3, 4], + ); + write( + &mut file, + &mut sink, + RecoveryCommand::IndirectFifoData, + &[5, 6, 7, 8], + ); + assert!(sink.is_complete()); + + // The agent activates; the device sees the request once and the + // field reads back cleared. + write( + &mut file, + &mut sink, + RecoveryCommand::RecoveryCtrl, + &[0, 0x1, ACTIVATE_RECOVERY_IMAGE], + ); + let request = file.take_activation().expect("activation pending"); + assert_eq!(request.cms, 0); + assert!(file.take_activation().is_none()); + let len = read( + &mut file, + &mut sink, + RecoveryCommand::RecoveryCtrl, + &mut resp, + ); + assert_eq!(len, 3); + assert_eq!(resp[2], 0x0); + } + + #[test] + fn a_data_chunk_past_the_announced_size_is_refused_and_not_staged() { + let (mut file, mut sink) = (file(), RamStaging::new()); + let mut ctrl = [0u8; 6]; + ctrl[1] = FIFO_RESET; + ctrl[2..].copy_from_slice(&1u32.to_le_bytes()); // 4 bytes announced + write( + &mut file, + &mut sink, + RecoveryCommand::IndirectFifoCtrl, + &ctrl, + ); + + write( + &mut file, + &mut sink, + RecoveryCommand::IndirectFifoData, + &[1, 2, 3, 4, 5], + ); + assert_eq!( + protocol_error_byte(&mut file, &mut sink), + ProtocolError::UnsupportedParameter as u8 + ); + assert!(sink.is_empty()); + } +} diff --git a/services/ocp-recovery/server/src/lib.rs b/services/ocp-recovery/server/src/lib.rs new file mode 100644 index 000000000..d76116849 --- /dev/null +++ b/services/ocp-recovery/server/src/lib.rs @@ -0,0 +1,41 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The device side of the OCP Recovery interface: the register file and the +//! dispatch that answers the Recovery Agent. +//! +//! Everything here is transport-free and host-testable, the +//! `services/i2c/server` half of the pattern. The register file instance +//! lives in the i2c server-runtime loop at run time, because only that loop +//! can answer an SMBus read within the clock-stretch budget; this crate gives +//! the loop a [`dispatch`] to call and the policy side the event takes +//! ([`RecoveryRegisterFile::take_activation`], +//! [`RecoveryRegisterFile::take_reset_request`]) to poll. +//! +//! The dispatch is mechanical: it serves register bytes, latches protocol +//! errors into DEVICE_STATUS byte 1 and drains `INDIRECT_FIFO_DATA` payloads +//! into a [`FifoSink`]. What a state means, when the device status changes +//! and what happens after an activation is policy, decided by the caller +//! through the setters and takes. + +#![cfg_attr(not(test), no_std)] +#![forbid(unsafe_code)] +#![warn(missing_docs)] +#![deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable, + clippy::todo, + clippy::unimplemented +)] +// Tests unwrap results whose shape the same lines settle. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] + +mod dispatch; +mod registers; +mod staging; + +pub use dispatch::dispatch; +pub use registers::{ActivationRequest, RecoveryRegisterFile}; +pub use staging::{FifoSink, RamStaging, StagingError}; diff --git a/services/ocp-recovery/server/src/registers.rs b/services/ocp-recovery/server/src/registers.rs new file mode 100644 index 000000000..38fafb788 --- /dev/null +++ b/services/ocp-recovery/server/src/registers.rs @@ -0,0 +1,159 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The register file the eRoT serves to the Recovery Agent. + +use openprot_ocp_recovery_api::regs::{ + DeviceReset, DeviceState, DeviceStatus, HwStatus, ImageIndex, ImageSelection, IndirectFifoCtrl, + IndirectFifoStatus, ProtCap, RecoveryCtrl, RecoveryReason, RecoveryState, RecoveryStatus, + ResetControl, +}; + +/// An activation the Recovery Agent requested through `RECOVERY_CTRL`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ActivationRequest { + /// The component memory space the agent selected. + pub cms: u8, + /// Where the agent pointed the device for the image. + pub selection: Option, +} + +/// One instance of every register of the recovery interface. +/// +/// The i2c server-runtime owns the instance and calls [`dispatch`] with it +/// for every agent access; the policy side reaches it through the setters and +/// the event takes. The takes are the device half of the "Write 1, Device +/// Clears" contract: reading the event clears the field, so the agent sees +/// the command value only until the device has acted. +/// +/// [`dispatch`]: crate::dispatch +pub struct RecoveryRegisterFile { + /// Preformatted `DEVICE_ID` response, laid out by the board. + device_id: &'static [u8], + prot_cap: ProtCap, + device_status: DeviceStatus, + device_reset: DeviceReset, + recovery_ctrl: RecoveryCtrl, + recovery_status: RecoveryStatus, + hw_status: HwStatus, + fifo_ctrl: IndirectFifoCtrl, + fifo_status: IndirectFifoStatus, +} + +impl RecoveryRegisterFile { + /// Builds the register file a device starts with. + /// + /// `prot_cap` carries the capabilities the board advertises, `device_id` + /// the preformatted identity block, and `fifo_status` describes the code + /// region the staging memory backs. The device status starts at pending + /// and the recovery status at not-in-recovery; the policy side moves both. + pub fn new( + prot_cap: ProtCap, + device_id: &'static [u8], + fifo_status: IndirectFifoStatus, + ) -> Self { + Self { + device_id, + prot_cap, + device_status: DeviceStatus::pending(), + device_reset: DeviceReset::default(), + recovery_ctrl: RecoveryCtrl::default(), + recovery_status: RecoveryStatus::default(), + hw_status: HwStatus::default(), + fifo_ctrl: IndirectFifoCtrl::default(), + fifo_status, + } + } + + /// Reports a device state and the reason code that belongs with it. + pub fn set_device_state(&mut self, state: DeviceState, reason: RecoveryReason) { + self.device_status.set_state(state, reason); + } + + /// Reports how far the device has come with one image. + pub fn set_recovery_status(&mut self, state: RecoveryState, image: ImageIndex) { + self.recovery_status.set(state, image); + } + + /// Advances the DEVICE_STATUS heartbeat counter. + pub fn tick_heartbeat(&mut self) { + self.device_status.tick_heartbeat(); + } + + /// The hardware status register, for the board to fill. + pub fn hw_status_mut(&mut self) -> &mut HwStatus { + &mut self.hw_status + } + + /// Takes a pending activation request and clears the activate field, the + /// device half of the "Write 1, Device Clears" contract. The agent reads + /// 0x0 from `RECOVERY_CTRL` byte 2 afterwards. + pub fn take_activation(&mut self) -> Option { + if !self.recovery_ctrl.activation_requested() { + return None; + } + self.recovery_ctrl.clear_activation(); + Some(ActivationRequest { + cms: self.recovery_ctrl.cms(), + selection: self.recovery_ctrl.image_selection(), + }) + } + + /// Takes a pending reset command and clears `DEVICE_RESET` byte 0. The + /// forced-recovery and interface bytes keep their values, which is what + /// the spec has the device clear. + pub fn take_reset_request(&mut self) -> Option { + match self.device_reset.reset_control() { + Some(ResetControl::None) | None => None, + Some(reset) => { + self.device_reset.clear_reset_control(); + Some(reset) + } + } + } + + /// The mode the agent armed for the next platform reset. The field stays + /// set across the take: the spec clears it through a platform reset, not + /// through the device acting on it. + pub fn armed_forced_recovery( + &mut self, + ) -> Option { + self.device_reset.forced_recovery() + } + + pub(crate) fn device_id(&self) -> &'static [u8] { + self.device_id + } + + pub(crate) fn prot_cap(&self) -> &ProtCap { + &self.prot_cap + } + + pub(crate) fn device_status_mut(&mut self) -> &mut DeviceStatus { + &mut self.device_status + } + + pub(crate) fn device_reset_mut(&mut self) -> &mut DeviceReset { + &mut self.device_reset + } + + pub(crate) fn recovery_ctrl_mut(&mut self) -> &mut RecoveryCtrl { + &mut self.recovery_ctrl + } + + pub(crate) fn recovery_status(&self) -> &RecoveryStatus { + &self.recovery_status + } + + pub(crate) fn hw_status(&self) -> &HwStatus { + &self.hw_status + } + + pub(crate) fn fifo_ctrl_mut(&mut self) -> &mut IndirectFifoCtrl { + &mut self.fifo_ctrl + } + + pub(crate) fn fifo_status_mut(&mut self) -> &mut IndirectFifoStatus { + &mut self.fifo_status + } +} diff --git a/services/ocp-recovery/server/src/staging.rs b/services/ocp-recovery/server/src/staging.rs new file mode 100644 index 000000000..755fc34bd --- /dev/null +++ b/services/ocp-recovery/server/src/staging.rs @@ -0,0 +1,181 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Where the pushed image lands. + +/// Why the staging region refused bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum StagingError { + /// The write would run past the size the agent announced in + /// `INDIRECT_FIFO_CTRL`, or past the capacity of the region. + Overflow, + /// Bytes arrived before the agent announced an image size. + NoImageAnnounced, +} + +impl core::fmt::Display for StagingError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Overflow => write!(f, "write past the announced image size"), + Self::NoImageAnnounced => write!(f, "bytes before an image size was announced"), + } + } +} + +impl core::error::Error for StagingError {} + +/// The sink one `INDIRECT_FIFO_DATA` payload drains into. +/// +/// The dispatch calls [`FifoSink::begin_image`] when the agent programs +/// `INDIRECT_FIFO_CTRL` with a reset, and [`FifoSink::append`] for every data +/// payload. The real sink is the staging region the verify path reads back; +/// tests use [`RamStaging`]. +pub trait FifoSink { + /// Starts a fresh image of `size_bytes`, discarding anything staged. + /// + /// Refuses a size the region cannot hold, so the refusal reaches the + /// agent before any bytes move. + fn begin_image(&mut self, size_bytes: u64) -> Result<(), StagingError>; + + /// Appends one payload. + fn append(&mut self, bytes: &[u8]) -> Result<(), StagingError>; +} + +/// A RAM-backed staging region. +/// +/// `len` and `read_at` mirror the `ImageSource` shape the orchestrator verify +/// path consumes (`services/orchestrator/driver/src/board.rs`); the trait +/// impl itself lives in an orchestrator adapter so this crate does not depend +/// on the orchestrator driver. +pub struct RamStaging { + buf: [u8; N], + len: usize, + announced: Option, +} + +impl RamStaging { + /// An empty region with no image announced. + pub const fn new() -> Self { + Self { + buf: [0; N], + len: 0, + announced: None, + } + } + + /// How many bytes the agent has pushed so far. + pub fn len(&self) -> usize { + self.len + } + + /// Whether nothing is staged. + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Whether the staged bytes amount to the size the agent announced. + pub fn is_complete(&self) -> bool { + self.announced + .is_some_and(|announced| announced == self.len as u64) + } + + /// Reads staged bytes starting at `offset`, the `ImageSource::read_at` + /// shape. `None` means the range runs past what is staged. + pub fn read_at(&self, offset: usize, buf: &mut [u8]) -> Option<()> { + let end = offset.checked_add(buf.len())?; + let staged = self.buf.get(offset..end.min(self.len))?; + if staged.len() != buf.len() { + return None; + } + buf.copy_from_slice(staged); + Some(()) + } +} + +impl Default for RamStaging { + fn default() -> Self { + Self::new() + } +} + +impl FifoSink for RamStaging { + fn begin_image(&mut self, size_bytes: u64) -> Result<(), StagingError> { + if size_bytes > N as u64 { + return Err(StagingError::Overflow); + } + self.len = 0; + self.announced = Some(size_bytes); + Ok(()) + } + + fn append(&mut self, bytes: &[u8]) -> Result<(), StagingError> { + let announced = self.announced.ok_or(StagingError::NoImageAnnounced)?; + let end = self + .len + .checked_add(bytes.len()) + .ok_or(StagingError::Overflow)?; + if end as u64 > announced { + return Err(StagingError::Overflow); + } + // begin_image bounds announced by N, so end fits the buffer. + let dst = self + .buf + .get_mut(self.len..end) + .ok_or(StagingError::Overflow)?; + dst.copy_from_slice(bytes); + self.len = end; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bytes_before_an_announced_image_are_refused() { + let mut staging = RamStaging::<16>::new(); + assert_eq!(staging.append(&[1, 2]), Err(StagingError::NoImageAnnounced)); + } + + #[test] + fn an_image_larger_than_the_region_is_refused_before_bytes_move() { + let mut staging = RamStaging::<16>::new(); + assert_eq!(staging.begin_image(17), Err(StagingError::Overflow)); + assert!(staging.begin_image(16).is_ok()); + } + + #[test] + fn a_write_past_the_announced_size_is_refused_and_the_staged_bytes_stay() { + let mut staging = RamStaging::<16>::new(); + staging.begin_image(4).unwrap(); + staging.append(&[1, 2, 3]).unwrap(); + assert_eq!(staging.append(&[4, 5]), Err(StagingError::Overflow)); + assert_eq!(staging.len(), 3); + assert!(!staging.is_complete()); + staging.append(&[4]).unwrap(); + assert!(staging.is_complete()); + } + + #[test] + fn read_at_serves_the_staged_bytes_and_refuses_past_the_end() { + let mut staging = RamStaging::<16>::new(); + staging.begin_image(4).unwrap(); + staging.append(&[1, 2, 3, 4]).unwrap(); + + let mut out = [0u8; 2]; + staging.read_at(1, &mut out).unwrap(); + assert_eq!(out, [2, 3]); + assert!(staging.read_at(3, &mut out).is_none()); + } + + #[test] + fn a_new_image_discards_the_staged_one() { + let mut staging = RamStaging::<16>::new(); + staging.begin_image(2).unwrap(); + staging.append(&[1, 2]).unwrap(); + staging.begin_image(2).unwrap(); + assert!(staging.is_empty()); + } +}