From fcce4df338238b26c8282524371d481e1461bf3b Mon Sep 17 00:00:00 2001 From: Christina Quast Date: Thu, 29 Oct 2026 11:40:16 +0100 Subject: [PATCH] services: Add the Recovery Agent sequence test for streaming boot The register file and the dispatch answer one access at a time, and their in-crate tests check one access at a time. This drives the whole sequence the BMC runs for a streaming boot: read PROT_CAP and DEVICE_ID, read the reason code that says the device has no image to boot, watch the heartbeat move, program INDIRECT_FIFO_CTRL, push the image in transport-sized chunks while polling INDIRECT_FIFO_STATUS, activate through RECOVERY_CTRL, then poll RECOVERY_STATUS and DEVICE_STATUS to the terminal values. The agent half goes through dispatch with command bytes and payloads, the device half through the owner methods, which is the split the i2c server-runtime and the policy loop will run. The test decodes register bytes rather than calling the accessors, because that is what the agent has: a response buffer. StagingSpy wraps RamStaging to record the size the device announced to the region, which RamStaging does not keep. The claims the sequence cannot make in passing get their own test: a failed stage names the image index it failed on, an activate value of 0x1 is refused and leaves the field at ACTIVATE_IDLE rather than the 0xe a bitwise clear would leave, an activation with the image still short reaches the device because the verify path checks completeness and the dispatch does not, and arming forced recovery survives the reset take that clears only byte 0. The transport is out of scope, so the chunk size and the transport_max argument are one constant, MAX_TRANSFER_BYTES, and nothing here assumes SMBus. Gap found while writing this: RecoveryCommand::scope() has no caller. HW_STATUS, INDIRECT_FIFO_CTRL, INDIRECT_FIFO_STATUS and INDIRECT_FIFO_DATA are Scope::RecoveryMode, and the dispatch serves them whether or not the device is in recovery mode. The test asserts what the code does today and leaves the scope check to the crate that owns the dispatch. Signed-off-by: Christina Quast --- services/ocp-recovery/tests/BUILD.bazel | 17 + .../ocp-recovery/tests/agent_sequence_host.rs | 523 ++++++++++++++++++ 2 files changed, 540 insertions(+) create mode 100644 services/ocp-recovery/tests/BUILD.bazel create mode 100644 services/ocp-recovery/tests/agent_sequence_host.rs diff --git a/services/ocp-recovery/tests/BUILD.bazel b/services/ocp-recovery/tests/BUILD.bazel new file mode 100644 index 000000000..13ca4d3c6 --- /dev/null +++ b/services/ocp-recovery/tests/BUILD.bazel @@ -0,0 +1,17 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@rules_rust//rust:defs.bzl", "rust_test") + +# Host end-to-end: the Recovery Agent sequence for streaming boot, driven +# through ocp_recovery_server::dispatch with the bytes the transport hands +# the device. No kernel, no QEMU, no transport. +rust_test( + name = "agent_sequence_host_test", + srcs = ["agent_sequence_host.rs"], + edition = "2024", + deps = [ + "//services/ocp-recovery/api:ocp_recovery_api", + "//services/ocp-recovery/server:ocp_recovery_server", + ], +) diff --git a/services/ocp-recovery/tests/agent_sequence_host.rs b/services/ocp-recovery/tests/agent_sequence_host.rs new file mode 100644 index 000000000..80df24a78 --- /dev/null +++ b/services/ocp-recovery/tests/agent_sequence_host.rs @@ -0,0 +1,523 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The Recovery Agent sequence for streaming boot, driven against the device +//! the way the BMC drives it. +//! +//! The eRoT is the Device and the BMC is the Recovery Agent. The agent half +//! of every step here goes through [`dispatch`] with a command byte and a +//! payload, which is all the transport hands the device; the device half +//! goes through the owner methods of [`RecoveryRegisterFile`], which the +//! policy side calls between agent accesses. Nothing in this file reaches +//! into the register structs, so the test reads the registers the way the +//! agent reads them: bytes, decoded by the agent. +//! +//! The transport is out of scope: the register file answers the same bytes +//! over I3C, USB and SMBus, and `transport_max` is the only place the +//! transport shows. The sequence uses the FIFO push path, the one the +//! streaming-boot capability bits advertise. + +use openprot_ocp_recovery_api::command::RecoveryCommand; +use openprot_ocp_recovery_api::regs::{ + capability, DeviceReset, DeviceState, ForcedRecovery, ImageIndex, ImageSelection, + IndirectFifoStatus, ProtCap, RecoveryReason, RecoveryState, RecoveryStatus, ResetControl, + ACTIVATE_IDLE, ACTIVATE_RECOVERY_IMAGE, FIFO_IDLE, FIFO_RESET, PROT_CAP_MAGIC, + PROT_CAP_VERSION_MAJOR, PROT_CAP_VERSION_MINOR, +}; +use openprot_ocp_recovery_api::ProtocolError; +use openprot_ocp_recovery_server::{ + dispatch, FifoSink, RamStaging, RecoveryRegisterFile, StagingError, +}; + +/// Largest payload one access of the transport carries. The register file +/// takes this as an argument, so the test states one value and uses it both +/// as the chunk size the agent pushes and as the cap the device enforces. +const MAX_TRANSFER_BYTES: usize = 64; + +/// Size of the image the agent streams. A whole number of chunks, so the +/// last push is a full one and the FIFO indices land on the announced size. +const IMAGE_SIZE_BYTES: usize = 4 * MAX_TRANSFER_BYTES; + +/// Capacity of the staging region, which has to hold the whole image. +const STAGING_BYTES: usize = IMAGE_SIZE_BYTES; + +/// The FIFO indices count 4-byte units. +const DWORD: usize = 4; + +/// Largest read any recovery command answers, from the command table. +const MAX_READ_BYTES: usize = 255; + +/// The preformatted `DEVICE_ID` block the board lays out. The contents are +/// opaque to the register file, so the test uses a pattern no zeroed +/// response buffer could pass for. +const DEVICE_ID: &[u8] = &[0xa5; 24]; + +/// `DEVICE_STATUS` byte offsets, as the agent decodes them. +const STATUS_STATE: usize = 0; +const STATUS_PROTOCOL_ERROR: usize = 1; +const STATUS_REASON: usize = 2; +const STATUS_HEARTBEAT: usize = 4; + +/// `PROT_CAP` byte offsets. +const PROT_CAP_VERSION: usize = 8; +const PROT_CAP_CAPABILITIES: usize = 10; + +/// `RECOVERY_CTRL` byte offsets. +const CTRL_CMS: usize = 0; +const CTRL_IMAGE_SELECTION: usize = 1; +const CTRL_ACTIVATE: usize = 2; + +/// `INDIRECT_FIFO_CTRL` byte offsets. +const FIFO_CTRL_CMS: usize = 0; +const FIFO_CTRL_RESET: usize = 1; +const FIFO_CTRL_IMAGE_SIZE: usize = 2; + +/// `DEVICE_RESET` byte offsets. +const RESET_CONTROL: usize = 0; +const RESET_FORCED_RECOVERY: usize = 1; +const RESET_INTERFACE_CONTROL: usize = 2; + +/// `INDIRECT_FIFO_STATUS` byte offsets. +const FIFO_STATUS_BITS: usize = 0; +const FIFO_STATUS_REGION: usize = 1; +const FIFO_STATUS_WRITE_INDEX: usize = 4; +const FIFO_STATUS_READ_INDEX: usize = 8; + +/// `RECOVERY_STATUS` byte 0 splits into the state and the image index. +const RECOVERY_STATE_MASK: u8 = 0x0f; +const RECOVERY_INDEX_SHIFT: u8 = 4; + +/// The component memory space the image goes to. The device advertises one. +const CODE_CMS: u8 = 0; + +/// A staging region that also records the size the device announced to it. +/// +/// `RamStaging` holds the bytes and answers `is_complete`, but the size the +/// dispatch passed to `begin_image` leaves no trace there, and that the +/// device forwards the agent's image size is part of what this test checks. +struct StagingSpy { + region: RamStaging, + announced: Option, +} + +impl StagingSpy { + fn new() -> Self { + Self { + region: RamStaging::new(), + announced: None, + } + } +} + +impl FifoSink for StagingSpy { + fn begin_image(&mut self, size_bytes: u64) -> Result<(), StagingError> { + self.region.begin_image(size_bytes)?; + self.announced = Some(size_bytes); + Ok(()) + } + + fn append(&mut self, bytes: &[u8]) -> Result<(), StagingError> { + self.region.append(bytes) + } +} + +/// The device under test, with the staging region its FIFO drains into. +struct Device { + file: RecoveryRegisterFile, + staging: StagingSpy, +} + +impl Device { + /// A device that advertises streaming boot over the FIFO region. + fn new() -> Self { + let prot_cap = ProtCap::new( + capability::IDENTIFICATION + | capability::DEVICE_STATUS + | capability::PUSH_C_IMAGE + | capability::FLASHLESS_BOOT + | capability::FIFO_CMS, + 1, + 0, + 0, + ); + let fifo_status = + IndirectFifoStatus::code_region(STAGING_BYTES as u32, MAX_TRANSFER_BYTES as u32) + .expect("the region and the transfer cap divide by 4"); + Self { + file: RecoveryRegisterFile::new(prot_cap, DEVICE_ID, fifo_status), + staging: StagingSpy::new(), + } + } + + /// One agent read, which answers the bytes the transport sends back. + fn agent_read(&mut self, command: RecoveryCommand) -> Vec { + let mut resp = [0u8; MAX_READ_BYTES]; + let len = dispatch( + &mut self.file, + &mut self.staging, + command as u8, + None, + &mut resp, + MAX_TRANSFER_BYTES, + ); + resp[..len].to_vec() + } + + /// One agent write. The transport carries no answer back, so what the + /// device made of it shows in the next read. + fn agent_write(&mut self, command: RecoveryCommand, bytes: &[u8]) { + let mut resp = [0u8; 0]; + let len = dispatch( + &mut self.file, + &mut self.staging, + command as u8, + Some(bytes), + &mut resp, + MAX_TRANSFER_BYTES, + ); + assert_eq!(len, 0, "a write answers no bytes"); + } + + /// Reads `DEVICE_STATUS`. The read clears the protocol error, so each + /// call is one agent poll and the bytes it returns are the only look at + /// that error the test gets. + fn poll_device_status(&mut self) -> Vec { + self.agent_read(RecoveryCommand::DeviceStatus) + } + + /// The protocol error of one `DEVICE_STATUS` poll. + fn poll_protocol_error(&mut self) -> u8 { + self.poll_device_status()[STATUS_PROTOCOL_ERROR] + } + + fn poll_fifo_status(&mut self) -> Vec { + self.agent_read(RecoveryCommand::IndirectFifoStatus) + } +} + +fn reason_code(status: &[u8]) -> u16 { + u16::from_le_bytes([status[STATUS_REASON], status[STATUS_REASON + 1]]) +} + +fn heartbeat(status: &[u8]) -> u16 { + u16::from_le_bytes([status[STATUS_HEARTBEAT], status[STATUS_HEARTBEAT + 1]]) +} + +fn advertised_capabilities(prot_cap: &[u8]) -> u16 { + u16::from_le_bytes([ + prot_cap[PROT_CAP_CAPABILITIES], + prot_cap[PROT_CAP_CAPABILITIES + 1], + ]) +} + +/// Bits 0 to 3 of `RECOVERY_STATUS` byte 0. +fn recovery_state(status: &[u8]) -> Option { + RecoveryState::from_bits(status[0] & RECOVERY_STATE_MASK) +} + +/// Bits 4 to 7 of `RECOVERY_STATUS` byte 0, the image a status refers to. +fn recovery_image_index(status: &[u8]) -> u8 { + status[0] >> RECOVERY_INDEX_SHIFT +} + +/// The write and read indices of `INDIRECT_FIFO_STATUS`, in 4-byte units. +fn fifo_indices(status: &[u8]) -> (u32, u32) { + let index = |offset: usize| { + u32::from_le_bytes( + status[offset..offset + 4] + .try_into() + .expect("4 bytes of an index"), + ) + }; + ( + index(FIFO_STATUS_WRITE_INDEX), + index(FIFO_STATUS_READ_INDEX), + ) +} + +/// The `RECOVERY_CTRL` write that activates the image the agent pushed. +fn activate_write(selection: ImageSelection) -> [u8; 3] { + let mut write = [0u8; 3]; + write[CTRL_CMS] = CODE_CMS; + write[CTRL_IMAGE_SELECTION] = selection as u8; + write[CTRL_ACTIVATE] = ACTIVATE_RECOVERY_IMAGE; + write +} + +/// The `INDIRECT_FIFO_CTRL` write that programs a push of `size_bytes`. +fn fifo_ctrl_write(size_bytes: usize) -> [u8; 6] { + let mut ctrl = [0u8; 6]; + ctrl[FIFO_CTRL_CMS] = CODE_CMS; + ctrl[FIFO_CTRL_RESET] = FIFO_RESET; + let dwords = (size_bytes / DWORD) as u32; + ctrl[FIFO_CTRL_IMAGE_SIZE..].copy_from_slice(&dwords.to_le_bytes()); + ctrl +} + +/// The image the agent streams, a pattern that catches a chunk landing at +/// the wrong offset. +fn image() -> Vec { + (0..IMAGE_SIZE_BYTES).map(|i| (i % 251) as u8).collect() +} + +#[test] +fn the_agent_streams_an_image_and_polls_the_device_to_a_terminal_status() { + let mut device = Device::new(); + + // Image bytes before the agent has programmed the FIFO: the staging + // region has no size to hold them against, so the device refuses the + // write and the agent reads why on its next poll. The poll clears the + // error, which the second poll shows. + device.agent_write(RecoveryCommand::IndirectFifoData, &[0u8; DWORD]); + assert_eq!( + device.poll_protocol_error(), + ProtocolError::UnsupportedParameter as u8 + ); + assert_eq!(device.poll_protocol_error(), ProtocolError::None as u8); + assert!(device.staging.region.is_empty()); + + // The device enters recovery mode with no local image to boot. + device.file.set_device_state( + DeviceState::RecoveryModeReady, + RecoveryReason::FlashlessStreamingBoot, + ); + device + .file + .set_recovery_status(RecoveryState::AwaitingImage, ImageIndex::FIRST); + + // The agent reads what the device supports before it drives anything. + let prot_cap = device.agent_read(RecoveryCommand::ProtCap); + assert_eq!(prot_cap.len(), ProtCap::SIZE); + assert_eq!(&prot_cap[..PROT_CAP_MAGIC.len()], &PROT_CAP_MAGIC); + assert_eq!(prot_cap[PROT_CAP_VERSION], PROT_CAP_VERSION_MAJOR); + assert_eq!(prot_cap[PROT_CAP_VERSION + 1], PROT_CAP_VERSION_MINOR); + let streaming_boot = capability::FLASHLESS_BOOT | capability::FIFO_CMS; + assert_eq!( + advertised_capabilities(&prot_cap) & streaming_boot, + streaming_boot + ); + assert_eq!(device.agent_read(RecoveryCommand::DeviceId), DEVICE_ID); + + // The device reports why it is in recovery: it has no image to boot. + let status = device.poll_device_status(); + assert_eq!(status[STATUS_STATE], DeviceState::RecoveryModeReady as u8); + assert_eq!( + reason_code(&status), + RecoveryReason::FlashlessStreamingBoot as u16 + ); + assert_eq!(status[STATUS_PROTOCOL_ERROR], ProtocolError::None as u8); + + // The agent watches the heartbeat to tell a live device from a hung one. + let before = heartbeat(&status); + device.file.tick_heartbeat(); + assert_eq!(heartbeat(&device.poll_device_status()), before + 1); + + // The agent programs the FIFO for the image it is about to push. + device.agent_write( + RecoveryCommand::IndirectFifoCtrl, + &fifo_ctrl_write(IMAGE_SIZE_BYTES), + ); + assert_eq!(device.staging.announced, Some(IMAGE_SIZE_BYTES as u64)); + // The reset ran, so the device zeroed the field: the agent polls this + // byte to learn the reset is done. + let fifo_ctrl = device.agent_read(RecoveryCommand::IndirectFifoCtrl); + assert_eq!(fifo_ctrl[FIFO_CTRL_RESET], FIFO_IDLE); + assert_eq!(fifo_ctrl[FIFO_CTRL_CMS], CODE_CMS); + + let fifo_status = device.poll_fifo_status(); + assert_eq!(fifo_status.len(), IndirectFifoStatus::SIZE); + assert_eq!( + fifo_status[FIFO_STATUS_BITS] & IndirectFifoStatus::EMPTY, + IndirectFifoStatus::EMPTY + ); + assert_eq!( + fifo_status[FIFO_STATUS_REGION], + IndirectFifoStatus::REGION_CODE + ); + + // The image goes up in transport-sized chunks. The device drains each + // one into staging before it answers, so the FIFO reads empty between + // chunks and both indices follow the bytes pushed so far. + let image = image(); + let mut pushed = 0; + for chunk in image.chunks(MAX_TRANSFER_BYTES) { + device.agent_write(RecoveryCommand::IndirectFifoData, chunk); + pushed += chunk.len(); + let fifo_status = device.poll_fifo_status(); + assert_eq!(fifo_status[FIFO_STATUS_BITS] & IndirectFifoStatus::FULL, 0); + assert_eq!( + fifo_status[FIFO_STATUS_BITS] & IndirectFifoStatus::EMPTY, + IndirectFifoStatus::EMPTY + ); + let drained = (pushed / DWORD) as u32; + assert_eq!(fifo_indices(&fifo_status), (drained, drained)); + } + assert_eq!(pushed, IMAGE_SIZE_BYTES); + assert_eq!(device.poll_protocol_error(), ProtocolError::None as u8); + assert!(device.staging.region.is_complete()); + let mut staged = vec![0u8; IMAGE_SIZE_BYTES]; + device + .staging + .region + .read_at(0, &mut staged) + .expect("the whole image is staged"); + assert_eq!(staged, image); + + // The agent activates the image it pushed. + device.agent_write( + RecoveryCommand::RecoveryCtrl, + &activate_write(ImageSelection::FromMemoryWindow), + ); + let request = device + .file + .take_activation() + .expect("the agent asked for an activation"); + assert_eq!(request.cms, CODE_CMS); + assert_eq!(request.selection, Some(ImageSelection::FromMemoryWindow)); + assert!( + device.file.take_activation().is_none(), + "the device acts on one activation once" + ); + // The device zeroes the whole field, so the agent reads the idle value + // and not the 0xe a bitwise clear of 0x1 would leave behind. + let ctrl = device.agent_read(RecoveryCommand::RecoveryCtrl); + assert_eq!(ctrl[CTRL_ACTIVATE], ACTIVATE_IDLE); + assert_eq!(ctrl[CTRL_CMS], CODE_CMS); + assert_eq!( + ctrl[CTRL_IMAGE_SELECTION], + ImageSelection::FromMemoryWindow as u8 + ); + + // The verify and trial-boot path runs, and the agent follows it. + device + .file + .set_recovery_status(RecoveryState::BootingImage, ImageIndex::FIRST); + device.file.set_device_state( + DeviceState::RecoveryPending, + RecoveryReason::FlashlessStreamingBoot, + ); + let status = device.agent_read(RecoveryCommand::RecoveryStatus); + assert_eq!(status.len(), RecoveryStatus::SIZE); + assert_eq!(recovery_state(&status), Some(RecoveryState::BootingImage)); + + // The image booted. Both registers reach a terminal value the agent can + // stop on, and the reason code goes back to 0 with a state that carries + // none, so no stale reason reaches the agent. + device + .file + .set_recovery_status(RecoveryState::Successful, ImageIndex::FIRST); + device + .file + .set_device_state(DeviceState::RunningRecoveryImage, RecoveryReason::NoFailure); + let status = device.agent_read(RecoveryCommand::RecoveryStatus); + assert_eq!(recovery_state(&status), Some(RecoveryState::Successful)); + assert_eq!(recovery_image_index(&status), ImageIndex::FIRST.get()); + let status = device.poll_device_status(); + assert_eq!( + status[STATUS_STATE], + DeviceState::RunningRecoveryImage as u8 + ); + assert_eq!(reason_code(&status), RecoveryReason::NoFailure as u16); + assert_eq!(status[STATUS_PROTOCOL_ERROR], ProtocolError::None as u8); +} + +#[test] +fn a_failed_stage_names_the_image_it_failed_on() { + let mut device = Device::new(); + let second_stage = ImageIndex::new(1).expect("1 fits the index field"); + + device + .file + .set_recovery_status(RecoveryState::AuthenticationError, second_stage); + + // An agent that reads only the state learns the image failed; the index + // is what tells it which of the stages it pushed. + let status = device.agent_read(RecoveryCommand::RecoveryStatus); + assert_eq!( + recovery_state(&status), + Some(RecoveryState::AuthenticationError) + ); + assert_eq!(recovery_image_index(&status), second_stage.get()); +} + +#[test] +fn an_activate_value_of_one_is_refused_and_the_field_keeps_the_idle_value() { + let mut device = Device::new(); + + // 0x1 is what a device that treated byte 2 as a bitwise clear would + // accept, and it would leave 0xe behind. The write is refused instead, + // and every byte of the register keeps the value it had. + let mut write = activate_write(ImageSelection::FromMemoryWindow); + write[CTRL_ACTIVATE] = 0x1; + device.agent_write(RecoveryCommand::RecoveryCtrl, &write); + + assert_eq!( + device.poll_protocol_error(), + ProtocolError::UnsupportedParameter as u8 + ); + let ctrl = device.agent_read(RecoveryCommand::RecoveryCtrl); + assert_eq!(ctrl[CTRL_ACTIVATE], ACTIVATE_IDLE); + assert_eq!(ctrl[CTRL_IMAGE_SELECTION], ImageSelection::None as u8); + assert!(device.file.take_activation().is_none()); +} + +#[test] +fn an_activation_before_the_image_is_complete_still_reaches_the_device() { + let mut device = Device::new(); + device.agent_write( + RecoveryCommand::IndirectFifoCtrl, + &fifo_ctrl_write(IMAGE_SIZE_BYTES), + ); + device.agent_write( + RecoveryCommand::IndirectFifoData, + &image()[..MAX_TRANSFER_BYTES], + ); + + device.agent_write( + RecoveryCommand::RecoveryCtrl, + &activate_write(ImageSelection::FromMemoryWindow), + ); + + // The dispatch serves registers and does not decide policy: it hands the + // activation over with the image short, and the staging region is what + // says the image is not there yet. Refusing an incomplete image is the + // verify path, which runs after the take. + assert!(device.file.take_activation().is_some()); + assert!(!device.staging.region.is_complete()); + assert_eq!(device.poll_protocol_error(), ProtocolError::None as u8); +} + +#[test] +fn the_agent_arms_forced_recovery_and_the_take_clears_only_the_reset() { + let mut device = Device::new(); + + // The agent commands a reset and arms the mode the device comes back in. + let mut write = [0u8; 3]; + write[RESET_CONTROL] = ResetControl::ResetDevice as u8; + write[RESET_FORCED_RECOVERY] = ForcedRecovery::RecoveryMode as u8; + write[RESET_INTERFACE_CONTROL] = DeviceReset::INTERFACE_MASTERING_DISABLED; + device.agent_write(RecoveryCommand::DeviceReset, &write); + + assert_eq!( + device.file.take_reset_request(), + Some(ResetControl::ResetDevice) + ); + assert!( + device.file.take_reset_request().is_none(), + "the device performs one reset once" + ); + + // The device cleared the reset it performed. The armed mode stays: a + // platform reset is what clears it, and the device reads it on the way + // back up. + let reset = device.agent_read(RecoveryCommand::DeviceReset); + assert_eq!(reset[RESET_CONTROL], ResetControl::None as u8); + assert_eq!( + reset[RESET_FORCED_RECOVERY], + ForcedRecovery::RecoveryMode as u8 + ); + assert_eq!( + device.file.armed_forced_recovery(), + Some(ForcedRecovery::RecoveryMode) + ); +}