From fc7761a694f349c47531b4e48c745633db615b28 Mon Sep 17 00:00:00 2001 From: Manish Ranjan Mahanta Date: Mon, 29 Jun 2026 15:07:59 +0530 Subject: [PATCH 1/2] ipmi_kcs: productionize KCS BMC device with SEL egress + clock injection Import the IPMI KCS device crate and add production-readiness changes over the PoC: - SelSink trait for forwarding SEL entries to a host (no-op by default); enables OpenHCL to publish guest SEL to host ETW. - BmcClock trait to remove std::time dependency from the SEL store; SystemClock default, injectable for paravisor. - with_deps constructors on IpmiKcsDevice/SelStore; register resolver in openvmm_resources (x86_64). - Tests for sink egress and injected clock. --- Cargo.toml | 2 + openvmm/openvmm_resources/Cargo.toml | 1 + openvmm/openvmm_resources/src/lib.rs | 2 + vm/devices/ipmi_kcs/Cargo.toml | 23 + vm/devices/ipmi_kcs/src/lib.rs | 625 +++++++++++++++++++++++ vm/devices/ipmi_kcs/src/protocol.rs | 144 ++++++ vm/devices/ipmi_kcs/src/resolver.rs | 32 ++ vm/devices/ipmi_kcs/src/sel.rs | 568 ++++++++++++++++++++ vm/devices/ipmi_kcs/src/sink.rs | 67 +++ vm/devices/ipmi_kcs_resources/Cargo.toml | 14 + vm/devices/ipmi_kcs_resources/src/lib.rs | 21 + 11 files changed, 1499 insertions(+) create mode 100644 vm/devices/ipmi_kcs/Cargo.toml create mode 100644 vm/devices/ipmi_kcs/src/lib.rs create mode 100644 vm/devices/ipmi_kcs/src/protocol.rs create mode 100644 vm/devices/ipmi_kcs/src/resolver.rs create mode 100644 vm/devices/ipmi_kcs/src/sel.rs create mode 100644 vm/devices/ipmi_kcs/src/sink.rs create mode 100644 vm/devices/ipmi_kcs_resources/Cargo.toml create mode 100644 vm/devices/ipmi_kcs_resources/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 74166fc35c8..058bbef4ffe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -262,6 +262,8 @@ hyperv_ic = { path = "vm/devices/hyperv_ic" } hyperv_ic_protocol = { path = "vm/devices/hyperv_ic_protocol" } hyperv_ic_resources = { path = "vm/devices/hyperv_ic_resources" } hyperv_ic_guest = { path = "vm/devices/hyperv_ic_guest" } +ipmi_kcs = { path = "vm/devices/ipmi_kcs" } +ipmi_kcs_resources = { path = "vm/devices/ipmi_kcs_resources" } input_core = { path = "vm/devices/input_core" } cxl_spec = { path = "vm/devices/cxl" } underhill_config = { path = "vm/devices/get/underhill_config" } diff --git a/openvmm/openvmm_resources/Cargo.toml b/openvmm/openvmm_resources/Cargo.toml index 1a26af22a2e..da95676279a 100644 --- a/openvmm/openvmm_resources/Cargo.toml +++ b/openvmm/openvmm_resources/Cargo.toml @@ -106,6 +106,7 @@ vnc_worker.workspace = true rusqlite = { workspace = true, features = ["bundled"] } [target.'cfg(target_arch = "x86_64")'.dependencies] +ipmi_kcs.workspace = true serial_16550.workspace = true serial_debugcon.workspace = true diff --git a/openvmm/openvmm_resources/src/lib.rs b/openvmm/openvmm_resources/src/lib.rs index ac5664bc6c2..f79d1b5674a 100644 --- a/openvmm/openvmm_resources/src/lib.rs +++ b/openvmm/openvmm_resources/src/lib.rs @@ -44,6 +44,8 @@ vm_resource::register_static_resolvers! { serial_pl011::resolver::SerialPl011Resolver, chipset::battery::resolver::BatteryResolver, guest_watchdog::resolver::HyperVGuestWatchdogResolver, + #[cfg(guest_arch = "x86_64")] + ipmi_kcs::resolver::IpmiKcsResolver, // Non-volatile stores vmcore::non_volatile_store::resources::EphemeralNonVolatileStoreResolver, diff --git a/vm/devices/ipmi_kcs/Cargo.toml b/vm/devices/ipmi_kcs/Cargo.toml new file mode 100644 index 00000000000..0e116289db0 --- /dev/null +++ b/vm/devices/ipmi_kcs/Cargo.toml @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "ipmi_kcs" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +chipset_device.workspace = true +chipset_device_resources.workspace = true +inspect.workspace = true +ipmi_kcs_resources.workspace = true +open_enum.workspace = true +tracelimit.workspace = true +vm_resource.workspace = true +vmcore.workspace = true + +[dev-dependencies] +test_with_tracing.workspace = true + +[lints] +workspace = true diff --git a/vm/devices/ipmi_kcs/src/lib.rs b/vm/devices/ipmi_kcs/src/lib.rs new file mode 100644 index 00000000000..710d71d6731 --- /dev/null +++ b/vm/devices/ipmi_kcs/src/lib.rs @@ -0,0 +1,625 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! IPMI KCS (Keyboard Controller Style) device implementation. +//! +//! Exposes a virtual IPMI BMC via the KCS system interface at I/O ports +//! 0xCA2 (data) and 0xCA3 (status/command). Supports System Event Log (SEL) +//! operations and basic device identification. + +#![forbid(unsafe_code)] + +pub mod protocol; +pub mod resolver; +mod sel; +pub mod sink; + +use chipset_device::ChipsetDevice; +use chipset_device::io::IoError; +use chipset_device::io::IoResult; +use chipset_device::pio::PortIoIntercept; +use inspect::InspectMut; +use protocol::CompletionCode; +use protocol::IpmiCommand; +use protocol::IpmiNetFn; +use protocol::KcsCommand; +use protocol::KcsState; +use protocol::STATUS_CD; +use protocol::STATUS_IBF; +use protocol::STATUS_OBF; +use protocol::STATUS_STATE_MASK; +use protocol::set_state_in_status; +use sel::SelStore; +use sink::SelDeps; +use std::collections::VecDeque; +use std::ops::RangeInclusive; +use vmcore::device_state::ChangeDeviceState; + +/// IPMI KCS device. +#[derive(InspectMut)] +pub struct IpmiKcsDevice { + // KCS protocol state + #[inspect(hex)] + status: u8, + #[inspect(hex)] + data_out: u8, + #[inspect(with = "Vec::len")] + write_buffer: Vec, + #[inspect(with = "VecDeque::len")] + read_buffer: VecDeque, + write_end_pending: bool, + + // IPMI layer + sel: SelStore, + + // Static I/O region + #[inspect(skip)] + pio_region: (&'static str, RangeInclusive), +} + +impl IpmiKcsDevice { + /// Create a new IPMI KCS device in the IDLE state with default (no-op sink, + /// system clock) dependencies. + pub fn new() -> Self { + Self::with_deps(SelDeps::default()) + } + + /// Create a new IPMI KCS device with the given SEL egress/clock + /// dependencies. Used when hosting inside OpenHCL to forward SEL entries. + pub fn with_deps(deps: SelDeps) -> Self { + Self { + status: KcsState::IDLE_STATE.0, + data_out: 0, + write_buffer: Vec::new(), + read_buffer: VecDeque::new(), + write_end_pending: false, + sel: SelStore::with_deps(deps), + pio_region: ( + "ipmi_kcs", + protocol::KCS_DATA_REG..=protocol::KCS_STATUS_CMD_REG, + ), + } + } + + /// Get the current KCS state from the status register. + fn kcs_state(&self) -> KcsState { + KcsState(self.status & STATUS_STATE_MASK) + } + + /// Set the KCS state in the status register. + fn set_kcs_state(&mut self, state: KcsState) { + self.status = set_state_in_status(self.status, state); + } + + /// Handle a write to the command register (port 0xCA3). + fn handle_command_write(&mut self, cmd: u8) { + let cmd = KcsCommand(cmd); + self.status |= STATUS_CD; // Mark last write as command. + + match cmd { + KcsCommand::WRITE_START => { + self.write_buffer.clear(); + self.write_end_pending = false; + self.set_kcs_state(KcsState::WRITE_STATE); + // Set OBF so host reads dummy byte before writing data. + self.data_out = 0x00; + self.status |= STATUS_OBF; + } + KcsCommand::WRITE_END => { + // Next data byte will be the last one. + self.write_end_pending = true; + self.set_kcs_state(KcsState::WRITE_STATE); + // Set OBF so host reads dummy byte before writing last byte. + self.data_out = 0x00; + self.status |= STATUS_OBF; + } + KcsCommand::READ => { + // This is handled as a data write during READ state. + // Should not appear on the command register normally. + tracelimit::warn_ratelimited!("unexpected READ command on command register"); + } + KcsCommand::GET_STATUS_ABORT => { + self.handle_abort(); + } + _ => { + tracelimit::warn_ratelimited!(cmd = cmd.0, "unknown KCS command"); + self.handle_abort(); + } + } + + // Clear IBF — we've consumed the command. + self.status &= !STATUS_IBF; + } + + /// Handle a write to the data register (port 0xCA2). + fn handle_data_write(&mut self, byte: u8) { + self.status &= !STATUS_CD; // Mark last write as data. + + match self.kcs_state() { + KcsState::WRITE_STATE => { + self.write_buffer.push(byte); + + if self.write_end_pending { + // This was the last byte. Process the complete message. + self.write_end_pending = false; + self.process_ipmi_message(); + } else { + // More bytes expected. Set OBF for dummy read. + self.data_out = 0x00; + self.status |= STATUS_OBF; + } + } + KcsState::READ_STATE => { + // Host is acknowledging a byte read (should be READ=0x68). + // Advance to next byte. + if let Some(next_byte) = self.read_buffer.pop_front() { + self.data_out = next_byte; + self.status |= STATUS_OBF; + // Stay in READ state. + } else { + // No more bytes — transition to IDLE. + self.data_out = 0x00; // Dummy status byte. + self.status |= STATUS_OBF; + self.set_kcs_state(KcsState::IDLE_STATE); + } + } + _ => { + tracelimit::warn_ratelimited!( + state = self.kcs_state().0, + "data write in unexpected state" + ); + } + } + + // Clear IBF — we've consumed the data. + self.status &= !STATUS_IBF; + } + + /// Handle GET_STATUS/ABORT — recover from error state. + fn handle_abort(&mut self) { + self.write_buffer.clear(); + self.read_buffer.clear(); + self.write_end_pending = false; + // Enter READ state with error status byte. + self.read_buffer.push_back(0xFF); // Error status. + self.data_out = 0x00; + self.status |= STATUS_OBF; + self.set_kcs_state(KcsState::READ_STATE); + } + + /// Process a completed IPMI message from the write buffer. + fn process_ipmi_message(&mut self) { + if self.write_buffer.len() < 2 { + tracelimit::warn_ratelimited!(len = self.write_buffer.len(), "IPMI message too short"); + self.enter_error_state(); + return; + } + + let netfn_lun = self.write_buffer[0]; + let cmd = IpmiCommand(self.write_buffer[1]); + let data = &self.write_buffer[2..]; + let netfn = protocol::extract_netfn(netfn_lun); + + let response_data = match IpmiNetFn(netfn) { + IpmiNetFn::APP_REQUEST => self.handle_app_command(cmd, data), + IpmiNetFn::STORAGE_REQUEST => self.sel.handle_command(cmd, data), + _ => { + tracelimit::warn_ratelimited!(netfn = netfn, "unsupported IPMI NetFn"); + vec![CompletionCode::INVALID_COMMAND.0] + } + }; + + // Build response: [ResponseNetFn/LUN, Cmd, ...response_data] + let resp_netfn_lun = protocol::response_netfn_lun(netfn_lun); + self.read_buffer.clear(); + self.read_buffer.push_back(resp_netfn_lun); + self.read_buffer.push_back(cmd.0); + for b in response_data { + self.read_buffer.push_back(b); + } + + // Enter READ state with first byte ready. + if let Some(first_byte) = self.read_buffer.pop_front() { + self.data_out = first_byte; + } + self.status |= STATUS_OBF; + self.set_kcs_state(KcsState::READ_STATE); + } + + /// Handle App NetFn commands. + fn handle_app_command(&self, cmd: IpmiCommand, _data: &[u8]) -> Vec { + match cmd { + IpmiCommand::GET_DEVICE_ID => self.cmd_get_device_id(), + _ => { + tracelimit::warn_ratelimited!(cmd = cmd.0, "unsupported App command"); + vec![CompletionCode::INVALID_COMMAND.0] + } + } + } + + /// Get Device ID (NetFn=App, Cmd=0x01). + /// Response format per IPMI v2.0 Section 20.1. + fn cmd_get_device_id(&self) -> Vec { + vec![ + CompletionCode::SUCCESS.0, // Completion code + 0x20, // Device ID + 0x01, // Device revision + 0x01, // Firmware revision 1 (major, bit 7=0 = device available) + 0x00, // Firmware revision 2 (minor, BCD) + 0x02, // IPMI version 2.0 (BCD: low nibble=major, high=minor) + 0x2D, // Additional device support: SEL + SDR Repo + Sensor + FRU + IPMB Event Receiver + 0x37, + 0x01, + 0x00, // Manufacturer ID (IANA 311 = Microsoft, LS byte first) + 0x01, + 0x00, // Product ID (LS byte first) — 0x0001 = OpenVMM virtual BMC + ] + } + + /// Enter error state. + fn enter_error_state(&mut self) { + self.write_buffer.clear(); + self.read_buffer.clear(); + self.write_end_pending = false; + self.set_kcs_state(KcsState::ERROR_STATE); + self.data_out = 0xFF; + self.status |= STATUS_OBF; + } +} + +impl ChangeDeviceState for IpmiKcsDevice { + fn start(&mut self) {} + + async fn stop(&mut self) {} + + async fn reset(&mut self) { + self.status = KcsState::IDLE_STATE.0; + self.data_out = 0; + self.write_buffer.clear(); + self.read_buffer.clear(); + self.write_end_pending = false; + self.sel.reset(); + } +} + +impl ChipsetDevice for IpmiKcsDevice { + fn supports_pio(&mut self) -> Option<&mut dyn PortIoIntercept> { + Some(self) + } +} + +impl PortIoIntercept for IpmiKcsDevice { + fn io_read(&mut self, io_port: u16, data: &mut [u8]) -> IoResult { + if data.len() != 1 { + return IoResult::Err(IoError::InvalidAccessSize); + } + + data[0] = match io_port { + protocol::KCS_DATA_REG => { + // Reading data clears OBF. + self.status &= !STATUS_OBF; + self.data_out + } + protocol::KCS_STATUS_CMD_REG => { + // Reading status does not change any state. + self.status + } + _ => return IoResult::Err(IoError::InvalidRegister), + }; + + IoResult::Ok + } + + fn io_write(&mut self, io_port: u16, data: &[u8]) -> IoResult { + if data.len() != 1 { + return IoResult::Err(IoError::InvalidAccessSize); + } + + match io_port { + protocol::KCS_DATA_REG => { + self.handle_data_write(data[0]); + } + protocol::KCS_STATUS_CMD_REG => { + self.handle_command_write(data[0]); + } + _ => return IoResult::Err(IoError::InvalidRegister), + } + + IoResult::Ok + } + + fn get_static_regions(&mut self) -> &[(&str, RangeInclusive)] { + std::slice::from_ref(&self.pio_region) + } +} + +mod save_restore { + use crate::IpmiKcsDevice; + use vmcore::save_restore::NoSavedState; + use vmcore::save_restore::RestoreError; + use vmcore::save_restore::SaveError; + use vmcore::save_restore::SaveRestore; + + impl SaveRestore for IpmiKcsDevice { + type SavedState = NoSavedState; + + fn save(&mut self) -> Result { + Ok(NoSavedState) + } + + fn restore(&mut self, state: Self::SavedState) -> Result<(), RestoreError> { + let NoSavedState = state; + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + /// Helper: simulate a full KCS write-read transaction. + /// Sends a request and returns the response bytes. + fn kcs_transfer(dev: &mut IpmiKcsDevice, request: &[u8]) -> Vec { + assert!(!request.is_empty(), "request must not be empty"); + + // 1. Write WRITE_START to command register. + dev.io_write(protocol::KCS_STATUS_CMD_REG, &[KcsCommand::WRITE_START.0]) + .unwrap(); + + // 2. Write all bytes except the last. + for &byte in &request[..request.len() - 1] { + // Wait for OBF (should be set), read dummy. + assert_obf_set(dev); + read_data(dev); // dummy read to clear OBF + dev.io_write(protocol::KCS_DATA_REG, &[byte]).unwrap(); + } + + // 3. Write WRITE_END command. + dev.io_write(protocol::KCS_STATUS_CMD_REG, &[KcsCommand::WRITE_END.0]) + .unwrap(); + + // 4. Read dummy, write last byte. + assert_obf_set(dev); + read_data(dev); // dummy read + dev.io_write(protocol::KCS_DATA_REG, &[*request.last().unwrap()]) + .unwrap(); + + // 5. READ phase — collect response bytes. + let mut response = Vec::new(); + loop { + assert_obf_set(dev); + let status = read_status(dev); + let byte = read_data(dev); + + if KcsState(status & STATUS_STATE_MASK) != KcsState::READ_STATE { + // IDLE — done. + break; + } + + response.push(byte); + // Acknowledge with READ. + dev.io_write(protocol::KCS_DATA_REG, &[KcsCommand::READ.0]) + .unwrap(); + } + + response + } + + fn read_status(dev: &mut IpmiKcsDevice) -> u8 { + let mut data = [0u8]; + dev.io_read(protocol::KCS_STATUS_CMD_REG, &mut data) + .unwrap(); + data[0] + } + + fn read_data(dev: &mut IpmiKcsDevice) -> u8 { + let mut data = [0u8]; + dev.io_read(protocol::KCS_DATA_REG, &mut data).unwrap(); + data[0] + } + + fn assert_obf_set(dev: &mut IpmiKcsDevice) { + let status = read_status(dev); + assert!( + status & STATUS_OBF != 0, + "OBF not set, status: {:#04x}", + status + ); + } + + #[test] + fn kcs_get_device_id() { + let mut dev = IpmiKcsDevice::new(); + // Get Device ID: NetFn=App(0x06), LUN=0 -> NetFn/LUN = 0x18 + let resp = kcs_transfer(&mut dev, &[0x18, 0x01]); + // Response: [NetFn/LUN, Cmd, CC, DeviceID, ...] + assert!(resp.len() >= 3, "response too short: {:?}", resp); + assert_eq!(resp[0], 0x1C); // App response NetFn/LUN + assert_eq!(resp[1], 0x01); // Command + assert_eq!(resp[2], CompletionCode::SUCCESS.0); + assert_eq!(resp[3], 0x20); // Device ID + } + + #[test] + fn kcs_sel_add_and_get_roundtrip() { + let mut dev = IpmiKcsDevice::new(); + + // Add SEL Entry: NetFn=Storage(0x0A), LUN=0 -> NetFn/LUN = 0x28 + let sel_record: [u8; 16] = [ + 0x00, 0x00, // Record ID (ignored) + 0x02, // Record Type + 0x00, 0x00, 0x00, 0x00, // Timestamp + 0x20, 0x00, // Generator ID + 0x04, // EvM Rev + 0x01, // Sensor Type + 0x42, // Sensor Number + 0x6F, // Event Dir/Type + 0x01, 0x02, 0x03, // Event Data + ]; + let mut add_req = vec![0x28, 0x44]; + add_req.extend_from_slice(&sel_record); + + let resp = kcs_transfer(&mut dev, &add_req); + assert_eq!(resp[0], 0x2C); // Storage response NetFn/LUN + assert_eq!(resp[1], 0x44); // Command + assert_eq!(resp[2], CompletionCode::SUCCESS.0); + let record_id = u16::from_le_bytes([resp[3], resp[4]]); + assert_eq!(record_id, 1); + + // Get SEL Entry. + let get_req = vec![ + 0x28, 0x43, 0x00, 0x00, // Reservation ID + resp[3], resp[4], // Record ID + 0x00, // Offset + 0xFF, // Read all + ]; + + let resp = kcs_transfer(&mut dev, &get_req); + assert_eq!(resp[0], 0x2C); // Storage response + assert_eq!(resp[1], 0x43); // Command + assert_eq!(resp[2], CompletionCode::SUCCESS.0); + // Next record ID = 0xFFFF. + assert_eq!(u16::from_le_bytes([resp[3], resp[4]]), 0xFFFF); + // Verify some fields in the record. + let record_data = &resp[5..5 + 16]; + assert_eq!(record_data[2], 0x02); // Record type + assert_eq!(record_data[11], 0x42); // Sensor number (offset 11) + assert_eq!(record_data[12], 0x6F); // Event type + } + + #[test] + fn kcs_unknown_command() { + let mut dev = IpmiKcsDevice::new(); + // Unknown command under App NetFn. + let resp = kcs_transfer(&mut dev, &[0x18, 0xFF]); + assert_eq!(resp[2], CompletionCode::INVALID_COMMAND.0); + } + + #[test] + fn kcs_unknown_netfn() { + let mut dev = IpmiKcsDevice::new(); + // NetFn=0x30 (unknown) -> NetFn/LUN = 0xC0 + let resp = kcs_transfer(&mut dev, &[0xC0, 0x01]); + assert_eq!(resp[2], CompletionCode::INVALID_COMMAND.0); + } + + #[test] + fn kcs_error_recovery() { + let mut dev = IpmiKcsDevice::new(); + + // Start a write but abort mid-stream. + dev.io_write(protocol::KCS_STATUS_CMD_REG, &[KcsCommand::WRITE_START.0]) + .unwrap(); + + // Send abort. + dev.io_write( + protocol::KCS_STATUS_CMD_REG, + &[KcsCommand::GET_STATUS_ABORT.0], + ) + .unwrap(); + + // Should be in READ state with error status. + let status = read_status(&mut dev); + assert_eq!(KcsState(status & STATUS_STATE_MASK), KcsState::READ_STATE); + + // Read through the error response. + assert!(status & STATUS_OBF != 0); + // Read and acknowledge until IDLE. + loop { + let byte_status = read_status(&mut dev); + let _byte = read_data(&mut dev); + if KcsState(byte_status & STATUS_STATE_MASK) != KcsState::READ_STATE { + break; + } + dev.io_write(protocol::KCS_DATA_REG, &[KcsCommand::READ.0]) + .unwrap(); + } + + // Now should be in IDLE state. + let status = read_status(&mut dev); + assert_eq!(KcsState(status & STATUS_STATE_MASK), KcsState::IDLE_STATE); + + // Verify the device still works after recovery. + let resp = kcs_transfer(&mut dev, &[0x18, 0x01]); + assert_eq!(resp[2], CompletionCode::SUCCESS.0); + } + + #[test] + fn kcs_sel_info_after_operations() { + let mut dev = IpmiKcsDevice::new(); + + // Get SEL Info — should be empty. + let resp = kcs_transfer(&mut dev, &[0x28, 0x40]); + assert_eq!(resp[2], CompletionCode::SUCCESS.0); + let count = u16::from_le_bytes([resp[4], resp[5]]); + assert_eq!(count, 0); + + // Add an entry. + let sel_record = [0u8; 16]; + let mut add_req = vec![0x28, 0x44]; + add_req.extend_from_slice(&sel_record); + kcs_transfer(&mut dev, &add_req); + + // Get SEL Info — should show 1. + let resp = kcs_transfer(&mut dev, &[0x28, 0x40]); + assert_eq!(resp[2], CompletionCode::SUCCESS.0); + let count = u16::from_le_bytes([resp[4], resp[5]]); + assert_eq!(count, 1); + } + + #[test] + fn kcs_invalid_access_size() { + let mut dev = IpmiKcsDevice::new(); + let mut data = [0u8; 2]; + let result = dev.io_read(protocol::KCS_DATA_REG, &mut data); + assert!(matches!(result, IoResult::Err(IoError::InvalidAccessSize))); + + let result = dev.io_write(protocol::KCS_DATA_REG, &[0, 0]); + assert!(matches!(result, IoResult::Err(IoError::InvalidAccessSize))); + } + + #[test] + fn kcs_invalid_register() { + let mut dev = IpmiKcsDevice::new(); + let mut data = [0u8]; + let result = dev.io_read(0xCA4, &mut data); + assert!(matches!(result, IoResult::Err(IoError::InvalidRegister))); + + let result = dev.io_write(0xCA4, &[0]); + assert!(matches!(result, IoResult::Err(IoError::InvalidRegister))); + } + + #[test] + fn kcs_initial_state_is_idle() { + let dev = IpmiKcsDevice::new(); + assert_eq!(dev.kcs_state(), KcsState::IDLE_STATE); + assert_eq!(dev.status & STATUS_OBF, 0); + assert_eq!(dev.status & STATUS_IBF, 0); + } + + #[test] + fn kcs_clear_sel_via_kcs() { + let mut dev = IpmiKcsDevice::new(); + + // Add two entries. + let sel_record = [0u8; 16]; + let mut add_req = vec![0x28, 0x44]; + add_req.extend_from_slice(&sel_record); + kcs_transfer(&mut dev, &add_req); + let mut add_req = vec![0x28, 0x44]; + add_req.extend_from_slice(&sel_record); + kcs_transfer(&mut dev, &add_req); + + // Clear SEL. + let clear_req = vec![0x28, 0x47, 0x00, 0x00, 0x43, 0x4C, 0x52, 0xAA]; + let resp = kcs_transfer(&mut dev, &clear_req); + assert_eq!(resp[2], CompletionCode::SUCCESS.0); + + // Verify SEL is empty. + let resp = kcs_transfer(&mut dev, &[0x28, 0x40]); + let count = u16::from_le_bytes([resp[4], resp[5]]); + assert_eq!(count, 0); + } +} diff --git a/vm/devices/ipmi_kcs/src/protocol.rs b/vm/devices/ipmi_kcs/src/protocol.rs new file mode 100644 index 00000000000..02453aea23e --- /dev/null +++ b/vm/devices/ipmi_kcs/src/protocol.rs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! KCS (Keyboard Controller Style) state machine and IPMI message protocol. + +use open_enum::open_enum; + +open_enum! { + /// KCS interface states (encoded in status register S1:S0, bits 7:6). + #[allow(missing_docs)] + pub enum KcsState: u8 { + IDLE_STATE = 0x00, + READ_STATE = 0x40, + WRITE_STATE = 0x80, + ERROR_STATE = 0xC0, + } +} + +open_enum! { + /// KCS commands written to the command register. + #[allow(missing_docs)] + pub enum KcsCommand: u8 { + GET_STATUS_ABORT = 0x60, + WRITE_START = 0x61, + WRITE_END = 0x62, + READ = 0x68, + } +} + +open_enum! { + /// IPMI Network Function codes (upper 6 bits of NetFn/LUN byte). + #[allow(missing_docs)] + pub enum IpmiNetFn: u8 { + APP_REQUEST = 0x06, + APP_RESPONSE = 0x07, + STORAGE_REQUEST = 0x0A, + STORAGE_RESPONSE = 0x0B, + } +} + +open_enum! { + /// IPMI command codes. + #[allow(missing_docs)] + pub enum IpmiCommand: u8 { + GET_DEVICE_ID = 0x01, + GET_SEL_INFO = 0x40, + GET_SEL_ENTRY = 0x43, + ADD_SEL_ENTRY = 0x44, + CLEAR_SEL = 0x47, + GET_SEL_TIME = 0x48, + SET_SEL_TIME = 0x49, + } +} + +open_enum! { + /// IPMI completion codes. + #[allow(missing_docs)] + pub enum CompletionCode: u8 { + SUCCESS = 0x00, + INVALID_COMMAND = 0xC1, + REQUEST_DATA_LENGTH_INVALID = 0xC7, + INSUFFICIENT_PRIVILEGE = 0xD4, + } +} + +/// KCS data register I/O port address (Base+0). +pub const KCS_DATA_REG: u16 = 0xCA2; +/// KCS status/command register I/O port address (Base+1). +pub const KCS_STATUS_CMD_REG: u16 = 0xCA3; + +/// Status register bit: Output Buffer Full — data available for host to read. +pub const STATUS_OBF: u8 = 0x01; +/// Status register bit: Input Buffer Full — host must wait until 0 before writing. +pub const STATUS_IBF: u8 = 0x02; +/// Status register bit: BMC has a message for the host. +pub const STATUS_SMS_ATN: u8 = 0x04; +/// Status register bit: Command/Data flag — 1 = last write was command. +pub const STATUS_CD: u8 = 0x08; +/// Status register mask for state bits (S1:S0). +pub const STATUS_STATE_MASK: u8 = 0xC0; + +/// Encode the state into the status register, preserving other bits. +pub fn set_state_in_status(status: u8, state: KcsState) -> u8 { + (status & !STATUS_STATE_MASK) | state.0 +} + +/// Extract the state from the status register. +pub fn get_state_from_status(status: u8) -> KcsState { + KcsState(status & STATUS_STATE_MASK) +} + +/// Build a NetFn/LUN byte from a network function and LUN. +pub fn netfn_lun(netfn: u8, lun: u8) -> u8 { + (netfn << 2) | (lun & 0x03) +} + +/// Extract the NetFn from a NetFn/LUN byte. +pub fn extract_netfn(netfn_lun: u8) -> u8 { + netfn_lun >> 2 +} + +/// Extract the LUN from a NetFn/LUN byte. +pub fn extract_lun(netfn_lun: u8) -> u8 { + netfn_lun & 0x03 +} + +/// Convert a request NetFn to a response NetFn (set bit 0 of NetFn, which +/// is bit 2 of the NetFn/LUN byte). +pub fn response_netfn_lun(request_netfn_lun: u8) -> u8 { + request_netfn_lun | 0x04 +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn state_encoding() { + let status = set_state_in_status(0x00, KcsState::IDLE_STATE); + assert_eq!(get_state_from_status(status), KcsState::IDLE_STATE); + + let status = set_state_in_status(STATUS_OBF | STATUS_IBF, KcsState::READ_STATE); + assert_eq!(status, 0x40 | STATUS_OBF | STATUS_IBF); + assert_eq!(get_state_from_status(status), KcsState::READ_STATE); + + let status = set_state_in_status(0x0F, KcsState::WRITE_STATE); + assert_eq!(get_state_from_status(status), KcsState::WRITE_STATE); + assert_eq!(status & !STATUS_STATE_MASK, 0x0F); + + let status = set_state_in_status(0x00, KcsState::ERROR_STATE); + assert_eq!(get_state_from_status(status), KcsState::ERROR_STATE); + } + + #[test] + fn netfn_lun_encoding() { + assert_eq!(netfn_lun(0x06, 0x00), 0x18); + assert_eq!(netfn_lun(0x0A, 0x00), 0x28); + assert_eq!(extract_netfn(0x18), 0x06); + assert_eq!(extract_lun(0x18), 0x00); + assert_eq!(response_netfn_lun(0x18), 0x1C); + assert_eq!(response_netfn_lun(0x28), 0x2C); + } +} diff --git a/vm/devices/ipmi_kcs/src/resolver.rs b/vm/devices/ipmi_kcs/src/resolver.rs new file mode 100644 index 00000000000..4bbe0d05f0d --- /dev/null +++ b/vm/devices/ipmi_kcs/src/resolver.rs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Resource resolver for the IPMI KCS chipset device. + +use crate::IpmiKcsDevice; +use chipset_device_resources::ResolveChipsetDeviceHandleParams; +use chipset_device_resources::ResolvedChipsetDevice; +use ipmi_kcs_resources::IpmiKcsHandle; +use std::convert::Infallible; +use vm_resource::ResolveResource; +use vm_resource::declare_static_resolver; +use vm_resource::kind::ChipsetDeviceHandleKind; + +/// The resource resolver for [`IpmiKcsDevice`]. +pub struct IpmiKcsResolver; + +declare_static_resolver!(IpmiKcsResolver, (ChipsetDeviceHandleKind, IpmiKcsHandle)); + +impl ResolveResource for IpmiKcsResolver { + type Output = ResolvedChipsetDevice; + type Error = Infallible; + + fn resolve( + &self, + _resource: IpmiKcsHandle, + input: ResolveChipsetDeviceHandleParams<'_>, + ) -> Result { + input.configure.omit_saved_state(); + Ok(IpmiKcsDevice::new().into()) + } +} diff --git a/vm/devices/ipmi_kcs/src/sel.rs b/vm/devices/ipmi_kcs/src/sel.rs new file mode 100644 index 00000000000..531f9d102e7 --- /dev/null +++ b/vm/devices/ipmi_kcs/src/sel.rs @@ -0,0 +1,568 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! System Event Log (SEL) storage and IPMI SEL command handling. + +use crate::protocol::CompletionCode; +use crate::protocol::IpmiCommand; +use crate::sink::SelDeps; +use inspect::Inspect; + +/// Maximum number of SEL entries. +const MAX_SEL_ENTRIES: usize = 128; + +/// Size of a single SEL record in bytes. +pub const SEL_RECORD_SIZE: usize = 16; + +/// SEL version (IPMI v1.5 / v2.0 format). +const SEL_VERSION: u8 = 0x51; + +/// A 16-byte SEL record per IPMI v2.0 Section 32. +struct SelEntry { + record_id: u16, + data: [u8; SEL_RECORD_SIZE], +} + +impl Inspect for SelEntry { + fn inspect(&self, req: inspect::Request<'_>) { + let d = &self.data; + let record_type = d[2]; + let timestamp = u32::from_le_bytes([d[3], d[4], d[5], d[6]]); + let mut resp = req.respond(); + resp.hex("record_id", self.record_id) + .hex("record_type", record_type) + .field("timestamp", timestamp); + + if record_type == 0x02 { + // Standard System Event Record (IPMI v2.0, Section 32.1) + let generator_id = u16::from_le_bytes([d[7], d[8]]); + resp.hex("generator_id", generator_id) + .hex("evm_rev", d[9]) + .hex("sensor_type", d[10]) + .hex("sensor_number", d[11]) + .hex("event_dir_type", d[12]) + .hex("event_data1", d[13]) + .hex("event_data2", d[14]) + .hex("event_data3", d[15]); + } else if (0xC0..=0xDF).contains(&record_type) { + // OEM Timestamped Record (IPMI v2.0, Section 32.2) + let manufacturer_id = + u32::from_le_bytes([d[7], d[8], d[9], 0]); + resp.field("manufacturer_id", manufacturer_id) + .hex("oem_data", u64::from_le_bytes([d[10], d[11], d[12], d[13], d[14], d[15], 0, 0])); + } else if record_type >= 0xE0 { + // OEM Non-timestamped Record (IPMI v2.0, Section 32.3) + // Bytes 3-15 are all OEM-defined (no timestamp) + resp.hex("oem_data", &d[3..]); + } else { + // Unknown record type — dump raw bytes + resp.hex("raw_data", &d[2..]); + } + } +} + +/// SEL storage. +pub struct SelStore { + entries: Vec, + next_record_id: u16, + time_offset: i64, + reservation_id: u16, + deps: SelDeps, +} + +impl Inspect for SelStore { + fn inspect(&self, req: inspect::Request<'_>) { + req.respond() + .field("entry_count", self.entries.len()) + .field("next_record_id", self.next_record_id) + .field("time_offset", self.time_offset) + .child("entries", |req| { + let mut resp = req.respond(); + for entry in &self.entries { + resp.child(&format!("{}", entry.record_id), |req| { + entry.inspect(req); + }); + } + }); + } +} + +impl SelStore { + /// Create a new empty SEL store with default (no-op sink, system clock) + /// dependencies. + pub fn new() -> Self { + Self::with_deps(SelDeps::default()) + } + + /// Create a new empty SEL store with the given egress/clock dependencies. + pub fn with_deps(deps: SelDeps) -> Self { + Self { + entries: Vec::new(), + next_record_id: 1, + time_offset: 0, + reservation_id: 0, + deps, + } + } + + /// Reset the SEL store, clearing all entries. + pub fn reset(&mut self) { + self.entries.clear(); + self.next_record_id = 1; + self.time_offset = 0; + self.reservation_id = 0; + } + + /// Get the current BMC time as seconds since 1970-01-01. + fn bmc_time(&self) -> u32 { + let now = self.deps.clock.now_unix_secs(); + let adjusted = now.saturating_add(self.time_offset); + adjusted.max(0) as u32 + } + + /// Handle an IPMI SEL command. Returns the response data (after NetFn/LUN + /// and command byte — i.e., starting with the completion code). + pub fn handle_command(&mut self, cmd: IpmiCommand, data: &[u8]) -> Vec { + match cmd { + IpmiCommand::GET_SEL_INFO => self.cmd_get_sel_info(), + IpmiCommand::GET_SEL_ENTRY => self.cmd_get_sel_entry(data), + IpmiCommand::ADD_SEL_ENTRY => self.cmd_add_sel_entry(data), + IpmiCommand::CLEAR_SEL => self.cmd_clear_sel(data), + IpmiCommand::GET_SEL_TIME => self.cmd_get_sel_time(), + IpmiCommand::SET_SEL_TIME => self.cmd_set_sel_time(data), + _ => vec![CompletionCode::INVALID_COMMAND.0], + } + } + + /// Get SEL Info (0x40). + fn cmd_get_sel_info(&self) -> Vec { + let count = self.entries.len() as u16; + let free_space = ((MAX_SEL_ENTRIES - self.entries.len()) * SEL_RECORD_SIZE) as u16; + + // Most recent addition timestamp (0 if empty). + let last_add_time: u32 = self + .entries + .last() + .map(|e| u32::from_le_bytes([e.data[3], e.data[4], e.data[5], e.data[6]])) + .unwrap_or(0); + + let mut resp = vec![CompletionCode::SUCCESS.0]; + resp.push(SEL_VERSION); + resp.extend_from_slice(&count.to_le_bytes()); + resp.extend_from_slice(&free_space.to_le_bytes()); + resp.extend_from_slice(&last_add_time.to_le_bytes()); // Most recent addition timestamp + resp.extend_from_slice(&last_add_time.to_le_bytes()); // Most recent erase timestamp (same) + resp.push(0x00); // Operation support (no overflow, delete not supported) + resp + } + + /// Get SEL Entry (0x43). + fn cmd_get_sel_entry(&self, data: &[u8]) -> Vec { + // Data: [ResvID_lo, ResvID_hi, RecordID_lo, RecordID_hi, Offset, BytesToRead] + if data.len() < 6 { + return vec![CompletionCode::REQUEST_DATA_LENGTH_INVALID.0]; + } + + let record_id = u16::from_le_bytes([data[2], data[3]]); + let offset = data[4] as usize; + let bytes_to_read = data[5] as usize; + + // Special record IDs: 0x0000 = first, 0xFFFF = last. + let entry = match record_id { + 0x0000 => self.entries.first(), + 0xFFFF => self.entries.last(), + id => self.entries.iter().find(|e| e.record_id == id), + }; + + let entry = match entry { + Some(e) => e, + None => { + // Record not found — return completion code 0xCB. + return vec![0xCB]; + } + }; + + // Determine next record ID. + let next_record_id = self + .entries + .iter() + .position(|e| e.record_id == entry.record_id) + .and_then(|idx| self.entries.get(idx + 1)) + .map(|e| e.record_id) + .unwrap_or(0xFFFF); // 0xFFFF means no more records. + + // Extract the requested portion of the record. + let end = (offset + bytes_to_read).min(SEL_RECORD_SIZE); + let start = offset.min(SEL_RECORD_SIZE); + let record_data = &entry.data[start..end]; + + let mut resp = vec![CompletionCode::SUCCESS.0]; + resp.extend_from_slice(&next_record_id.to_le_bytes()); + resp.extend_from_slice(record_data); + resp + } + + /// Add SEL Entry (0x44). + fn cmd_add_sel_entry(&mut self, data: &[u8]) -> Vec { + if data.len() < SEL_RECORD_SIZE { + return vec![CompletionCode::REQUEST_DATA_LENGTH_INVALID.0]; + } + + if self.entries.len() >= MAX_SEL_ENTRIES { + // SEL is full — return "out of space" completion code (0x80 + // per IPMI v2.0 Table 5-2, command-specific range). + return vec![0x80]; + } + + let record_id = self.next_record_id; + self.next_record_id = self.next_record_id.wrapping_add(1); + if self.next_record_id == 0 || self.next_record_id == 0xFFFF { + self.next_record_id = 1; + } + + let mut record_data = [0u8; SEL_RECORD_SIZE]; + record_data.copy_from_slice(&data[..SEL_RECORD_SIZE]); + + // Overwrite record ID with the assigned one. + record_data[0] = record_id as u8; + record_data[1] = (record_id >> 8) as u8; + + // Fill in timestamp with current BMC time. + let timestamp = self.bmc_time(); + record_data[3..7].copy_from_slice(×tamp.to_le_bytes()); + + self.entries.push(SelEntry { + record_id, + data: record_data, + }); + + // Forward the committed record to the host sink (no-op by default). + self.deps.sink.log_sel_entry(record_id, &record_data); + + let mut resp = vec![CompletionCode::SUCCESS.0]; + resp.extend_from_slice(&record_id.to_le_bytes()); + resp + } + + /// Clear SEL (0x47). + fn cmd_clear_sel(&mut self, data: &[u8]) -> Vec { + // Data: [ResvID_lo, ResvID_hi, 'C', 'L', 'R', action] + if data.len() < 6 { + return vec![CompletionCode::REQUEST_DATA_LENGTH_INVALID.0]; + } + + // Verify "CLR" signature. + if data[2] != 0x43 || data[3] != 0x4C || data[4] != 0x52 { + return vec![CompletionCode::REQUEST_DATA_LENGTH_INVALID.0]; + } + + let action = data[5]; + match action { + 0xAA => { + // Initiate erase — for virtual device, complete immediately. + self.entries.clear(); + self.next_record_id = 1; + // Return erasure complete (0x01 = erasure completed). + vec![CompletionCode::SUCCESS.0, 0x01] + } + 0x00 => { + // Get erasure status — always complete for virtual device. + vec![CompletionCode::SUCCESS.0, 0x01] + } + _ => vec![CompletionCode::REQUEST_DATA_LENGTH_INVALID.0], + } + } + + /// Get SEL Time (0x48). + fn cmd_get_sel_time(&self) -> Vec { + let time = self.bmc_time(); + let mut resp = vec![CompletionCode::SUCCESS.0]; + resp.extend_from_slice(&time.to_le_bytes()); + resp + } + + /// Set SEL Time (0x49). + fn cmd_set_sel_time(&mut self, data: &[u8]) -> Vec { + if data.len() < 4 { + return vec![CompletionCode::REQUEST_DATA_LENGTH_INVALID.0]; + } + + let new_time = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + self.time_offset = (new_time as i64) - now; + + vec![CompletionCode::SUCCESS.0] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sink::BmcClock; + use crate::sink::SelDeps; + use crate::sink::SelSink; + use std::sync::Arc; + use std::sync::Mutex; + use test_with_tracing::test; + + /// Sink that records forwarded entries for assertions. + #[derive(Default)] + struct CapturingSink { + entries: Mutex)>>, + } + + impl SelSink for CapturingSink { + fn log_sel_entry(&self, record_id: u16, record: &[u8]) { + self.entries.lock().unwrap().push((record_id, record.to_vec())); + } + } + + /// Fixed clock for deterministic timestamps. + struct FixedClock(i64); + + impl BmcClock for FixedClock { + fn now_unix_secs(&self) -> i64 { + self.0 + } + } + + fn make_sel_record() -> [u8; 16] { + [ + 0x00, 0x00, // Record ID (ignored, assigned by BMC) + 0x02, // Record Type = System Event + 0x00, 0x00, 0x00, 0x00, // Timestamp (BMC fills in) + 0x20, 0x00, // Generator ID + 0x04, // EvM Rev + 0x01, // Sensor Type = Temperature + 0x42, // Sensor Number + 0x6F, // Event Dir / Event Type + 0x01, 0x02, 0x03, // Event Data 1-3 + ] + } + + #[test] + fn sel_add_and_get_entry() { + let mut store = SelStore::new(); + let record = make_sel_record(); + + // Add an entry. + let resp = store.handle_command(IpmiCommand::ADD_SEL_ENTRY, &record); + assert_eq!(resp[0], CompletionCode::SUCCESS.0); + assert_eq!(resp.len(), 3); // CC + 2 bytes record ID + let record_id = u16::from_le_bytes([resp[1], resp[2]]); + assert_eq!(record_id, 1); + + // Get the entry back. + let get_data = [ + 0x00, 0x00, // Reservation ID + resp[1], resp[2], // Record ID + 0x00, // Offset + 0xFF, // Read all + ]; + let resp = store.handle_command(IpmiCommand::GET_SEL_ENTRY, &get_data); + assert_eq!(resp[0], CompletionCode::SUCCESS.0); + // Next record ID = 0xFFFF (no more). + assert_eq!(u16::from_le_bytes([resp[1], resp[2]]), 0xFFFF); + // Record data starts at offset 3. + let record_data = &resp[3..3 + SEL_RECORD_SIZE]; + // Record ID should be 1. + assert_eq!(u16::from_le_bytes([record_data[0], record_data[1]]), 1); + // Record type should match. + assert_eq!(record_data[2], 0x02); + // Sensor number should match (offset 11 in SEL record). + assert_eq!(record_data[11], 0x42); + // Event data should match. + assert_eq!(record_data[13], 0x01); + assert_eq!(record_data[14], 0x02); + assert_eq!(record_data[15], 0x03); + } + + #[test] + fn sel_get_info() { + let mut store = SelStore::new(); + + // Empty SEL. + let resp = store.handle_command(IpmiCommand::GET_SEL_INFO, &[]); + assert_eq!(resp[0], CompletionCode::SUCCESS.0); + assert_eq!(resp[1], SEL_VERSION); + // Count = 0. + assert_eq!(u16::from_le_bytes([resp[2], resp[3]]), 0); + + // Add an entry. + let record = make_sel_record(); + store.handle_command(IpmiCommand::ADD_SEL_ENTRY, &record); + + let resp = store.handle_command(IpmiCommand::GET_SEL_INFO, &[]); + assert_eq!(resp[0], CompletionCode::SUCCESS.0); + // Count = 1. + assert_eq!(u16::from_le_bytes([resp[2], resp[3]]), 1); + } + + #[test] + fn sel_clear() { + let mut store = SelStore::new(); + let record = make_sel_record(); + + // Add two entries. + store.handle_command(IpmiCommand::ADD_SEL_ENTRY, &record); + store.handle_command(IpmiCommand::ADD_SEL_ENTRY, &record); + + // Verify count = 2. + let resp = store.handle_command(IpmiCommand::GET_SEL_INFO, &[]); + assert_eq!(u16::from_le_bytes([resp[2], resp[3]]), 2); + + // Clear SEL. + let clear_data = [0x00, 0x00, 0x43, 0x4C, 0x52, 0xAA]; + let resp = store.handle_command(IpmiCommand::CLEAR_SEL, &clear_data); + assert_eq!(resp[0], CompletionCode::SUCCESS.0); + assert_eq!(resp[1], 0x01); // Erasure complete. + + // Verify count = 0. + let resp = store.handle_command(IpmiCommand::GET_SEL_INFO, &[]); + assert_eq!(u16::from_le_bytes([resp[2], resp[3]]), 0); + } + + #[test] + fn sel_get_entry_not_found() { + let mut store = SelStore::new(); + let get_data = [0x00, 0x00, 0x01, 0x00, 0x00, 0xFF]; + let resp = store.handle_command(IpmiCommand::GET_SEL_ENTRY, &get_data); + // 0xCB = requested record not found. + assert_eq!(resp[0], 0xCB); + } + + #[test] + fn sel_get_first_and_last() { + let mut store = SelStore::new(); + let record = make_sel_record(); + + store.handle_command(IpmiCommand::ADD_SEL_ENTRY, &record); + store.handle_command(IpmiCommand::ADD_SEL_ENTRY, &record); + store.handle_command(IpmiCommand::ADD_SEL_ENTRY, &record); + + // Get first (record ID 0x0000). + let get_data = [0x00, 0x00, 0x00, 0x00, 0x00, 0xFF]; + let resp = store.handle_command(IpmiCommand::GET_SEL_ENTRY, &get_data); + assert_eq!(resp[0], CompletionCode::SUCCESS.0); + let record_data = &resp[3..]; + assert_eq!(u16::from_le_bytes([record_data[0], record_data[1]]), 1); + + // Get last (record ID 0xFFFF). + let get_data = [0x00, 0x00, 0xFF, 0xFF, 0x00, 0xFF]; + let resp = store.handle_command(IpmiCommand::GET_SEL_ENTRY, &get_data); + assert_eq!(resp[0], CompletionCode::SUCCESS.0); + let record_data = &resp[3..]; + assert_eq!(u16::from_le_bytes([record_data[0], record_data[1]]), 3); + } + + #[test] + fn sel_invalid_data_length() { + let mut store = SelStore::new(); + + // Add with too few bytes. + let resp = store.handle_command(IpmiCommand::ADD_SEL_ENTRY, &[0x00; 5]); + assert_eq!(resp[0], CompletionCode::REQUEST_DATA_LENGTH_INVALID.0); + + // Get with too few bytes. + let resp = store.handle_command(IpmiCommand::GET_SEL_ENTRY, &[0x00; 2]); + assert_eq!(resp[0], CompletionCode::REQUEST_DATA_LENGTH_INVALID.0); + + // Clear with too few bytes. + let resp = store.handle_command(IpmiCommand::CLEAR_SEL, &[0x00; 3]); + assert_eq!(resp[0], CompletionCode::REQUEST_DATA_LENGTH_INVALID.0); + + // Set time with too few bytes. + let resp = store.handle_command(IpmiCommand::SET_SEL_TIME, &[0x00; 2]); + assert_eq!(resp[0], CompletionCode::REQUEST_DATA_LENGTH_INVALID.0); + } + + #[test] + fn sel_unknown_command() { + let mut store = SelStore::new(); + let resp = store.handle_command(IpmiCommand(0xFF), &[]); + assert_eq!(resp[0], CompletionCode::INVALID_COMMAND.0); + } + + #[test] + fn sel_time_get_and_set() { + let mut store = SelStore::new(); + + // Get time (should be current time approximately). + let resp = store.handle_command(IpmiCommand::GET_SEL_TIME, &[]); + assert_eq!(resp[0], CompletionCode::SUCCESS.0); + assert_eq!(resp.len(), 5); + let time = u32::from_le_bytes([resp[1], resp[2], resp[3], resp[4]]); + assert!(time > 0); + + // Set time to a known value. + let new_time: u32 = 1_000_000; + let resp = store.handle_command(IpmiCommand::SET_SEL_TIME, &new_time.to_le_bytes()); + assert_eq!(resp[0], CompletionCode::SUCCESS.0); + + // Get time should return approximately the same value. + let resp = store.handle_command(IpmiCommand::GET_SEL_TIME, &[]); + let time = u32::from_le_bytes([resp[1], resp[2], resp[3], resp[4]]); + // Allow 2 seconds of drift for test execution time. + assert!((1_000_000..=1_000_002).contains(&time)); + } + + #[test] + fn sel_multiple_entries_next_record_id() { + let mut store = SelStore::new(); + let record = make_sel_record(); + + store.handle_command(IpmiCommand::ADD_SEL_ENTRY, &record); + store.handle_command(IpmiCommand::ADD_SEL_ENTRY, &record); + + // Get first entry — next record should be second. + let get_data = [0x00, 0x00, 0x01, 0x00, 0x00, 0xFF]; + let resp = store.handle_command(IpmiCommand::GET_SEL_ENTRY, &get_data); + assert_eq!(resp[0], CompletionCode::SUCCESS.0); + let next_id = u16::from_le_bytes([resp[1], resp[2]]); + assert_eq!(next_id, 2); + + // Get second entry — next record should be 0xFFFF (end). + let get_data = [0x00, 0x00, 0x02, 0x00, 0x00, 0xFF]; + let resp = store.handle_command(IpmiCommand::GET_SEL_ENTRY, &get_data); + assert_eq!(resp[0], CompletionCode::SUCCESS.0); + let next_id = u16::from_le_bytes([resp[1], resp[2]]); + assert_eq!(next_id, 0xFFFF); + } + + #[test] + fn sel_sink_receives_entries() { + let sink = Arc::new(CapturingSink::default()); + let deps = SelDeps { + sink: sink.clone(), + clock: Arc::new(FixedClock(1_700_000_000)), + }; + let mut store = SelStore::with_deps(deps); + + let resp = store.handle_command(IpmiCommand::ADD_SEL_ENTRY, &make_sel_record()); + assert_eq!(resp[0], CompletionCode::SUCCESS.0); + + let captured = sink.entries.lock().unwrap(); + assert_eq!(captured.len(), 1); + let (record_id, record) = &captured[0]; + assert_eq!(*record_id, 1); + // Record id stamped into the forwarded record. + assert_eq!(u16::from_le_bytes([record[0], record[1]]), 1); + // Timestamp comes from the injected clock. + assert_eq!(u32::from_le_bytes([record[3], record[4], record[5], record[6]]), 1_700_000_000); + } + + #[test] + fn sel_injected_clock_used_for_time() { + let deps = SelDeps { + sink: Arc::new(crate::sink::NullSelSink), + clock: Arc::new(FixedClock(1_700_000_000)), + }; + let mut store = SelStore::with_deps(deps); + let resp = store.handle_command(IpmiCommand::GET_SEL_TIME, &[]); + let time = u32::from_le_bytes([resp[1], resp[2], resp[3], resp[4]]); + assert_eq!(time, 1_700_000_000); + } +} diff --git a/vm/devices/ipmi_kcs/src/sink.rs b/vm/devices/ipmi_kcs/src/sink.rs new file mode 100644 index 00000000000..b167982e08c --- /dev/null +++ b/vm/devices/ipmi_kcs/src/sink.rs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Egress and time abstractions for the IPMI KCS device. +//! +//! In OpenVMM, the SEL is kept purely in-memory and inspected via the `inspect` +//! tree. When this device is hosted inside OpenHCL (the paravisor), the SEL +//! entries written by the guest are diagnostic events that must be forwarded to +//! the host. [`SelSink`] is the injection point for that forwarding so the +//! device core stays free of any host-specific plumbing. [`BmcClock`] abstracts +//! the wall clock so paravisor builds can use the platform time source instead +//! of `std::time`. + +use std::sync::Arc; + +/// Sink that receives SEL records as the guest adds them. +/// +/// The default implementation is a no-op; hosts that want to collect SEL +/// (e.g. OpenHCL forwarding to host ETW) provide their own. +pub trait SelSink: Send + Sync { + /// Called after a SEL entry is committed. `record` is the full 16-byte + /// SEL record with the assigned record id and timestamp filled in. + fn log_sel_entry(&self, record_id: u16, record: &[u8]); +} + +/// No-op sink used when no host forwarding is configured. +pub struct NullSelSink; + +impl SelSink for NullSelSink { + fn log_sel_entry(&self, _record_id: u16, _record: &[u8]) {} +} + +/// Wall-clock source for SEL timestamps, abstracted for paravisor builds. +pub trait BmcClock: Send + Sync { + /// Current time as seconds since the Unix epoch (1970-01-01). + fn now_unix_secs(&self) -> i64; +} + +/// Default clock backed by `std::time::SystemTime`. +pub struct SystemClock; + +impl BmcClock for SystemClock { + fn now_unix_secs(&self) -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) + } +} + +/// Bundle of injectable dependencies for the device. +#[derive(Clone)] +pub struct SelDeps { + /// Sink for forwarding SEL entries. + pub sink: Arc, + /// Wall-clock source. + pub clock: Arc, +} + +impl Default for SelDeps { + fn default() -> Self { + Self { + sink: Arc::new(NullSelSink), + clock: Arc::new(SystemClock), + } + } +} diff --git a/vm/devices/ipmi_kcs_resources/Cargo.toml b/vm/devices/ipmi_kcs_resources/Cargo.toml new file mode 100644 index 00000000000..463be1e09cd --- /dev/null +++ b/vm/devices/ipmi_kcs_resources/Cargo.toml @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "ipmi_kcs_resources" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +mesh.workspace = true +vm_resource.workspace = true + +[lints] +workspace = true diff --git a/vm/devices/ipmi_kcs_resources/src/lib.rs b/vm/devices/ipmi_kcs_resources/src/lib.rs new file mode 100644 index 00000000000..b8f76238db1 --- /dev/null +++ b/vm/devices/ipmi_kcs_resources/src/lib.rs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Resource definitions for the IPMI KCS device. + +#![forbid(unsafe_code)] + +use mesh::MeshPayload; +use vm_resource::ResourceId; +use vm_resource::kind::ChipsetDeviceHandleKind; + +/// Resource handle for the IPMI KCS device. +/// +/// No configuration fields — the device starts with an empty SEL +/// and the guest populates it at runtime. +#[derive(MeshPayload)] +pub struct IpmiKcsHandle; + +impl ResourceId for IpmiKcsHandle { + const ID: &'static str = "ipmi_kcs"; +} From 39439e7c55c2ff5ceacf976673ec5171cded5a40 Mon Sep 17 00:00:00 2001 From: Manish Ranjan Mahanta Date: Mon, 29 Jun 2026 18:07:41 +0530 Subject: [PATCH 2/2] ipmi_kcs: silence missing_docs and dead_code warnings - protocol.rs: use open_enum's inner #![expect(missing_docs)] idiom instead of an outer #[allow], so it reaches the generated associated constants (protocol module is pub). - sel.rs: SelStore::new() is only used by tests now that the lib paths construct via with_deps; move it into a #[cfg(test)] impl. - Cargo.lock: add ipmi_kcs / ipmi_kcs_resources entries. --- Cargo.lock | 24 ++++++++++++++++++++++++ vm/devices/ipmi_kcs/src/protocol.rs | 10 +++++----- vm/devices/ipmi_kcs/src/sel.rs | 13 +++++++------ 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c7ca18842f..7cbe8c2f4cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3934,6 +3934,29 @@ dependencies = [ "thiserror 2.0.16", ] +[[package]] +name = "ipmi_kcs" +version = "0.0.0" +dependencies = [ + "chipset_device", + "chipset_device_resources", + "inspect", + "ipmi_kcs_resources", + "open_enum", + "test_with_tracing", + "tracelimit", + "vm_resource", + "vmcore", +] + +[[package]] +name = "ipmi_kcs_resources" +version = "0.0.0" +dependencies = [ + "mesh", + "vm_resource", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -5871,6 +5894,7 @@ dependencies = [ "guest_watchdog", "hyperv_ic", "hypervisor_resources", + "ipmi_kcs", "mesh_worker", "missing_dev", "net_backend", diff --git a/vm/devices/ipmi_kcs/src/protocol.rs b/vm/devices/ipmi_kcs/src/protocol.rs index 02453aea23e..1657beab5a1 100644 --- a/vm/devices/ipmi_kcs/src/protocol.rs +++ b/vm/devices/ipmi_kcs/src/protocol.rs @@ -7,8 +7,8 @@ use open_enum::open_enum; open_enum! { /// KCS interface states (encoded in status register S1:S0, bits 7:6). - #[allow(missing_docs)] pub enum KcsState: u8 { + #![expect(missing_docs)] IDLE_STATE = 0x00, READ_STATE = 0x40, WRITE_STATE = 0x80, @@ -18,8 +18,8 @@ open_enum! { open_enum! { /// KCS commands written to the command register. - #[allow(missing_docs)] pub enum KcsCommand: u8 { + #![expect(missing_docs)] GET_STATUS_ABORT = 0x60, WRITE_START = 0x61, WRITE_END = 0x62, @@ -29,8 +29,8 @@ open_enum! { open_enum! { /// IPMI Network Function codes (upper 6 bits of NetFn/LUN byte). - #[allow(missing_docs)] pub enum IpmiNetFn: u8 { + #![expect(missing_docs)] APP_REQUEST = 0x06, APP_RESPONSE = 0x07, STORAGE_REQUEST = 0x0A, @@ -40,8 +40,8 @@ open_enum! { open_enum! { /// IPMI command codes. - #[allow(missing_docs)] pub enum IpmiCommand: u8 { + #![expect(missing_docs)] GET_DEVICE_ID = 0x01, GET_SEL_INFO = 0x40, GET_SEL_ENTRY = 0x43, @@ -54,8 +54,8 @@ open_enum! { open_enum! { /// IPMI completion codes. - #[allow(missing_docs)] pub enum CompletionCode: u8 { + #![expect(missing_docs)] SUCCESS = 0x00, INVALID_COMMAND = 0xC1, REQUEST_DATA_LENGTH_INVALID = 0xC7, diff --git a/vm/devices/ipmi_kcs/src/sel.rs b/vm/devices/ipmi_kcs/src/sel.rs index 531f9d102e7..99b2f7cd80d 100644 --- a/vm/devices/ipmi_kcs/src/sel.rs +++ b/vm/devices/ipmi_kcs/src/sel.rs @@ -88,12 +88,6 @@ impl Inspect for SelStore { } impl SelStore { - /// Create a new empty SEL store with default (no-op sink, system clock) - /// dependencies. - pub fn new() -> Self { - Self::with_deps(SelDeps::default()) - } - /// Create a new empty SEL store with the given egress/clock dependencies. pub fn with_deps(deps: SelDeps) -> Self { Self { @@ -308,6 +302,13 @@ mod tests { use std::sync::Mutex; use test_with_tracing::test; + impl SelStore { + /// Empty SEL store with default (no-op sink, system clock) deps. + fn new() -> Self { + Self::with_deps(SelDeps::default()) + } + } + /// Sink that records forwarded entries for assertions. #[derive(Default)] struct CapturingSink {