diff --git a/Cargo.lock b/Cargo.lock index c7876681c41..1e86e3c9949 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5340,6 +5340,7 @@ dependencies = [ name = "nvme_test" version = "0.0.0" dependencies = [ + "anyhow", "async-trait", "chipset_device", "device_emulators", @@ -5360,6 +5361,7 @@ dependencies = [ "scsi_buffers", "task_control", "tdisp", + "tdisp_proto", "thiserror 2.0.16", "tracelimit", "tracing", @@ -5590,8 +5592,14 @@ name = "openhcl_tdisp" version = "0.0.0" dependencies = [ "anyhow", + "futures", + "hvdef", + "inspect", + "parking_lot", "tdisp", "tdisp_proto", + "tracing", + "virt", ] [[package]] @@ -11022,6 +11030,7 @@ dependencies = [ "futures-concurrency", "guestmem", "guid", + "hvdef", "inspect", "mesh", "openhcl_tdisp", @@ -11035,6 +11044,7 @@ dependencies = [ "thiserror 2.0.16", "tracelimit", "tracing", + "virt", "vmbus_async", "vmbus_channel", "vmbus_ring", @@ -11060,22 +11070,31 @@ version = "0.0.0" dependencies = [ "anyhow", "chipset_device", + "closeable_mutex", "fs-err", "futures", + "guestmem", + "guid", "hcl", "hvdef", "inspect", "memory_range", "mesh", "openhcl_tdisp", + "pal_async", + "parking_lot", "pci_core", "slab", "sparse_mmap", "state_unit", + "task_control", "tdisp", + "test_with_tracing", "tracelimit", "tracing", "user_driver", + "virt", + "vmbus_channel", "vmbus_client", "vmbus_server", "vmcore", diff --git a/openhcl/openhcl_tdisp/Cargo.toml b/openhcl/openhcl_tdisp/Cargo.toml index 73617f827c2..0c9e334771d 100644 --- a/openhcl/openhcl_tdisp/Cargo.toml +++ b/openhcl/openhcl_tdisp/Cargo.toml @@ -7,10 +7,16 @@ rust-version.workspace = true edition.workspace = true [dependencies] +hvdef.workspace = true +inspect.workspace = true +parking_lot.workspace = true tdisp.workspace = true tdisp_proto.workspace = true +virt.workspace = true anyhow.workspace = true +futures.workspace = true +tracing.workspace = true [lints] workspace = true diff --git a/openhcl/openhcl_tdisp/src/client.rs b/openhcl/openhcl_tdisp/src/client.rs new file mode 100644 index 00000000000..ef2f5e8860d --- /dev/null +++ b/openhcl/openhcl_tdisp/src/client.rs @@ -0,0 +1,1571 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The guest side of the TDISP protocol, shared by every virtual bus. +//! +//! This code is bus-agnostic and handles higher-level TDISP client logic. +//! Busses implement [TdispCommandTransport] and provide the underlying +//! communication channel for TDISP commands. + +use crate::GuestToHostCommand; +use crate::GuestToHostCommandExt; +use crate::GuestToHostResponse; +use crate::GuestToHostResponseExt; +use crate::TdispCommandResponseBind; +use crate::TdispCommandResponseGetDeviceInterfaceInfo; +use crate::TdispCommandResponseGetTdiReport; +use crate::TdispCommandResponseModifyMmioRange; +use crate::TdispCommandResponseStartTdi; +use crate::TdispDeviceInterfaceInfo; +use crate::TdispGuestOperationErrorCode; +use crate::TdispGuestProtocolType; +use crate::TdispGuestUnbindReason; +use crate::TdispReportType; +use crate::TdispResourceValidationInterface; +use anyhow::Context; +use hvdef::Vtl; +use inspect::Inspect; +use std::collections::HashSet; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use tdisp::TdispIsolationReport; +use tdisp::TdispResourceIsolation; +use tdisp::TdispTdiState; +use tdisp::devicereport::TdiReportStruct; +use virt::IsolationType; + +/// Carries TDISP guest-to-host commands over a particular bus. +pub trait TdispCommandTransport: Send + Sync + Inspect { + /// The identifier the bus uses to address this device, carried in the + /// `device_id` field of every guest-to-host command. Independent of the + /// TDI device id used by platform specific firmware calls. + fn bus_device_id(&self) -> u64; + + /// Send a command to the host and wait for its response. + /// + /// An error means the command did not complete a round trip. A command the + /// host answered with a TDISP error is still `Ok` here, and the caller + /// reads the outcome from the response. + /// + /// * `command` - The command to send. + fn send_command<'a>( + &'a self, + command: GuestToHostCommand, + ) -> Pin> + Send + Sync + 'a>>; +} + +/// A TDISP-capable device, driven through a bus transport. +/// +/// Operations are serialized against each other, so this can be shared and +/// called concurrently. +pub struct TdispClient(futures::lock::Mutex); + +impl Inspect for TdispClient { + fn inspect(&self, req: inspect::Request<'_>) { + match self.0.try_lock() { + Some(guard) => guard.inspect(req), + None => req.value("locked"), + } + } +} + +impl TdispClient { + /// * `transport` - Carries commands to the host over the bus this device + /// is on. + /// * `resource_validator` - Platform hooks that gate attestation and + /// unblock device resources. + /// * `isolation_type` - The isolation type of the partition the device is + /// assigned to, which decides the guest protocol to negotiate. + /// * `target_vtl` - The VTL the device is assigned to. + /// * `bar_masks` - Which BARs the device implements. + pub fn new( + transport: Box, + resource_validator: Arc, + isolation_type: IsolationType, + target_vtl: Vtl, + bar_masks: [bool; 6], + ) -> Self { + Self(futures::lock::Mutex::new(TdispClientState::new( + transport, + resource_validator, + isolation_type, + target_vtl, + bar_masks, + ))) + } + + /// The TDI state the host reported for the most recent operation. + pub async fn tdi_state(&self) -> TdispTdiState { + self.0.lock().await.tdi_state() + } + + /// Negotiate a guest protocol with the host and return the device's + /// interface info. + /// + /// * `target_protocol` - The guest protocol to negotiate. + pub async fn get_device_interface_info( + &self, + target_protocol: TdispGuestProtocolType, + ) -> anyhow::Result { + self.0 + .lock() + .await + .get_device_interface_info(target_protocol) + .await + } + + /// Detect TDISP capabilities for the device. Returns the interface info if + /// the device supports TDISP and a guest protocol that matches the + /// partition's isolation type, and otherwise an error saying why the device + /// is not suitable for TDISP. + pub async fn query_capabilities(&self) -> anyhow::Result { + self.0.lock().await.query_capabilities().await + } + + /// Run the full attestation flow, leaving the TDI in Run with its interface + /// report cached. Any prior attestation is torn down first, so this is safe + /// to call from any TDI state. + /// + /// Device resources are not accessible on return. They are unblocked later, + /// when the guest enables MMIO. + /// + /// * `interface_info` - The negotiated capabilities for this device. + pub async fn attest(&self, interface_info: TdispDeviceInterfaceInfo) -> anyhow::Result<()> { + self.0.lock().await.attest(interface_info).await + } + + /// Fetch and decode the device's TDI interface report. + pub async fn get_tdi_report(&self) -> anyhow::Result { + self.0.lock().await.get_tdi_report().await + } + + /// Unbind the device, returning the TDI to Unlocked and dropping all + /// per-attest state so the next attestation starts clean. + /// + /// * `reason` - Reported to the host to explain why the TDI is unbinding. + pub async fn unbind(&self, reason: TdispGuestUnbindReason) { + self.0.lock().await.unbind(reason).await + } + + /// Called when the guest reconfigures a BAR's MMIO range, to make the range + /// accessible if it is private memory. + /// + /// * `bar_id` - The BAR index being configured. + /// * `base_address` - The base guest physical address of the MMIO range. + /// * `length` - The length in bytes of the MMIO range. + pub async fn on_mmio_reconfigured( + &self, + bar_id: u16, + base_address: u64, + length: u64, + ) -> anyhow::Result<()> { + self.0 + .lock() + .await + .on_mmio_reconfigured(bar_id, base_address, length) + .await + } + + /// Mark a BAR as paravisor-intercepted so that it is always considered + /// shared. Use this for BARs whose memory is registered as a MMIO intercept + /// region, such as the MSI-X table and PBA BAR, which have no host-side + /// RAM. + /// + /// * `bar_id` - The BAR index to mark. + pub async fn mark_bar_intercepted(&self, bar_id: u16) { + self.0.lock().await.mark_bar_intercepted(bar_id) + } + + /// Classify BAR and DMA isolation for this device as it stands right now. + /// + /// Returns `NotReady` if no TDI interface report is cached. + pub async fn isolation_snapshot(&self) -> TdispIsolationReport { + self.0.lock().await.isolation_snapshot() + } + + /// Classify BAR and DMA isolation, attesting the device first if it has not + /// been attested yet. + /// + /// Returns `NotTdispCapable` if the device turns out not to support TDISP, + /// and `Error` if attestation fails. + pub async fn isolation_snapshot_attested(&self) -> TdispIsolationReport { + let mut state = self.0.lock().await; + + if state.tdi_state() == TdispTdiState::Unlocked { + let info = match state.query_capabilities().await { + Ok(info) => info, + Err(err) => { + tracing::error!( + error = &*err as &dyn std::error::Error, + "isolation_snapshot_attested: query_capabilities failed, \ + TDISP is unsupported or the host errored out trying to start TDISP" + ); + return TdispIsolationReport::NotTdispCapable; + } + }; + + if let Err(err) = state.attest(info).await { + tracing::error!( + error = &*err as &dyn std::error::Error, + "isolation_snapshot_attested: attest from Unlocked failed", + ); + return TdispIsolationReport::Error; + } + } + + state.isolation_snapshot() + } +} + +#[derive(Inspect)] +struct TdispClientMutableState { + tdi_state: TdispTdiState, + #[inspect(debug)] + guest_device_id: TdispDeviceId, + /// Map of BAR ID to the range the guest configured, how it was classified + /// by the TDI report, and its current protection state. Cleared on unbind. + #[inspect(iter_by_key)] + validated_mmio_bars: std::collections::HashMap, + /// Whether DMA has been unblocked via `tdisp_unblock_dma`. Cleared on + /// unbind so that DMA is re-unblocked after re-attestation. + dma_unblocked: bool, + /// The most recently obtained TDI interface report populated during attestation. + /// Cleared on unbind so that it is re-fetched after re-attestation. + #[inspect(debug)] + tdi_report: Option, + /// Set of BAR IDs whose MMIO pages are intercepted (e.g. a BAR that hosts + /// the MSI-X table / PBA emulated by the host). These pages are not backed + /// by guest RAM on the host and are always marked SHARED. + #[inspect(iter_by_index)] + intercepted_bars: HashSet, +} + +/// Identifies the TDI to the host (distinct from the bus's device id). +/// +/// A TDI only has an id once attestation has fetched one from the host. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TdispDeviceId { + /// No TDI has been identified: attestation has not fetched an id yet, or + /// unbind has dropped the one it had. + Invalid, + /// The TDI device id the host reported. + Valid(u16), +} + +impl TdispDeviceId { + /// The underlying device id, or `None` when no TDI has been identified. + fn id(self) -> Option { + match self { + TdispDeviceId::Invalid => None, + TdispDeviceId::Valid(device_id) => Some(device_id), + } + } +} + +/// Tracks how a BAR's MMIO range was handled when the guest reconfigured it, +/// so unbind knows whether the range needs blocking back and with what +/// parameters. +#[derive(Inspect, Clone, Copy, Debug)] +struct ValidatedMmio { + #[inspect(hex)] + base_gpa: u64, + #[inspect(hex)] + length_in_bytes: u64, + + /// How the range was classified from the report. + #[inspect(debug)] + isolation: TdispResourceIsolation, +} + +impl TdispClientMutableState { + fn update_tdi_state(&mut self, new_state: TdispTdiState) { + tracing::info!( + old_state = %self.tdi_state, + new_state = %new_state, + "updating TDI state based on host response" + ); + self.tdi_state = new_state; + } + + fn update_guest_device_id(&mut self, new_device_id: TdispDeviceId) { + tracing::info!( + old_device_id = ?self.guest_device_id, + new_device_id = ?new_device_id, + "updating guest device ID based on host response" + ); + self.guest_device_id = new_device_id; + } +} + +struct SetupDeviceFailure { + reason: TdispGuestUnbindReason, + message: String, +} + +/// TDISP state for a single device, guarded by [`TdispClient`]. +#[derive(Inspect)] +struct TdispClientState { + /// The bus transport used to communicate with the host. + transport: Box, + /// The isolation type the VM is running in. + isolation_type: IsolationType, + /// Target VTL the TDI will be assigned to. + #[inspect(debug)] + target_vtl: Vtl, + /// State that is mutable and can change over the lifetime of the device and + /// cleared on Unbind. + mutable_state: TdispClientMutableState, + /// Which BAR indices the device actually implements. Fixed for the life of + /// the device. + #[inspect(iter_by_index)] + bar_masks: [bool; 6], + /// Platform hooks used to gate attestation and unblock device resources. + #[inspect(skip)] + resource_validator: Arc, +} + +impl TdispClientState { + fn new( + transport: Box, + resource_validator: Arc, + isolation_type: IsolationType, + target_vtl: Vtl, + bar_masks: [bool; 6], + ) -> Self { + Self { + transport, + mutable_state: TdispClientMutableState { + tdi_state: TdispTdiState::Unlocked, + guest_device_id: TdispDeviceId::Invalid, + validated_mmio_bars: std::collections::HashMap::new(), + dma_unblocked: false, + tdi_report: None, + intercepted_bars: HashSet::new(), + }, + isolation_type, + target_vtl, + bar_masks, + resource_validator, + } + } + + /// Get the TDI state returned by the host for the most recent operation. + fn tdi_state(&self) -> TdispTdiState { + self.mutable_state.tdi_state + } + + /// Require the TDI to be in `expected`, according to both the host and the + /// platform firmware. + /// + /// # Panics + /// + /// Panics if either source reports anything other than `expected`. + /// + /// In TDISP, the host controls the TDI lifecycle states and transitions. + /// The host's reported state and the trusted firmware's state are two + /// independent sources.. Either source diverging from it means the + /// paravisor's view of the device is wrong, which is not a condition it can + /// recover from or safely continue past. A platform that cannot report its + /// own state (test environments) leaves only the host's answer to check. + /// + /// * `expected` - The state the TDI must be in. + /// * `device_id` - Identifies the TDI device (not the bus's device ID). + /// Only valid if the platform supports reporting its own TDI state. + fn require_tdi_state(&self, expected: TdispTdiState, device_id: TdispDeviceId) { + let cached = self.tdi_state(); + + // Read the firmware first even when the host's answer is already wrong, + // so the panic can report both values. + let firmware = match device_id.id() { + Some(device_id) => self + .resource_validator + .get_tsm_tdi_state(self.target_vtl, device_id) + .unwrap_or_else(|e| { + panic!("require_tdi_state: failed to read the TDI state from the firmware: {e}") + }), + None => { + // Require that any state beyond Unlocked is not allowed when the device ID is not assigned. + if cached != TdispTdiState::Unlocked || expected != TdispTdiState::Unlocked { + panic!( + "require_tdi_state: device ID wasn't assigned when calling require_tdi_state, \ + but the host reports {cached}" + ); + } + + None + } + }; + + if cached != expected { + panic!( + "TDI {device_id:?} must be in state {expected}, but the host reports \ + {cached} (firmware reports {firmware:?})" + ); + } + + match firmware { + Some(firmware) if firmware != expected => { + panic!( + "TDI {device_id:?} must be in state {expected}, but the firmware \ + reports {firmware} (the host reports {cached})" + ); + } + Some(firmware) => tracing::trace!( + ?device_id, + %firmware, + %expected, + "require_tdi_state: host and firmware both confirm the TDI state" + ), + None => tracing::debug!( + ?device_id, + %cached, + %expected, + "require_tdi_state: firmware state unavailable, \ + checking the host's answer alone" + ), + } + } + + /// Send a command to the host over the bus transport and record the TDI + /// state the host reports back. + /// + /// * `payload` - The command to send. + async fn send_command( + &mut self, + payload: GuestToHostCommand, + ) -> anyhow::Result { + // Capture the name before the command moves, for the error path. + let command_name = payload.type_name().map(|name| name.to_string()); + + let res = self.transport.send_command(payload).await?; + + // Record state transitions based on the TDI state returned by the host in the response, if available. + match res.tdi_state_after_enum() { + Some(state) => self.mutable_state.update_tdi_state(state), + None => std::panic!("tdisp: host returned a completely unknown TDI state in response"), + } + + match res.error_code() { + Some(TdispGuestOperationErrorCode::Success) => Ok(res), + other => { + let err_name = match other { + Some(code) => format!("{code:?}"), + None => format!("Unknown({})", res.result), + }; + let err_msg = format!( + "send_command {:?} failed because host responded with an error: {}", + command_name, err_name, + ); + + tracing::error!(msg = err_msg); + Err(anyhow::anyhow!(err_msg)) + } + } + } + + /// Get the TDISP interface info for the device, negotiating the given + /// guest protocol with the host. + /// + /// * `target_protocol` - The guest protocol to negotiate. + async fn get_device_interface_info( + &mut self, + target_protocol: TdispGuestProtocolType, + ) -> anyhow::Result { + let res = self + .send_command(crate::new_get_device_interface_info_command( + self.transport.bus_device_id(), + target_protocol, + )) + .await?; + + match res.response::() { + Ok(info) => info.interface_info.ok_or_else(|| { + anyhow::anyhow!("missing interface_info after validation, this should never happen") + }), + Err(err) => Err(anyhow::anyhow!( + "error response in get_device_interface_info: {err}" + )), + } + } + + /// Bind the device to the current partition, transitioning the TDI from + /// Unlocked to Locked. + /// + /// While Locked the device can still perform unencrypted operations. The + /// state exists to keep the device from modifying its resources between + /// the bind and attestation. + async fn bind_interface(&mut self) -> anyhow::Result<()> { + let state_before = self.tdi_state(); + let res = self + .send_command(crate::new_bind_command(self.transport.bus_device_id())) + .await?; + + // The host should have transitioned the device to the Bind state if the bind was successful. + match self.tdi_state() { + TdispTdiState::Locked => { + tracing::info!("device successfully transitioned to Bind state after bind command") + } + state_after => { + tracing::error!( + %state_before, + state_after = %state_after, + "device is in unexpected TDI state after bind command, expected Locked" + ); + anyhow::bail!( + "device is in unexpected TDI state after bind command, expected Locked" + ); + } + } + + match res.response::() { + Ok(_) => Ok(()), + Err(err) => Err(anyhow::anyhow!( + "error response in tdisp_bind_interface: {err}" + )), + } + } + + /// Start a bound device, transitioning the TDI from Locked to Run. This is + /// the point from which resources can be accepted into the guest context. + async fn start_device(&mut self) -> anyhow::Result<()> { + let state_before = self.tdi_state(); + let res = self + .send_command(crate::new_start_tdi_command(self.transport.bus_device_id())) + .await?; + + match self.tdi_state() { + TdispTdiState::Run => { + tracing::info!("device successfully transitioned to Run state after start command") + } + state_after => { + tracing::error!( + %state_before, + state_after = %state_after, + "device is in unexpected TDI state after start command, expected Run" + ); + anyhow::bail!( + "device is in unexpected TDI state after start command, expected Run" + ); + } + } + + match res.response::() { + Ok(_) => Ok(()), + Err(err) => Err(anyhow::anyhow!( + "error response in tdisp_start_device: {err}" + )), + } + } + + /// Request a report from the TDI or the physical device, as raw bytes. + /// + /// * `report_type` - Selects which report to fetch, which also determines + /// whether the TDI must be Locked or Run to ask for it. + async fn get_device_report( + &mut self, + report_type: &TdispReportType, + ) -> anyhow::Result> { + let res = self + .send_command(crate::new_get_tdi_report_command( + self.transport.bus_device_id(), + *report_type, + )) + .await?; + + match res.response::() { + Ok(r) => Ok(r.report_buffer), + Err(err) => Err(anyhow::anyhow!( + "error response in tdisp_get_device_report: {err}" + )), + } + } + + /// Fetch the device's TDI interface report and decode it. The report + /// describes the TDI's MMIO ranges and their TEE/non-TEE attributes, which + /// is what decides each BAR's isolation. + async fn get_tdi_report(&mut self) -> anyhow::Result { + let buffer = self + .get_device_report(&TdispReportType::InterfaceReport) + .await + .context("failed to get TDI report")?; + + // Log the raw bytes before parsing them, so a report that fails to + // deserialize can still be decoded by hand from the trace. + tracing::info!( + device_id = self.transport.bus_device_id(), + len = buffer.len(), + raw = format_args!("{buffer:02x?}"), + "tdisp_get_tdi_report: raw TDI interface report from the host" + ); + + let report = tdisp::devicereport::deserialize_tdi_report(&buffer) + .context("failed to deserialize TDI report from host")?; + + tracing::info!( + device_id = self.transport.bus_device_id(), + ?report, + "tdisp_get_tdi_report: decoded TDI interface report" + ); + + for range in &report.mmio_interface_info { + tracing::debug!( + "tdisp_get_tdi_report: MMIO range: range_id={}, first_4k_page_offset={:#x}, \ + num_4k_pages={}, size_bytes={:#x}, is_non_tee_mem={}, \ + is_mem_attr_updatable={}, range_maps_msix_table={}, range_maps_msix_pba={}", + range.range_id, + range.first_4k_page_offset, + range.num_4k_pages, + u64::from(range.num_4k_pages) * 4096, + range.flags.is_non_tee_mem(), + range.flags.is_mem_attr_updatable(), + range.flags.range_maps_msix_table(), + range.flags.range_maps_msix_pba() + ); + } + + Ok(report) + } + + /// Tell the host to block an MMIO range, reversing a previous unblock. + /// This only notifies the host; the platform-side block is separate. + /// + /// * `range_id` - Identifies which MMIO range to block (the PCI BAR index). + /// * `gpa_base` - The guest physical base address of the range. + /// * `range_len_bytes` - The length of the range, in bytes. + async fn host_block_mmio_range( + &mut self, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + ) -> anyhow::Result<()> { + self.send_modify_mmio_range( + crate::new_block_mmio_range_command( + self.transport.bus_device_id(), + range_id, + gpa_base, + range_len_bytes, + ), + "tdisp_host_block_mmio_range", + range_id, + gpa_base, + range_len_bytes, + ) + .await + } + + /// Tell the host to unblock an MMIO range, so its view matches the + /// platform's. This only notifies the host; the platform-side unblock is + /// separate. + /// + /// * `range_id` - Identifies which MMIO range to unblock (the PCI BAR + /// index). + /// * `gpa_base` - The guest physical base address of the range. + /// * `range_len_bytes` - The length of the range, in bytes. + async fn host_unblock_mmio_range( + &mut self, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + ) -> anyhow::Result<()> { + self.send_modify_mmio_range( + crate::new_unblock_mmio_range_command( + self.transport.bus_device_id(), + range_id, + gpa_base, + range_len_bytes, + ), + "tdisp_host_unblock_mmio_range", + range_id, + gpa_base, + range_len_bytes, + ) + .await + } + + /// Sends a `ModifyMmioRange` command, for either action. + /// + /// * `command` - The command to send. + /// * `caller` - Names the operation in the trace and error output. + /// * `range_id`, `gpa_base`, `range_len_bytes` - The range the command + /// describes, passed separately so it can be logged without decoding + /// the built command. + async fn send_modify_mmio_range( + &mut self, + command: GuestToHostCommand, + caller: &str, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + ) -> anyhow::Result<()> { + tracing::info!( + "sending ModifyMmioRange to the host: range_id={range_id}, gpa_base={gpa_base:#x}, range_len_bytes={range_len_bytes:#x}" + ); + + let res = self.send_command(command).await?; + + // The command requires the TDI to be Locked or Run, so record what the + // host thought the state was: an InvalidDeviceState response is most + // easily explained by this pair. + let tdi_state_before = res.tdi_state_before_enum(); + let tdi_state_after = res.tdi_state_after_enum(); + + // Unlike bind and start, this command does not transition the TDI, so + // there is no post-command state to check. + match res.response::() { + Ok(_) => { + tracing::info!( + "host accepted ModifyMmioRange: caller={caller}, range_id={range_id}, gpa_base={gpa_base:#x}, range_len_bytes={range_len_bytes:#x}, tdi_state_before={tdi_state_before:?}, tdi_state_after={tdi_state_after:?}", + ); + Ok(()) + } + Err(err) => { + tracing::error!( + "host rejected ModifyMmioRange: caller={caller}, range_id={range_id}, gpa_base={gpa_base:#x}, range_len_bytes={range_len_bytes:#x}, tdi_state_before={tdi_state_before:?}, tdi_state_after={tdi_state_after:?}, error={err}", + ); + Err(anyhow::anyhow!("error response in {caller}: {err}")) + } + } + } + + /// Unbind the device, returning the TDI to Unlocked and dropping all + /// per-attest state so the next attestation starts clean. + /// + /// Any resource still unblocked is flipped back to shared first. That part + /// is best-effort: a failure is logged but does not abort the unbind. + /// + /// # Arguments + /// + /// * `reason` - Reported to the host to explain why the TDI is unbinding. + /// + /// # Panics + /// + /// This function will panic if it fails to re-block any MMIO ranges or DMA + /// that were previously unblocked. This ensures that the TDI is left in a + /// consistent state after unblock. + /// + /// This function will also panic if the host lies about its acceptance of + /// the unbind request. If the guest asking the trusted firmware disagrees + /// with the state the host advertised after unbind, the function will + /// panic. + async fn unbind(&mut self, reason: TdispGuestUnbindReason) { + let validator = self.resource_validator.clone(); + let device_id = self.mutable_state.guest_device_id; + + // If we haven't even made it far enough to know what TDI we're talking + // to, we can't do any cleanup anyways. + if let Some(raw_device_id) = device_id.id() { + let validated_bars_clone = self.mutable_state.validated_mmio_bars.clone(); + for (bar_id, mmio) in validated_bars_clone { + match mmio.isolation { + // Nothing was ever unblocked for these, so there is nothing + // to block back. + TdispResourceIsolation::Shared | TdispResourceIsolation::Invalid => { + self.mutable_state.validated_mmio_bars.remove(&bar_id); + continue; + } + TdispResourceIsolation::Private => {} + } + + // Block the MMIO range again to return it to shared isolation. + let block_mmio_res = validator + .tdisp_block_mmio( + self.target_vtl, + raw_device_id, + mmio.base_gpa, + 0, + mmio.length_in_bytes, + bar_id, + ) + .await; + + if let Err(e) = block_mmio_res { + tracing::error!( + bar_id, + base_gpa = format_args!("{:#x}", mmio.base_gpa), + length_in_bytes = mmio.length_in_bytes, + error = &*e as &dyn std::error::Error, + "tdisp_unbind: failed to re-block MMIO range" + ); + std::panic!("tdisp_unbind: failed to re-block MMIO range: {e}"); + } + + // Tell the host only once the platform actually blocked the + // range, so the host can do any cleanup it needs to do for the + // range. + if let Err(e) = self + .host_block_mmio_range(bar_id, mmio.base_gpa, mmio.length_in_bytes) + .await + { + tracing::error!( + bar_id, + base_gpa = format_args!("{:#x}", mmio.base_gpa), + length_in_bytes = mmio.length_in_bytes, + error = &*e as &dyn std::error::Error, + "tdisp_unbind: failed to block MMIO range on the host" + ); + std::panic!("tdisp_unbind: failed to re-block MMIO range"); + } + + // Successful re-block, remove the bar from the validated list. + self.mutable_state.validated_mmio_bars.remove(&bar_id); + } + + if self.mutable_state.dma_unblocked { + if let Err(e) = validator.tdisp_block_dma(self.target_vtl, raw_device_id) { + tracing::error!( + raw_device_id, + error = &*e as &dyn std::error::Error, + "tdisp_unbind: failed to re-block DMA" + ); + std::panic!("tdisp_unbind: failed to re-block DMA: {e}"); + } else { + // Successful re-block, clear the DMA unblocked flag. + self.mutable_state.dma_unblocked = false; + } + } + + self.resource_validator + .tdisp_clear_tdi_report(raw_device_id); + } + + // Clear every per-attest field. All of these will be fetched cleanly on + // the next re-attest cycle. + self.mutable_state.tdi_report = None; + self.mutable_state.guest_device_id = TdispDeviceId::Invalid; + self.mutable_state.intercepted_bars.clear(); + + let res = self + .send_command(crate::new_unbind_command( + self.transport.bus_device_id(), + reason, + )) + .await; + + if let Err(e) = res { + tracing::error!( + error = &*e as &dyn std::error::Error, + "tdisp_unbind: error response from host" + ); + std::panic!("tdisp_unbind: error response from host, cannot continue: {e}"); + } + + // The TDI must be back in Unlocked, and the firmware has to agree that + // it is in Unlocked state as well. Any disagreement is fatal, as the state + // the firmware sees must match the host's view. + self.require_tdi_state(TdispTdiState::Unlocked, device_id); + } + + /// Detects TDISP capabilities for the device. If the device supports TDISP + /// and a guest protocol type that we support given the current VM's + /// isolation level, then returns the interface info. Otherwise, returns an + /// error representing why the device is not suitable for TDISP. + async fn query_capabilities(&mut self) -> anyhow::Result { + tracing::info!( + ?self.isolation_type, + "querying TDISP capabilities for device given VM isolation type" + ); + + let target_protocol = match self.isolation_type { + IsolationType::Snp => TdispGuestProtocolType::AmdSevTioV1, + IsolationType::Tdx => TdispGuestProtocolType::IntelTdxConnectV1, + IsolationType::Vbs => { + tracing::warn!( + "query_capabilities: VM is running with VBS isolation (NOT SUPPORTED)" + ); + anyhow::bail!("VBS isolation is not currently supported for TDISP") + } + IsolationType::Cca => { + tracing::warn!( + "query_capabilities: VM is running with CCA isolation (NOT SUPPORTED)" + ); + anyhow::bail!("CCA isolation is not currently supported for TDISP") + } + IsolationType::None => { + tracing::warn!("query_capabilities: VM is running with no isolation (no TDISP)"); + anyhow::bail!("TDISP is not supported without isolation") + } + }; + + let device_interface_info = self + .get_device_interface_info(target_protocol) + .await + .context("tdisp_query_capabilities: failed to get device interface info")?; + + tracing::info!( + ?device_interface_info, + "tdisp_query_capabilities: device interface info", + ); + + if device_interface_info.guest_protocol_type == target_protocol.into() { + tracing::info!( + ?device_interface_info.guest_protocol_type, + "tdisp_query_capabilities: TDISP is supported", + ); + + Ok(device_interface_info) + } else { + tracing::info!( + ?device_interface_info.guest_protocol_type, + ?target_protocol, + "tdisp_query_capabilities: device does not support a guest protocol we support", + ); + + anyhow::bail!("device does not support expected guest protocol we support"); + } + } + + /// Run the full attestation flow, leaving the TDI in Run with its interface + /// report cached. Any prior attestation is torn down first, so this is safe + /// to call from any TDI state. + /// + /// Resources are not yet accessible on return. They are unblocked when the + /// guest enables MMIO, so that platform validation runs against the + /// addresses the guest actually programmed. + /// + /// * `interface_info` - The negotiated capabilities for this device. + async fn attest(&mut self, interface_info: TdispDeviceInterfaceInfo) -> anyhow::Result<()> { + // Allow fast path if the device is already in `Run` state so that an entire attestation isn't run again. + // Only allow this if the firmware specifically validates that the device is in the proper state before + // continuing, else we risk a malicious host attempting to desync our state. + if self.tdi_state() == TdispTdiState::Run { + self.require_tdi_state(TdispTdiState::Run, self.mutable_state.guest_device_id); + + tracing::info!( + "tdisp::attest: fast path: device already in `Run` state, skipping initial bind/attest cycle" + ); + + return Ok(()); + } + + let attestation_result = self.setup_and_attest(interface_info).await; + + match attestation_result { + Ok(()) => {} + Err(err) => { + // Cleanup all partial resources on attestation failure. + tracing::error!("attest: failed to attest device: {}", err.message); + + self.unbind(err.reason).await; + + return Err(anyhow::anyhow!( + "attest: failed to attest device: {}", + err.message + )); + } + } + + Ok(()) + } + + /// Run the full attestation flow, leaving the TDI in Run with its interface + /// report cached. Any prior attestation is torn down first, so this is safe + /// to call from any TDI state. + /// + /// Resources are not yet accessible on return. They are unblocked when the + /// guest enables MMIO, so that platform validation runs against the + /// addresses the guest actually programmed. + /// + /// * `interface_info` - The negotiated capabilities for this device. + async fn setup_and_attest( + &mut self, + interface_info: TdispDeviceInterfaceInfo, + ) -> Result<(), SetupDeviceFailure> { + tracing::info!( + ?interface_info, + "tdisp_attest_device: beginning attestation flow" + ); + + // If there are any existing attestation artifacts, we need to clear + // them before starting a new attestation. + if self.tdi_state() != TdispTdiState::Unlocked + || self.mutable_state.dma_unblocked + || !self.mutable_state.validated_mmio_bars.is_empty() + { + tracing::info!( + current_state = %self.tdi_state(), + "tdisp_attest_device: TDI not in Unlocked, unbinding before rebind" + ); + self.unbind(TdispGuestUnbindReason::Graceful).await; + } + + // If there are *still* any attestation artifacts after unbind, + // something went wrong. We can't continue out of paranoia. + if self.tdi_state() != TdispTdiState::Unlocked + || self.mutable_state.dma_unblocked + || !self.mutable_state.validated_mmio_bars.is_empty() + { + return Err(SetupDeviceFailure { + reason: TdispGuestUnbindReason::StartupFailure, + message: "tdisp_attest_device: failed to clear existing attestation state, cannot proceed with new attestation".to_string(), + }); + } + + // The capability negotiation already identified the TDI, so take the id + // from there rather than asking the host a second time. It is needed + // before binding, so that the pre-bind and pre-start validator hooks + // can identify the TDI they are gating. + let guest_device_id = interface_info.tdisp_device_id; + + // Platforms require a u16 device ID even though the negotiated + // interface info carries a u64. Ensure it fits within that constraint + // before proceeding. + let guest_device_id_u16 = u16::try_from(guest_device_id) + .context("tdisp_attest_device: guest device ID must fit within u16") + .map_err(|e| SetupDeviceFailure { + reason: TdispGuestUnbindReason::StartupFailure, + message: format!( + "tdisp_attest_device: guest device ID must fit within u16: {}", + e + ), + })?; + + self.mutable_state + .update_guest_device_id(TdispDeviceId::Valid(guest_device_id_u16)); + + self.resource_validator + .on_pre_bind(self.target_vtl, guest_device_id_u16) + .context("tdisp_attest_device: pre-bind validation failed") + .map_err(|e| SetupDeviceFailure { + reason: TdispGuestUnbindReason::StartupFailure, + message: format!("tdisp_attest_device: pre-bind validation failed: {}", e), + })?; + + self.bind_interface() + .await + .context("tdisp_attest_device: failed to bind device interface") + .map_err(|e| SetupDeviceFailure { + reason: TdispGuestUnbindReason::StartupFailure, + message: format!( + "tdisp_attest_device: failed to bind device interface: {}", + e + ), + })?; + + self.require_tdi_state( + TdispTdiState::Locked, + TdispDeviceId::Valid(guest_device_id_u16), + ); + + self.resource_validator + .on_pre_start(self.target_vtl, guest_device_id_u16) + .context("tdisp_attest_device: pre-start validation failed") + .map_err(|e| SetupDeviceFailure { + reason: TdispGuestUnbindReason::StartupFailure, + message: format!("tdisp_attest_device: pre-start validation failed: {}", e), + })?; + + self.start_device() + .await + .context("tdisp_attest_device: failed to start device") + .map_err(|e| SetupDeviceFailure { + reason: TdispGuestUnbindReason::StartupFailure, + message: format!("tdisp_attest_device: failed to start device: {}", e), + })?; + + self.require_tdi_state( + TdispTdiState::Run, + TdispDeviceId::Valid(guest_device_id_u16), + ); + + self.resource_validator + .on_post_start(self.target_vtl, guest_device_id_u16) + .context("tdisp_attest_device: post-start validation failed") + .map_err(|e| SetupDeviceFailure { + reason: TdispGuestUnbindReason::StartupFailure, + message: format!("tdisp_attest_device: post-start validation failed: {}", e), + })?; + + // Fetch and save the TDI interface report so callers can inspect the + // attested device's reported capabilities and MMIO ranges. + let tdi_report = self.get_tdi_report().await.context( + "tdisp_attest_device: failed to get TDI interface report after starting device", + ).map_err(|e| SetupDeviceFailure { + reason: TdispGuestUnbindReason::StartupFailure, + message: format!("tdisp_attest_device: failed to get TDI interface report after starting device: {}", e), + })?; + + tracing::info!( + ?tdi_report, + %guest_device_id, + "tdisp_attest_device: device attestation flow completed successfully, waiting on resources to be assigned" + ); + + // Hand the report to the validator before any resource is unblocked. + self.resource_validator + .tdisp_set_tdi_report(guest_device_id_u16, &tdi_report); + + // Auto-mark any MMIO range that the device reports as mapping the MSI-X + // table or PBA as intercepted. Intercepted BARs are not backed by RAM + // on the host and therefore cannot be made private. Unblock calls will + // be skipped for these BARs. + for range in &tdi_report.mmio_interface_info { + if range.flags.range_maps_msix_table() || range.flags.range_maps_msix_pba() { + tracing::info!( + bar_id = range.range_id, + maps_msix_table = range.flags.range_maps_msix_table(), + maps_msix_pba = range.flags.range_maps_msix_pba(), + "auto-marking MSI-X table/PBA BAR as intercepted based on TDI report" + ); + self.mutable_state.intercepted_bars.insert(range.range_id); + } + } + + self.mutable_state.tdi_report = Some(tdi_report); + + // Device is now in the Run state without resource validation being + // performed. + Ok(()) + } + + /// Mark a BAR as being intercepted by the host. The classic case is the + /// MSI-X table / PBA BAR which is handled by the hypervisor through MMIO + /// enlightenments. + /// + /// Such a BAR is never made private, because it is not backed by RAM on + /// the host and so has nothing that could be flipped. + /// + /// * `bar_id` - The PCI BAR index to mark. + fn mark_bar_intercepted(&mut self, bar_id: u16) { + if self.mutable_state.intercepted_bars.insert(bar_id) { + tracing::info!( + bar_id, + "marking BAR as intercepted; TDISP MMIO unblock will be skipped for this BAR" + ); + } + } + + /// Classify a single BAR's isolation from the cached TDI interface report + /// and the set of intercepted BARs. + /// + /// * `bar_id` - The PCI BAR index to classify. + fn classify_bar(&self, bar_id: u16) -> TdispResourceIsolation { + // Host-intercepted BARs (MSI-X table / PBA) have no host-RAM + // backing and can never be flipped private, so always SHARED, + // independent of what the report says. + if self.mutable_state.intercepted_bars.contains(&bar_id) { + return TdispResourceIsolation::Shared; + } + + // No cached report yet (attestation hasn't run) → we don't know + // if this BAR is claimed at all, so INVALID rather than SHARED. + let Some(report) = self.mutable_state.tdi_report.as_ref() else { + return TdispResourceIsolation::Invalid; + }; + + // Match range_id to BAR index and only report BARs that were physically + // probed on the device. + let Some(range) = report + .mmio_interface_info + .iter() + .find(|r| r.range_id == bar_id) + else { + return if self.bar_masks[usize::from(bar_id)] { + TdispResourceIsolation::Shared + } else { + TdispResourceIsolation::Invalid + }; + }; + + // `is_non_tee_mem` ranges report SHARED and are skipped by attestation. + // Everything else is TEE memory the TDI owns -> PRIVATE. + if range.flags.is_non_tee_mem() { + TdispResourceIsolation::Shared + } else { + TdispResourceIsolation::Private + } + } + + /// Classify BAR and DMA isolation for this device at this instant in the + /// flow. + /// + /// Returns `NotReady` iff no TDI interface report is currently cached. + /// Callers that need a classification from an unattested device have to + /// attest it first. + fn isolation_snapshot(&self) -> TdispIsolationReport { + if self.mutable_state.tdi_report.is_none() { + return TdispIsolationReport::NotReady; + } + + let mut bars = [TdispResourceIsolation::Invalid; 6]; + for bar_id in 0..6u16 { + bars[bar_id as usize] = self.classify_bar(bar_id); + } + + let dma: TdispResourceIsolation = { + // If any BAR is classified as PRIVATE, the the device should also have PRIVATE DMA. + if bars.contains(&TdispResourceIsolation::Private) { + // TDISP devices with private MMIO always have private DMA, even + // if at this moment the device's DMA isn't unblocked. + TdispResourceIsolation::Private + } else { + TdispResourceIsolation::Shared + } + }; + + TdispIsolationReport::Ready { bars, dma } + } + + /// Called when a BAR MMIO range is reconfigured by the guest, to make the + /// range accessible to the guest if it is private memory. + /// + /// Only ranges the device reports as TEE memory are unblocked. Ranges the + /// device reports as non-TEE memory, BARs the paravisor has marked + /// intercepted, and BARs the device implements but the report does not list + /// are all skipped. + /// + /// Note: We have chosen to only allow MMIO reconfiguration only after the + /// Run state is reached. This is an implementation decision. + /// + /// # Arguments + /// + /// * `bar_id` - The BAR index being configured. Matched against the + /// `range_id` of the MMIO ranges reported in the TDI interface report. + /// * `base_address` - The base guest physical address of the MMIO range. + /// * `length` - The length in bytes of the MMIO range. + async fn on_mmio_reconfigured( + &mut self, + bar_id: u16, + base_address: u64, + length: u64, + ) -> anyhow::Result<()> { + // If the device is not attested and in Run state, don't attempt to unblock resources + if self.tdi_state() != TdispTdiState::Run { + tracing::warn!( + bar_id, + base_address, + length, + "ignoring MMIO reconfiguration callback because device is not in Run state" + ); + return Ok(()); + } + + if self.mutable_state.validated_mmio_bars.contains_key(&bar_id) { + tracing::debug!( + bar_id, + "skipping MMIO unblock for BAR that has already been validated" + ); + return Ok(()); + } + + match self.classify_bar(bar_id) { + TdispResourceIsolation::Shared => { + let listed = self + .mutable_state + .tdi_report + .as_ref() + .is_some_and(|report| { + report + .mmio_interface_info + .iter() + .any(|r| r.range_id == bar_id) + }); + + if listed { + tracing::info!( + bar_id, + base_address, + length, + "skipping MMIO unblock for BAR classified SHARED \ + (intercepted or non-TEE memory)" + ); + } else { + tracing::info!( + bar_id, + base_address, + length, + "BAR is implemented by the device but has no entry in the TDI \ + interface report; treating it as SHARED and skipping the MMIO unblock" + ); + } + // Record the range so we don't repeatedly fall through here + // on subsequent reconfigurations. Marked `Shared`, which + // tells the unbind path there is no block call to undo. + self.mutable_state.validated_mmio_bars.insert( + bar_id, + ValidatedMmio { + base_gpa: base_address, + length_in_bytes: length, + isolation: TdispResourceIsolation::Shared, + }, + ); + return Ok(()); + } + TdispResourceIsolation::Invalid => { + anyhow::bail!( + "tdisp_on_mmio_reconfigured: BAR {bar_id} cannot be classified \ + because no TDI interface report is cached; the device has not \ + been attested" + ); + } + TdispResourceIsolation::Private => {} + } + + // A device ID is required, this should be available if the device is in + // Run. This error shouldn't happen in normal flows. + let Some(device_id) = self.mutable_state.guest_device_id.id() else { + anyhow::bail!( + "tdisp_on_mmio_reconfigured: BAR {bar_id} is classified Private but no \ + TDI device id is known" + ); + }; + + tracing::info!( + "tdisp_on_mmio_reconfigured: unblocking MMIO for BAR classified PRIVATE: \ + device_id={device_id:#x}, bar_id={bar_id}, base_address={base_address:#x}, \ + length={length:#x}" + ); + + // Tell the host before the platform unblocks. Depending on the platform, the host + // may need to perform bookkeeping operations before the guest can unblock the range. + self.host_unblock_mmio_range(bar_id, base_address, length) + .await + .context("tdisp_on_mmio_reconfigured: failed to unblock MMIO on the host")?; + + self.resource_validator + .tdisp_unblock_mmio(self.target_vtl, device_id, base_address, 0, length, bar_id) + .await + .context("tdisp_on_mmio_reconfigured: failed to unblock MMIO")?; + + tracing::info!( + "tdisp_on_mmio_reconfigured: MMIO unblocked: device_id={device_id:#x}, \ + bar_id={bar_id}, base_address={base_address:#x}, length={length:#x}" + ); + + self.mutable_state.validated_mmio_bars.insert( + bar_id, + ValidatedMmio { + base_gpa: base_address, + length_in_bytes: length, + isolation: TdispResourceIsolation::Private, + }, + ); + + // After the first successful MMIO unblock following attestation, + // unblock DMA as well. Guard with `dma_unblocked` so it only fires once + // per bind/attest cycle (cleared on unbind). + if !self.mutable_state.dma_unblocked { + tracing::info!("tdisp_on_mmio_reconfigured: unblocking DMA: device_id={device_id:#x}"); + + self.resource_validator + .tdisp_unblock_dma(self.target_vtl, device_id) + .context("tdisp_on_mmio_reconfigured: failed to unblock DMA")?; + self.mutable_state.dma_unblocked = true; + tracing::info!("tdisp_on_mmio_reconfigured: DMA unblocked: device_id={device_id:#x}"); + } else { + tracing::info!( + "tdisp_on_mmio_reconfigured: skipping DMA unblock, already unblocked this \ + bind/attest cycle: device_id={device_id:#x}" + ); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use tdisp::devicereport::TdispTdiReportInterfaceInfo; + use tdisp::devicereport::TdispTdiReportMmioFlags; + use tdisp::devicereport::TdispTdiReportMmioInterfaceInfo; + + /// A transport for the classification tests, which never send a command. + #[derive(Inspect)] + struct PanickingTransport; + + impl TdispCommandTransport for PanickingTransport { + fn bus_device_id(&self) -> u64 { + 0 + } + + fn send_command<'a>( + &'a self, + _command: GuestToHostCommand, + ) -> Pin> + Send + Sync + 'a>> + { + panic!("these tests must not send a TDISP command"); + } + } + + /// Build a state whose `isolation_snapshot` and mutable-state fields are + /// safe to poke directly. + fn new_state() -> TdispClientState { + new_state_with_bars([false; 6]) + } + + /// Build a state whose device implements the given BAR slots. Only the + /// report-miss path consults this, so tests that never miss can use + /// `new_state`. + fn new_state_with_bars(present_bars: [bool; 6]) -> TdispClientState { + TdispClientState::new( + Box::new(PanickingTransport), + Arc::new(crate::noop::TdispNoopResourceValidator::new()), + IsolationType::None, + Vtl::Vtl0, + present_bars, + ) + } + + /// Build a minimal `TdiReportStruct` containing only the given + /// `mmio_interface_info` ranges, enough for `isolation_snapshot`. + fn make_report(ranges: Vec) -> TdiReportStruct { + TdiReportStruct { + interface_info: TdispTdiReportInterfaceInfo::new(), + msi_x_message_control: 0, + lnr_control: 0, + tph_control: 0, + mmio_interface_info: ranges, + } + } + + fn tee_range(range_id: u16) -> TdispTdiReportMmioInterfaceInfo { + TdispTdiReportMmioInterfaceInfo { + first_4k_page_offset: 0, + num_4k_pages: 1, + flags: TdispTdiReportMmioFlags::new().with_is_non_tee_mem(false), + range_id, + } + } + + fn non_tee_range(range_id: u16) -> TdispTdiReportMmioInterfaceInfo { + TdispTdiReportMmioInterfaceInfo { + first_4k_page_offset: 0, + num_4k_pages: 1, + flags: TdispTdiReportMmioFlags::new().with_is_non_tee_mem(true), + range_id, + } + } + + #[test] + fn isolation_snapshot_not_ready_without_report() { + // No cached TDI report → NotReady, regardless of TDI state. + let state = new_state(); + assert!(matches!( + state.isolation_snapshot(), + TdispIsolationReport::NotReady + )); + + let mut state = new_state(); + state.mutable_state.tdi_state = TdispTdiState::Run; + assert!(matches!( + state.isolation_snapshot(), + TdispIsolationReport::NotReady + )); + } + + #[test] + fn isolation_snapshot_ready_with_empty_report() { + // Cached (empty) report → Ready; every BAR INVALID, DMA SHARED. + // No TDI-state requirement. + let mut state = new_state(); + state.mutable_state.tdi_report = Some(make_report(vec![])); + let TdispIsolationReport::Ready { bars, dma } = state.isolation_snapshot() else { + panic!("expected Ready"); + }; + assert_eq!(bars, [TdispResourceIsolation::Invalid; 6]); + assert_eq!(dma, TdispResourceIsolation::Shared); + } + + #[test] + fn isolation_snapshot_classifies_report_ranges() { + // BAR 0: TEE memory → PRIVATE. + // BAR 2: non-TEE memory → SHARED. + // BAR 4: TEE memory but intercepted → SHARED. + // BARs 1, 3, 5: no entry → INVALID. + let mut state = new_state(); + state.mutable_state.intercepted_bars.insert(4); + state.mutable_state.tdi_report = Some(make_report(vec![ + tee_range(0), + non_tee_range(2), + tee_range(4), + ])); + let TdispIsolationReport::Ready { bars, dma } = state.isolation_snapshot() else { + panic!("expected Ready"); + }; + assert_eq!( + bars, + [ + TdispResourceIsolation::Private, + TdispResourceIsolation::Invalid, + TdispResourceIsolation::Shared, + TdispResourceIsolation::Invalid, + TdispResourceIsolation::Shared, + TdispResourceIsolation::Invalid, + ] + ); + assert_eq!(dma, TdispResourceIsolation::Private); + } + + #[test] + fn unlisted_bar_is_shared_when_the_device_implements_it() { + // The device has BARs 0 and 2; the report only describes BAR 0. BAR 2 + // is a real BAR the TDI does not claim, so it is host-visible, while + // the slots the device does not implement stay unclassified. + let mut state = new_state_with_bars([true, false, true, false, false, false]); + state.mutable_state.tdi_report = Some(make_report(vec![tee_range(0)])); + + assert_eq!(state.classify_bar(0), TdispResourceIsolation::Private); + assert_eq!(state.classify_bar(2), TdispResourceIsolation::Shared); + for bar_id in [1, 3, 4, 5] { + assert_eq!( + state.classify_bar(bar_id), + TdispResourceIsolation::Invalid, + "bar {bar_id}" + ); + } + } + + #[test] + fn unlisted_bar_reaches_the_guest_as_shared() { + // The same device seen through the reply the guest actually receives. + let mut state = new_state_with_bars([true, false, true, false, false, false]); + state.mutable_state.tdi_report = Some(make_report(vec![tee_range(0)])); + + let TdispIsolationReport::Ready { bars, dma } = state.isolation_snapshot() else { + panic!("expected Ready"); + }; + assert_eq!( + bars, + [ + TdispResourceIsolation::Private, + TdispResourceIsolation::Invalid, + TdispResourceIsolation::Shared, + TdispResourceIsolation::Invalid, + TdispResourceIsolation::Invalid, + TdispResourceIsolation::Invalid, + ] + ); + assert_eq!(dma, TdispResourceIsolation::Private); + } + + #[test] + fn unlisted_bar_is_invalid_without_a_report() { + // No report at all is the not-attested case, which stays unclassified + // even for a BAR the device implements. + let state = new_state_with_bars([true; 6]); + assert_eq!(state.classify_bar(0), TdispResourceIsolation::Invalid); + } + + #[test] + fn isolation_snapshot_dma_private_with_any_private_mmio() { + let mut state = new_state(); + state.mutable_state.tdi_report = Some(make_report(vec![tee_range(0)])); + let TdispIsolationReport::Ready { bars, dma } = state.isolation_snapshot() else { + panic!("expected Ready"); + }; + assert_eq!( + bars, + [ + TdispResourceIsolation::Private, + TdispResourceIsolation::Invalid, + TdispResourceIsolation::Invalid, + TdispResourceIsolation::Invalid, + TdispResourceIsolation::Invalid, + TdispResourceIsolation::Invalid, + ] + ); + assert_eq!(dma, TdispResourceIsolation::Private); + } +} diff --git a/openhcl/openhcl_tdisp/src/lib.rs b/openhcl/openhcl_tdisp/src/lib.rs index 0cb6351e994..c6bfd219c94 100644 --- a/openhcl/openhcl_tdisp/src/lib.rs +++ b/openhcl/openhcl_tdisp/src/lib.rs @@ -8,7 +8,11 @@ //! //! See: `vm/devices/tdisp` for more information. -use std::future::Future; +pub mod client; +pub mod noop; + +pub use client::TdispClient; +pub use client::TdispCommandTransport; // Re-export the TDISP protocol types necessary for OpenHCL from top level tdisp crates // to avoid a direct dependency on tdisp_proto and tdisp. @@ -26,64 +30,200 @@ pub use tdisp_proto::TdispCommandRequestGetDeviceInterfaceInfo; pub use tdisp_proto::TdispCommandResponseBind; pub use tdisp_proto::TdispCommandResponseGetDeviceInterfaceInfo; pub use tdisp_proto::TdispCommandResponseGetTdiReport; +pub use tdisp_proto::TdispCommandResponseModifyMmioRange; pub use tdisp_proto::TdispCommandResponseStartTdi; pub use tdisp_proto::TdispCommandResponseUnbind; pub use tdisp_proto::TdispDeviceInterfaceInfo; pub use tdisp_proto::TdispGuestOperationErrorCode; pub use tdisp_proto::TdispGuestProtocolType; pub use tdisp_proto::TdispGuestUnbindReason; +pub use tdisp_proto::TdispMmioRangeAction; pub use tdisp_proto::TdispReportType; +pub use tdisp_proto::TdispTdiState; +use hvdef::Vtl; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; use tdisp_proto::TdispCommandRequestBind; use tdisp_proto::TdispCommandRequestGetTdiReport; +use tdisp_proto::TdispCommandRequestModifyMmioRange; use tdisp_proto::TdispCommandRequestStartTdi; use tdisp_proto::TdispCommandRequestUnbind; use tdisp_proto::guest_to_host_command::Command; +use virt::IsolationType; -/// Represents a TDISP device assigned to a guest partition. This trait allows -/// implementations to send TDISP commands to the host through a backing interface -/// such as a VPCI channel. -/// -pub trait TdispVirtualDeviceInterface: Send + Sync { - /// Sends a TDISP command to the device through the VPCI channel. - fn send_tdisp_command( - &self, - payload: GuestToHostCommand, - ) -> impl Future> + Send; +/// Provides platform-specific methods for unblocking device resources after +/// TDISP attestation. +pub trait TdispResourceValidationInterface: Send + Sync { + /// Lifecycle method called immediately before the device is bound, while + /// the TDI is still Unlocked. + /// + /// Returning an error fails the attestation, leaving the device unbound. + /// + /// * `target_vtl` - The VTL the device is being attested for. + /// * `device_id` - Identifies the TDI device (not the bus's device ID). + fn on_pre_bind(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()>; - /// Get the TDISP interface info for the device. - fn tdisp_get_device_interface_info( + /// Lifecycle method called immediately after the device has been bound and + /// is Locked, immediately before it is started. + /// + /// This is where a platform can inspect the bound-but-not-yet-running TDI + /// and refuse to let it run. Returning an error fails the attestation. + /// + /// * `target_vtl` - The VTL the device is being attested for. + /// * `device_id` - Identifies the TDI device (not the bus's device ID). + fn on_pre_start(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()>; + + /// Lifecycle method called after the host has started the device and + /// reports it running. + /// + /// This is the last point at which a platform can refuse the device, and + /// the first at which it can confirm the started TDI against its own view + /// of the interface rather than the host's. Returning an error fails the + /// attestation. + /// + /// * `target_vtl` - The VTL the device is being attested for. + /// * `device_id` - Identifies the TDI device (not the bus's device ID). + fn on_post_start(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()>; + + /// Read the TDI's TDISP state directly from the platform's TEE Security + /// Manager, without the host's involvement. This provides a safe channel to + /// verify the TDI state. + /// + /// Support for this feature is dependent on platform capabilities. Not all + /// platforms support verifying the TDI state from the firmware directly. + /// + /// Returns `Ok(None)` on a platform that cannot report the state and is not + /// an error. `Err` is for a platform that should have been able to answer + /// and could not, including a TDI in the TDISP error state (which is never + /// a valid state from the guest's perspective). + /// + /// * `target_vtl` - The VTL the device is assigned to. + /// * `device_id` - Identifies the TDI device (not the bus's device ID). + fn get_tsm_tdi_state( &self, - ) -> impl Future> + Send; + target_vtl: Vtl, + device_id: u16, + ) -> anyhow::Result>; - /// Bind the device to the current partition and transition to Locked. - /// NOTE: While the device is in the Locked state, it can continue to - /// perform unencrypted operations until it is moved to the Running state. - /// The Locked state is a transitional state that is designed to keep - /// the device from modifying its resources prior to attestation. - fn tdisp_bind_interface(&self) -> impl Future> + Send; + /// Record the TDI interface report for a device. + /// + /// Called during the attestation flow to allow the validator interface to + /// cache the TDI interface report. + /// + /// * `device_id` - Identifies the TDI device (not the bus's device ID). + /// * `report` - The device's TDI interface report. + fn tdisp_set_tdi_report(&self, device_id: u16, report: &TdiReportStruct); - /// Start a bound device by transitioning it to the Run state from the Locked state. - /// This allows for attestation and for resources to be accepted into the guest context. - fn tdisp_start_device(&self) -> impl Future> + Send; + /// Drop the TDI interface report recorded for a device from the cache. + /// + /// Called during unbind, so that nothing kept from the old report outlives + /// the attestation it came from. + /// + /// * `device_id` - Identifies the TDI device (not the bus's device ID). + fn tdisp_clear_tdi_report(&self, device_id: u16); - /// Request a device report from the TDI or physical device depending on the report type. - fn tdisp_get_device_report( - &self, - report_type: &TdispReportType, - ) -> impl Future>> + Send; + /// Unblock MMIO access for a specific resource on the device by asking the + /// platform specific TSM to perform an unblock operation. + /// + /// * `target_vtl` - The VTL to unblock the range for. + /// * `device_id` - Identifies the TDI device (not the bus's device ID). + /// * `range_id` - Identifies which MMIO range to unblock. This is the + /// device-specific range identifier reported in the TDI interface report + /// (the PCI BAR index for the guest protocols supported here), *not* the + /// range's position in the report's list. A platform that needs the list + /// position looks it up in the interface report it was given for this + /// device. + /// * `base_gpa` - The base guest physical address of the MMIO range to + /// unblock. + /// * `base_offset` - The offset within the range specified by `range_id` to + /// start unblocking from. Necessary for cases where the host splits the + /// MMIO range into multiple subranges for unblocking. + /// * `length_in_bytes` - The length in bytes of the MMIO range to unblock + /// starting from `base_offset`. + fn tdisp_unblock_mmio<'a>( + &'a self, + target_vtl: Vtl, + device_id: u16, + base_gpa: u64, + base_offset: u32, + length_in_bytes: u64, + range_id: u16, + ) -> Pin> + Send + Sync + 'a>>; - /// Request a TDI report from the TDI or physical device. - fn tdisp_get_tdi_report(&self) -> impl Future> + Send; + /// Unblock DMA access for the device's IOMMU domain by asking the platform + /// specific TSM to perform an unblock operation. + /// + /// * `target_vtl` - The VTL to unblock DMA for. + /// * `device_id` - Identifies the TDI device (not the bus's device ID). + fn tdisp_unblock_dma(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()>; - /// Request the TDI device id from the vpci channel. - fn tdisp_get_tdi_device_id(&self) -> impl Future> + Send; + /// Re-block a previously-unblocked MMIO range, flipping the + /// guest-private pages back to shared (host-visible). Called during + /// unbind, before the device channel is torn down. + /// + /// * `target_vtl` - The VTL the range was unblocked for. + /// * `device_id` - Identifies the TDI device (not the bus's device ID). + /// * `base_gpa` - The base guest physical address of the MMIO range. + /// * `base_offset` - The offset within the range specified by `range_id` to + /// start blocking from. + /// * `length_in_bytes` - The length in bytes of the MMIO range to block + /// starting from `base_offset`. + /// * `range_id` - Identifies which MMIO range to block. As on the unblock + /// path, this is the device-specific range identifier from the TDI + /// interface report, not the range's position in the report's list. + fn tdisp_block_mmio<'a>( + &'a self, + target_vtl: Vtl, + device_id: u16, + base_gpa: u64, + base_offset: u32, + length_in_bytes: u64, + range_id: u16, + ) -> Pin> + Send + Sync + 'a>>; - /// Request to unbind the device and return to the Unlocked state. - fn tdisp_unbind( - &self, - reason: TdispGuestUnbindReason, - ) -> impl Future> + Send; + /// Re-block DMA access for the device's IOMMU domain, reversing a previous + /// unblock. + /// + /// * `target_vtl` - The VTL to block DMA for. + /// * `device_id` - Identifies the TDI device (not the bus's device ID). + fn tdisp_block_dma(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()>; +} + +/// Chooses the validator that gates access to a TDISP device's resources for +/// this partition. A device driven through the TDISP flow always has a +/// validator which is determined by the partition's isolation type. Test +/// environments run with no platform-specific validator. +/// +/// * `isolation` - The isolation type of the partition the device is assigned +/// to. +/// * `vtom` - The address mask with the VTOM bit set, marking where VTOM +/// addresses start in the CVM. `None` on a partition without one. +/// * `is_test_environment` - `true` if running in a test environment. +pub fn new_resource_validator( + isolation: IsolationType, + vtom: Option, + is_test_environment: bool, +) -> anyhow::Result> { + tracing::info!( + ?isolation, + ?vtom, + is_test_environment, + "selecting a TDISP resource validator" + ); + + // The mocked flow drives emulated devices on hosts that are not necessarily + // confidential, so it takes the no-op validator regardless of what kind of + // isolation is in use. + if is_test_environment { + return Ok(Arc::new(noop::TdispNoopResourceValidator::new())); + } + + // TODO: Add platform-specific resource validators based on the isolation type. + // This will follow in subsequent PRs. + + Ok(Arc::new(noop::TdispNoopResourceValidator::new())) } /// Creates a [`GuestToHostCommand`] for the `GetDeviceInterfaceInfo` command. @@ -139,3 +279,58 @@ pub fn new_unbind_command(device_id: u64, reason: TdispGuestUnbindReason) -> Gue })), } } + +/// Creates a [`GuestToHostCommand`] for the `ModifyMmioRange` command with the +/// `UnblockMmioRange` action. +pub fn new_unblock_mmio_range_command( + device_id: u64, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, +) -> GuestToHostCommand { + new_modify_mmio_range_command( + device_id, + TdispMmioRangeAction::UnblockMmioRange, + range_id, + gpa_base, + range_len_bytes, + ) +} + +/// Creates a [`GuestToHostCommand`] for the `ModifyMmioRange` command with the +/// `BlockMmioRange` action. +pub fn new_block_mmio_range_command( + device_id: u64, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, +) -> GuestToHostCommand { + new_modify_mmio_range_command( + device_id, + TdispMmioRangeAction::BlockMmioRange, + range_id, + gpa_base, + range_len_bytes, + ) +} + +/// Builds a `ModifyMmioRange` command for either action. +fn new_modify_mmio_range_command( + device_id: u64, + action: TdispMmioRangeAction, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, +) -> GuestToHostCommand { + GuestToHostCommand { + device_id, + command: Some(Command::ModifyMmioRange( + TdispCommandRequestModifyMmioRange { + action: action as i32, + range_id: range_id.into(), + gpa_base, + range_len_bytes, + }, + )), + } +} diff --git a/openhcl/openhcl_tdisp/src/noop.rs b/openhcl/openhcl_tdisp/src/noop.rs new file mode 100644 index 00000000000..ea2b5f183b7 --- /dev/null +++ b/openhcl/openhcl_tdisp/src/noop.rs @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! A resource validator for platforms that have no resource validation to do. +//! +//! Used on every isolation type without a platform validator of its own, and by +//! the mocked TDISP flow the OpenVMM tests drive. Unblocking and blocking do +//! nothing, but each request is recorded so a test can check which resources +//! the TDISP flow asked for. + +use parking_lot::Mutex; + +use hvdef::Vtl; + +use crate::TdispResourceValidationInterface; +use crate::TdispTdiState; +use std::future::Future; +use std::pin::Pin; +use tdisp::devicereport::TdiReportStruct; + +/// A single MMIO unblock request recorded by the validator. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnblockedMmioRange { + /// The VTL the MMIO range was unblocked for. + pub target_vtl: Vtl, + /// Identifies the TDI device (not the bus's device ID). + pub device_id: u16, + /// The base guest physical address of the unblocked MMIO range. + pub base_gpa: u64, + /// The offset within `range_id` that unblocking started from. + pub base_offset: u32, + /// The length in bytes of the unblocked MMIO range. + pub length_in_bytes: u64, + /// Identifies which MMIO range was unblocked. + pub range_id: u16, +} + +/// A [`TdispResourceValidationInterface`] that validates nothing. +/// +/// A device driven through the TDISP flow always has a validator, so this +/// stands in wherever the platform has no resources to validate or is running +/// in a mode without a proper TSM (such as a test environment). Every MMIO and +/// DMA request is recorded, so a test can assert on what the flow asked for. +#[derive(Default)] +pub struct TdispNoopResourceValidator { + unblocked_mmio_ranges: Mutex>, + dma_unblocked: Mutex, +} + +impl TdispNoopResourceValidator { + /// Creates a validator with nothing recorded yet. + pub fn new() -> Self { + Self::default() + } + + /// Returns the MMIO ranges that were unblocked, in call order. + pub fn unblocked_mmio_ranges(&self) -> Vec { + self.unblocked_mmio_ranges.lock().clone() + } + + /// Returns `true` if DMA was unblocked. + pub fn dma_unblocked(&self) -> bool { + *self.dma_unblocked.lock() + } +} + +impl TdispResourceValidationInterface for TdispNoopResourceValidator { + fn on_pre_bind(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()> { + tracing::info!( + ?target_vtl, + ?device_id, + "no-op resource validator on_pre_bind" + ); + Ok(()) + } + + fn on_pre_start(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()> { + tracing::info!( + ?target_vtl, + ?device_id, + "no-op resource validator on_pre_start" + ); + Ok(()) + } + + fn on_post_start(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()> { + tracing::info!( + ?target_vtl, + ?device_id, + "no-op resource validator on_post_start" + ); + Ok(()) + } + + fn get_tsm_tdi_state( + &self, + target_vtl: Vtl, + device_id: u16, + ) -> anyhow::Result> { + // There is no firmware to ask, so report that it cannot answer + // rather than inventing a state for callers to check against. + tracing::info!( + ?target_vtl, + ?device_id, + "no-op resource validator get_tsm_tdi_state" + ); + Ok(None) + } + + fn tdisp_set_tdi_report(&self, device_id: u16, _report: &TdiReportStruct) { + tracing::info!(?device_id, "no-op resource validator tdisp_set_tdi_report"); + } + + fn tdisp_clear_tdi_report(&self, device_id: u16) { + tracing::info!( + ?device_id, + "no-op resource validator tdisp_clear_tdi_report" + ); + } + + fn tdisp_unblock_mmio<'a>( + &'a self, + target_vtl: Vtl, + device_id: u16, + base_gpa: u64, + base_offset: u32, + length_in_bytes: u64, + range_id: u16, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(async move { + tracing::info!( + ?target_vtl, + ?device_id, + ?base_gpa, + ?base_offset, + ?length_in_bytes, + ?range_id, + "no-op resource validator recording MMIO unblock" + ); + self.unblocked_mmio_ranges.lock().push(UnblockedMmioRange { + target_vtl, + device_id, + range_id, + base_gpa, + base_offset, + length_in_bytes, + }); + Ok(()) + }) + } + + fn tdisp_unblock_dma(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()> { + tracing::info!( + ?target_vtl, + ?device_id, + "no-op resource validator recording DMA unblock" + ); + *self.dma_unblocked.lock() = true; + Ok(()) + } + + fn tdisp_block_mmio<'a>( + &'a self, + target_vtl: Vtl, + device_id: u16, + base_gpa: u64, + base_offset: u32, + length_in_bytes: u64, + range_id: u16, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(async move { + tracing::info!( + ?target_vtl, + ?device_id, + ?base_gpa, + ?base_offset, + ?length_in_bytes, + ?range_id, + "no-op resource validator recording MMIO block" + ); + self.unblocked_mmio_ranges + .lock() + .retain(|r| r.range_id != range_id); + Ok(()) + }) + } + + fn tdisp_block_dma(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()> { + tracing::info!( + ?target_vtl, + ?device_id, + "no-op resource validator recording DMA block" + ); + *self.dma_unblocked.lock() = false; + Ok(()) + } +} diff --git a/openhcl/underhill_core/src/worker.rs b/openhcl/underhill_core/src/worker.rs index 1ed765b1737..bb23bc6d141 100644 --- a/openhcl/underhill_core/src/worker.rs +++ b/openhcl/underhill_core/src/worker.rs @@ -3376,6 +3376,12 @@ async fn new_underhill_vm( let connection = relay_filter.take(); if enable_vpci_relay { + // Determine if we're doing a mock TDISP flow. + let test_tdisp_flow = matches!( + env_cfg.test_configuration, + Some(TestScenarioConfig::VpciTdispFlow) + ); + use vpci_relay::*; let mut relay = VpciRelay::new( @@ -3404,13 +3410,11 @@ async fn new_underhill_vm( .context("failed to create direct mmio accessor")?, ) }, + isolation, vtom, VpciRelayOptions { // Exercises a mocked TDISP flow for emulated TDISP devices produced by OpenVMM tests. - test_tdisp_flow: matches!( - env_cfg.test_configuration, - Some(TestScenarioConfig::VpciTdispFlow) - ), + test_tdisp_flow, }, ); diff --git a/vm/chipset_device/src/lib.rs b/vm/chipset_device/src/lib.rs index 5762ed65769..8d8736323be 100644 --- a/vm/chipset_device/src/lib.rs +++ b/vm/chipset_device/src/lib.rs @@ -62,10 +62,20 @@ pub trait ChipsetDevice: 'static + Send /* see DEVNOTE before adding bounds */ { None } - /// Optionally returns a trait object which implements TDISP host - /// communication. + /// Optionally returns a trait object which advertises that the + /// ChipsetDevice can respond to tdisp requests as a physical device (not + /// relayed) in the host (openvmm). #[inline(always)] - fn supports_tdisp(&mut self) -> Option<&mut dyn tdisp::TdispHostDeviceTarget> { + fn supports_tdisp_host(&mut self) -> Option<&mut dyn tdisp::TdispHostDeviceTarget> { + None + } + + /// Optionally returns a trait object which advertises that the + /// ChipsetDevice is paravirtualizing a device's TDISP interface in the + /// guest. This should only be implemented within a guest as part of a + /// virtual bus (VPCI, EPCI). + #[inline(always)] + fn supports_tdisp_relay(&mut self) -> Option<&mut dyn tdisp::TdispRelayedDeviceTarget> { None } } diff --git a/vm/chipset_device_resources/src/lib.rs b/vm/chipset_device_resources/src/lib.rs index f8ba3fa07a0..536a0446a69 100644 --- a/vm/chipset_device_resources/src/lib.rs +++ b/vm/chipset_device_resources/src/lib.rs @@ -174,8 +174,12 @@ impl ChipsetDevice for ErasedChipsetDevice { self.0.supports_acknowledge_pic_interrupt() } - fn supports_tdisp(&mut self) -> Option<&mut dyn tdisp::TdispHostDeviceTarget> { - self.0.supports_tdisp() + fn supports_tdisp_host(&mut self) -> Option<&mut dyn tdisp::TdispHostDeviceTarget> { + self.0.supports_tdisp_host() + } + + fn supports_tdisp_relay(&mut self) -> Option<&mut dyn tdisp::TdispRelayedDeviceTarget> { + self.0.supports_tdisp_relay() } } diff --git a/vm/devices/pci/vpci/src/device.rs b/vm/devices/pci/vpci/src/device.rs index 7cb806b7bff..7703036a67a 100644 --- a/vm/devices/pci/vpci/src/device.rs +++ b/vm/devices/pci/vpci/src/device.rs @@ -286,6 +286,7 @@ enum DeviceRequest { TdispCommand { data: Vec, }, + QueryIsolatedResources, } #[derive(Debug)] @@ -527,6 +528,15 @@ fn parse_packet(packet: &queue::DataPacket<'_, T>) -> Result { + let msg = protocol::VpciQueryIsolatedResources::read_from_prefix(buf) + .map_err(|_| PacketError::PacketTooSmall("query_isolated_resources"))? + .0; + PacketData::DeviceRequest { + slot: msg.slot, + request: DeviceRequest::QueryIsolatedResources, + } + } typ => return Err(PacketError::UnknownType(typ)), }; Ok(data) @@ -668,13 +678,26 @@ impl VpciChannelState { | protocol::ProtocolVersion::VB | protocol::ProtocolVersion::FE | protocol::ProtocolVersion::GE - | protocol::ProtocolVersion::DT => protocol::Status::SUCCESS, + | protocol::ProtocolVersion::DT + | protocol::ProtocolVersion::RB => protocol::Status::SUCCESS, _ => protocol::Status::REVISION_MISMATCH, }; + // Echo `VB` for every legacy version (unchanged). + // Echo `RB` only when the guest requested it + // so it enables new tdisp interfaces without + // confusing downlevel consumers. + let reply_version = if status == protocol::Status::SUCCESS + && version == protocol::ProtocolVersion::RB + { + protocol::ProtocolVersion::RB + } else { + protocol::ProtocolVersion::VB + }; + let reply = protocol::QueryProtocolVersionReply { status, - protocol_version: protocol::ProtocolVersion::VB, + protocol_version: reply_version, }; self.conn.send_completion(transaction_id, &reply, &[])?; @@ -994,7 +1017,69 @@ impl ReadyState { &[], )?; } + DeviceRequest::QueryIsolatedResources => { + let all_invalid = [protocol::ResourceIsolation::INVALID; 6]; + let reply = if self.vpci_version < protocol::ProtocolVersion::RB { + tracelimit::info_ratelimited!( + instance_id = %dev.instance_id, + negotiated_version = ?self.vpci_version, + "VPCI_QUERY_ISOLATED_RESOURCES on downlevel protocol. Replying NOT_SUPPORTED." + ); + protocol::VpciIsolatedResourcesReply { + status: protocol::Status::NOT_SUPPORTED, + bar_isolation: all_invalid, + dma_isolation: protocol::ResourceIsolation::INVALID, + } + } else { + // The reporter returns a `'static` boxed future, so + // we can drop the sync device guard before awaiting + // it. This avoids holding the chipset device lock + // across attestation work. + let fut = { + let mut locked_dev = dev.device.lock(); + locked_dev + .supports_tdisp_relay() + .map(|r| r.tdisp_isolation_report()) + }; + let report = match fut { + Some(f) => Some(f.await), + None => None, + }; + tracelimit::info_ratelimited!( + instance_id = %dev.instance_id, + ?report, + "VPCI_QUERY_ISOLATED_RESOURCES isolation report" + ); + let reply = build_isolation_reply(report); + tracelimit::info_ratelimited!( + instance_id = %dev.instance_id, + status = ?reply.status, + bar_isolation = ?reply.bar_isolation, + dma_isolation = ?reply.dma_isolation, + "VPCI_QUERY_ISOLATED_RESOURCES reply" + ); + reply + }; + conn.send_completion(transaction_id, &reply, &[])?; + } DeviceRequest::TdispCommand { data } => { + // TDISP commands only exist from RB onward, so a guest + // that negotiated an older version gets no further than + // this, whatever it put in the payload. + if self.vpci_version < protocol::ProtocolVersion::RB { + tracelimit::info_ratelimited!( + instance_id = %dev.instance_id, + negotiated_version = ?self.vpci_version, + "VPCI_TDISP_COMMAND on downlevel protocol. Replying NOT_SUPPORTED." + ); + conn.send_completion( + transaction_id, + &protocol::Status::NOT_SUPPORTED, + &[], + )?; + return Ok(()); + } + let command = match tdisp::serialize_proto::deserialize_command(&data) { Ok(cmd) => cmd, Err(err) => { @@ -1014,7 +1099,7 @@ impl ReadyState { tracing::debug!(?command, "received TDISP command over vpci channel"); let mut locked_dev = dev.device.lock(); - if let Some(tdisp) = locked_dev.supports_tdisp() { + if let Some(tdisp) = locked_dev.supports_tdisp_host() { tracelimit::info_ratelimited!( "chipset device supports TDISP, handing off command for processing" ); @@ -1079,6 +1164,61 @@ enum InvalidBars { TooLarge { index: usize, len: u64, mask: u64 }, } +/// Convert a `TdispIsolationReport` (or `None`, when the chipset device +/// does not support the isolation reporter) into the wire reply for +/// `VPCI_QUERY_ISOLATED_RESOURCES`. +fn build_isolation_reply( + report: Option, +) -> protocol::VpciIsolatedResourcesReply { + use protocol::ResourceIsolation; + use tdisp::TdispIsolationReport; + use tdisp::TdispResourceIsolation; + + fn to_wire(r: TdispResourceIsolation) -> ResourceIsolation { + match r { + TdispResourceIsolation::Shared => ResourceIsolation::SHARED, + TdispResourceIsolation::Private => ResourceIsolation::PRIVATE, + TdispResourceIsolation::Invalid => ResourceIsolation::INVALID, + } + } + + let all_invalid = [ResourceIsolation::INVALID; 6]; + match report { + None => protocol::VpciIsolatedResourcesReply { + status: protocol::Status::NOT_SUPPORTED, + bar_isolation: all_invalid, + dma_isolation: ResourceIsolation::INVALID, + }, + Some(TdispIsolationReport::NotTdispCapable) => protocol::VpciIsolatedResourcesReply { + status: protocol::Status::SUCCESS, + bar_isolation: [ResourceIsolation::SHARED; 6], + dma_isolation: ResourceIsolation::SHARED, + }, + Some(TdispIsolationReport::NotReady) => protocol::VpciIsolatedResourcesReply { + status: protocol::Status::INVALID_DEVICE_STATE, + bar_isolation: all_invalid, + dma_isolation: ResourceIsolation::INVALID, + }, + Some(TdispIsolationReport::Error) => protocol::VpciIsolatedResourcesReply { + status: protocol::Status::UNSUCCESSFUL, + bar_isolation: all_invalid, + dma_isolation: ResourceIsolation::INVALID, + }, + Some(TdispIsolationReport::Ready { bars, dma }) => protocol::VpciIsolatedResourcesReply { + status: protocol::Status::SUCCESS, + bar_isolation: [ + to_wire(bars[0]), + to_wire(bars[1]), + to_wire(bars[2]), + to_wire(bars[3]), + to_wire(bars[4]), + to_wire(bars[5]), + ], + dma_isolation: to_wire(dma), + }, + } +} + impl VpciChannel { fn bars(&mut self) -> [MmioResource; 6] { if !self.bars_set { @@ -1576,6 +1716,7 @@ mod tests { use tdisp::GuestToHostResponseExt; use tdisp::TdispCommandResponseGetDeviceInterfaceInfo; use tdisp::TdispHostDeviceTargetEmulator; + use tdisp::TdispTdiState; use tdisp::test_helpers::TDISP_MOCK_DEVICE_ID; use tdisp::test_helpers::TDISP_MOCK_GUEST_PROTOCOL; use tdisp::test_helpers::TDISP_MOCK_SUPPORTED_FEATURES; @@ -1889,14 +2030,12 @@ mod tests { (reply.interrupt.address, reply.interrupt.data_payload) } - /// Serializes `command` to a `VPCI_TDISP_COMMAND` vmbus packet, sends it - /// to the server requesting a completion, then reads the completion and - /// deserializes the payload back to a [`tdisp::GuestToHostResponse`]. - async fn send_tdisp_command( - &mut self, - command: tdisp::GuestToHostCommand, - ) -> tdisp::GuestToHostResponse { - let serialized = tdisp::serialize_proto::serialize_command(&command); + /// Serializes `command` into a `VPCI_TDISP_COMMAND` vmbus packet for + /// slot 0 and sends it to the server, requesting a completion. + /// + /// Returns the transaction id the completion will carry. + async fn write_tdisp_command(&mut self, command: &tdisp::GuestToHostCommand) -> u64 { + let serialized = tdisp::serialize_proto::serialize_command(command); let header = protocol::VpciTdispCommandHeader { message_type: protocol::MessageType::VPCI_TDISP_COMMAND, @@ -1907,6 +2046,34 @@ mod tests { self.write_packet_with_header(Some(transaction_id), &header, serialized.as_bytes()) .await .unwrap(); + transaction_id + } + + /// Sends `command` and reads the completion as a bare status, for the + /// cases where the server answers with a status alone and no TDISP + /// payload. + async fn send_tdisp_command_for_status( + &mut self, + command: tdisp::GuestToHostCommand, + ) -> protocol::Status { + let transaction_id = self.write_tdisp_command(&command).await; + + let mut pkt_info = ReadPacketInfo::None; + let status: protocol::Status = self.read_packet(&mut pkt_info).await.unwrap(); + let ReadPacketInfo::Completion(id) = pkt_info else { + panic!("unexpected TDISP command reply"); + }; + assert_eq!(id, transaction_id); + status + } + + /// Sends `command`, then reads the completion and deserializes the + /// payload back to a [`tdisp::GuestToHostResponse`]. + async fn send_tdisp_command( + &mut self, + command: tdisp::GuestToHostCommand, + ) -> tdisp::GuestToHostResponse { + let transaction_id = self.write_tdisp_command(&command).await; let mut queue = self.host_queue.split().0; let packet = queue.read().await.map_err(GuestError::Queue).unwrap(); @@ -1945,6 +2112,26 @@ mod tests { _ => panic!("unexpected incoming packet type"), } } + + /// Send a `VPCI_QUERY_ISOLATED_RESOURCES` packet for slot 0 and + /// read the completion reply. + async fn send_query_isolated_resources(&mut self) -> protocol::VpciIsolatedResourcesReply { + let msg = protocol::VpciQueryIsolatedResources { + message_type: protocol::MessageType::VPCI_QUERY_ISOLATED_RESOURCES, + slot: SlotNumber::new(), + }; + let transaction_id = self.transaction_id.fetch_add(1, Ordering::Relaxed); + self.write_packet(Some(transaction_id), &msg).await.unwrap(); + + let mut pkt_info = ReadPacketInfo::None; + let reply: protocol::VpciIsolatedResourcesReply = + self.read_packet(&mut pkt_info).await.unwrap(); + match pkt_info { + ReadPacketInfo::Completion(id) => assert_eq!(id, transaction_id), + _ => panic!("expected completion for QueryIsolatedResources"), + } + reply + } } struct NullDevice { @@ -2095,6 +2282,119 @@ mod tests { guest_driver.start_device(base_address).await; } + /// Sends a single `QueryProtocolVersion` packet with `requested` and + /// returns the `(status, echoed_version)` from the reply without asserting + /// anything about the echoed version (unlike `negotiate_version`, which + /// expects the echo to match the request). + async fn query_version_reply( + guest: &mut MockVpciGuestDevice, + requested: protocol::ProtocolVersion, + ) -> (protocol::Status, protocol::ProtocolVersion) { + let query = protocol::QueryProtocolVersion { + message_type: protocol::MessageType::QUERY_PROTOCOL_VERSION, + protocol_version: requested, + }; + let transaction_id = guest.transaction_id.fetch_add(1, Ordering::Relaxed); + guest + .write_packet(Some(transaction_id), &query) + .await + .unwrap(); + + let mut pkt_info = ReadPacketInfo::None; + let reply: protocol::QueryProtocolVersionReply = + guest.read_packet(&mut pkt_info).await.unwrap(); + match pkt_info { + ReadPacketInfo::Completion(id) => assert_eq!(id, transaction_id), + _ => panic!("expected completion"), + } + (reply.status, reply.protocol_version) + } + + /// Verify that `QUERY_PROTOCOL_VERSION` only echoes back `RB` when + /// the guest requested `RB`, and echoes `VB` for every other + /// supported version. Unsupported versions still return + /// `REVISION_MISMATCH` with `VB`. + #[async_test] + async fn verify_version_negotiation_rb_gated(driver: DefaultDriver) { + let msi_controller = TestVpciInterruptController::new(); + let pci_config = HardwareIds { + vendor_id: 0x123, + device_id: 0x789, + revision_id: 1, + prog_if: ProgrammingInterface::NONE, + base_class: ClassCode::BASE_SYSTEM_PERIPHERAL, + sub_class: Subclass::BASE_SYSTEM_PERIPHERAL_OTHER, + type0_sub_vendor_id: 0x456, + type0_sub_system_id: 0x1, + }; + + // Legacy versions: server accepts them and echoes `VB`. + for requested in [ + protocol::ProtocolVersion::RS1, + protocol::ProtocolVersion::VB, + protocol::ProtocolVersion::FE, + protocol::ProtocolVersion::GE, + protocol::ProtocolVersion::DT, + ] { + let pci = Arc::new(CloseableMutex::new(NullDevice { + config_space: ConfigSpaceType0Emulator::new( + pci_config, + Vec::new(), + Vec::new(), + DeviceBars::new(), + ), + })); + let mut guest = connected_device(&driver, pci, msi_controller.clone()); + let (status, echoed) = query_version_reply(&mut guest, requested).await; + assert_eq!( + status, + protocol::Status::SUCCESS, + "request {:?} should succeed", + requested + ); + assert_eq!( + echoed, + protocol::ProtocolVersion::VB, + "request {:?} must echo VB", + requested + ); + } + + // RB: server accepts and echoes `RB`. + { + let pci = Arc::new(CloseableMutex::new(NullDevice { + config_space: ConfigSpaceType0Emulator::new( + pci_config, + Vec::new(), + Vec::new(), + DeviceBars::new(), + ), + })); + let mut guest = connected_device(&driver, pci, msi_controller.clone()); + let (status, echoed) = + query_version_reply(&mut guest, protocol::ProtocolVersion::RB).await; + assert_eq!(status, protocol::Status::SUCCESS); + assert_eq!(echoed, protocol::ProtocolVersion::RB); + } + + // Unknown version: rejected with VB. + { + let pci = Arc::new(CloseableMutex::new(NullDevice { + config_space: ConfigSpaceType0Emulator::new( + pci_config, + Vec::new(), + Vec::new(), + DeviceBars::new(), + ), + })); + let mut guest = connected_device(&driver, pci, msi_controller); + let (status, echoed) = + query_version_reply(&mut guest, protocol::ProtocolVersion(0x00020000)).await; + assert_eq!(status, protocol::Status::REVISION_MISMATCH); + assert_eq!(echoed, protocol::ProtocolVersion::VB); + } + } + #[async_test] async fn verify_simple_capability(driver: DefaultDriver) { let msi_conn = MsiConnection::new(); @@ -2278,6 +2578,11 @@ mod tests { struct TestDevice { config_space: ConfigSpaceType0Emulator, tdisp_interface: TdispHostDeviceTargetEmulator, + /// If `Some`, the device also advertises + /// `TdispRelayedDeviceTarget` and returns the stored report. If `None`, + /// the device does not relay a TDISP interface, which is the + /// chipset-device default. + isolation_report: Option, } impl TestDevice { fn new(register_mmio: &mut dyn RegisterMmioIntercept) -> Self { @@ -2306,9 +2611,15 @@ mod tests { ), ), tdisp_interface: tdisp::test_helpers::new_null_tdisp_interface("vpci-unit-test"), + isolation_report: None, } } + fn with_isolation_report(mut self, report: tdisp::TdispIsolationReport) -> Self { + self.isolation_report = Some(report); + self + } + fn read_bar_u32(&self, bar: u8, offset: u64) -> u32 { if bar == 0 && offset == 0 { 1 @@ -2359,9 +2670,29 @@ mod tests { Some(self) } - fn supports_tdisp(&mut self) -> Option<&mut dyn tdisp::TdispHostDeviceTarget> { + fn supports_tdisp_host(&mut self) -> Option<&mut dyn tdisp::TdispHostDeviceTarget> { Some(&mut self.tdisp_interface) } + + fn supports_tdisp_relay(&mut self) -> Option<&mut dyn tdisp::TdispRelayedDeviceTarget> { + if self.isolation_report.is_some() { + Some(self) + } else { + None + } + } + } + + impl tdisp::TdispRelayedDeviceTarget for TestDevice { + fn tdisp_isolation_report( + &mut self, + ) -> std::pin::Pin + Send + 'static>> + { + let report = self + .isolation_report + .expect("isolation_report must be set when supports_tdisp_relay returns Some"); + Box::pin(async move { report }) + } } impl MmioIntercept for TestDevice { @@ -2490,6 +2821,7 @@ mod tests { .add(|services| TestDevice::new(&mut services.register_mmio())) .unwrap(); let mut guest_driver = connected_device(&driver, pci.clone(), msi_controller); + guest_driver.protocol_version = protocol::ProtocolVersion::RB; guest_driver.start_device(0x1000000).await; let guest_protocol_type: tdisp::TdispGuestProtocolType = TDISP_MOCK_GUEST_PROTOCOL; @@ -2498,9 +2830,11 @@ mod tests { TDISP_MOCK_GUEST_PROTOCOL, ); let response = guest_driver.send_tdisp_command(command).await; + let tdi_state_before = response.tdi_state_before_enum(); + let tdi_state_after = response.tdi_state_after_enum(); - let response = response.response::(); - match response { + let response_unpacked = response.response::(); + match response_unpacked { Ok(info_resp) => { let interface_info = info_resp .interface_info @@ -2515,14 +2849,159 @@ mod tests { TDISP_MOCK_SUPPORTED_FEATURES ); assert_eq!(interface_info.tdisp_device_id, TDISP_MOCK_DEVICE_ID); + assert_eq!(tdi_state_before, Some(TdispTdiState::Unlocked)); + assert_eq!(tdi_state_after, Some(TdispTdiState::Unlocked)); } _ => panic!( "expected GetDeviceInterfaceInfo response, got {:?}", - response + response_unpacked ), } } + /// TDISP commands only exist from `RB` onward, so a guest that negotiated + /// an older version is answered `NOT_SUPPORTED` even though the device + /// behind the bus implements TDISP. + #[async_test] + async fn verify_tdisp_command_downlevel_protocol(driver: DefaultDriver) { + let msi_controller = TestVpciInterruptController::new(); + let vm_chipset = TestChipset::default(); + let pci = vm_chipset + .device_builder("test") + .with_external_pci() + .add(|services| TestDevice::new(&mut services.register_mmio())) + .unwrap(); + let mut guest_driver = connected_device(&driver, pci.clone(), msi_controller); + guest_driver.protocol_version = protocol::ProtocolVersion::VB; + guest_driver.start_device(0x1000000).await; + + let command = new_get_device_interface_info_command( + SlotNumber::new().into_bits() as u64, + TDISP_MOCK_GUEST_PROTOCOL, + ); + assert_eq!( + guest_driver.send_tdisp_command_for_status(command).await, + protocol::Status::NOT_SUPPORTED + ); + } + + /// Verify that `VPCI_QUERY_ISOLATED_RESOURCES` is answered locally on a + /// TDISP-isolation-capable mock device after negotiating `RB`. + /// + /// Exercises every branch of `build_isolation_reply`: + /// - `Ready` → `SUCCESS` with the per-BAR/DMA classifications echoed. + /// - `NotReady` → `INVALID_DEVICE_STATE` with all entries `INVALID`. + /// - `NotTdispCapable` → `SUCCESS` with all entries `SHARED`. + /// - `Error` → `UNSUCCESSFUL`. + /// - Downlevel negotiation (no `RB`) → `NOT_SUPPORTED`. + #[async_test] + async fn verify_query_isolated_resources(driver: DefaultDriver) { + use tdisp::TdispIsolationReport; + use tdisp::TdispResourceIsolation; + + // Ready: BAR 0 PRIVATE (TEE), BAR 2 SHARED (non-TEE), BAR 4 + // SHARED (intercepted), others INVALID. DMA PRIVATE. + let ready_bars = [ + TdispResourceIsolation::Private, + TdispResourceIsolation::Invalid, + TdispResourceIsolation::Shared, + TdispResourceIsolation::Invalid, + TdispResourceIsolation::Shared, + TdispResourceIsolation::Invalid, + ]; + + let cases: &[(TdispIsolationReport, _, _)] = &[ + ( + TdispIsolationReport::Ready { + bars: ready_bars, + dma: TdispResourceIsolation::Private, + }, + protocol::Status::SUCCESS, + [ + protocol::ResourceIsolation::PRIVATE, + protocol::ResourceIsolation::INVALID, + protocol::ResourceIsolation::SHARED, + protocol::ResourceIsolation::INVALID, + protocol::ResourceIsolation::SHARED, + protocol::ResourceIsolation::INVALID, + ], + ), + ( + TdispIsolationReport::NotTdispCapable, + protocol::Status::SUCCESS, + [protocol::ResourceIsolation::SHARED; 6], + ), + ( + TdispIsolationReport::NotReady, + protocol::Status::INVALID_DEVICE_STATE, + [protocol::ResourceIsolation::INVALID; 6], + ), + ( + TdispIsolationReport::Error, + protocol::Status::UNSUCCESSFUL, + [protocol::ResourceIsolation::INVALID; 6], + ), + ]; + + for (report, expected_status, expected_bars) in cases.iter().copied() { + let msi_controller = TestVpciInterruptController::new(); + let vm_chipset = TestChipset::default(); + let pci = vm_chipset + .device_builder("test") + .with_external_pci() + .add(|services| { + TestDevice::new(&mut services.register_mmio()).with_isolation_report(report) + }) + .unwrap(); + let mut guest_driver = connected_device(&driver, pci.clone(), msi_controller); + guest_driver.protocol_version = protocol::ProtocolVersion::RB; + guest_driver.start_device(0x1000000).await; + + let reply = guest_driver.send_query_isolated_resources().await; + assert_eq!(reply.status, expected_status, "report {:?}", report); + assert_eq!(reply.bar_isolation, expected_bars, "report {:?}", report); + let expected_dma = match (report, expected_status) { + (TdispIsolationReport::Ready { dma, .. }, _) => match dma { + TdispResourceIsolation::Private => protocol::ResourceIsolation::PRIVATE, + TdispResourceIsolation::Shared => protocol::ResourceIsolation::SHARED, + TdispResourceIsolation::Invalid => protocol::ResourceIsolation::INVALID, + }, + (TdispIsolationReport::NotTdispCapable, _) => protocol::ResourceIsolation::SHARED, + _ => protocol::ResourceIsolation::INVALID, + }; + assert_eq!(reply.dma_isolation, expected_dma, "report {:?}", report); + } + + // Downlevel: negotiate `VB` instead of `RB`. A `TestDevice` that + // exposes a valid isolation report still replies `NOT_SUPPORTED` + // because the protocol gate is checked before consulting the device. + let msi_controller = TestVpciInterruptController::new(); + let vm_chipset = TestChipset::default(); + let pci = vm_chipset + .device_builder("test") + .with_external_pci() + .add(|services| { + TestDevice::new(&mut services.register_mmio()).with_isolation_report( + TdispIsolationReport::Ready { + bars: ready_bars, + dma: TdispResourceIsolation::Private, + }, + ) + }) + .unwrap(); + let mut guest_driver = connected_device(&driver, pci.clone(), msi_controller); + guest_driver.protocol_version = protocol::ProtocolVersion::VB; + guest_driver.start_device(0x1000000).await; + + let reply = guest_driver.send_query_isolated_resources().await; + assert_eq!(reply.status, protocol::Status::NOT_SUPPORTED); + assert_eq!( + reply.bar_isolation, + [protocol::ResourceIsolation::INVALID; 6] + ); + assert_eq!(reply.dma_isolation, protocol::ResourceIsolation::INVALID); + } + #[async_test] async fn verify_simple_device_interrupt(driver: DefaultDriver) { let msi_controller = TestVpciInterruptController::new(); diff --git a/vm/devices/pci/vpci_client/Cargo.toml b/vm/devices/pci/vpci_client/Cargo.toml index 71eaa695099..74b822ab9e8 100644 --- a/vm/devices/pci/vpci_client/Cargo.toml +++ b/vm/devices/pci/vpci_client/Cargo.toml @@ -10,8 +10,10 @@ edition.workspace = true chipset_device.workspace = true openhcl_tdisp.workspace = true pci_core.workspace = true +hvdef.workspace = true tdisp.workspace = true vpci_protocol.workspace = true +virt.workspace = true vmbus_async.workspace = true vmbus_channel.workspace = true vmbus_ring.workspace = true diff --git a/vm/devices/pci/vpci_client/src/lib.rs b/vm/devices/pci/vpci_client/src/lib.rs index 04a4d379547..8864c4aea3c 100644 --- a/vm/devices/pci/vpci_client/src/lib.rs +++ b/vm/devices/pci/vpci_client/src/lib.rs @@ -9,8 +9,10 @@ //! resource and power management, like Linux does, as opposed to the //! message-based interface, like Windows does. +pub mod tdisp; mod tests; +use ::tdisp::TdispGuestUnbindReason; use anyhow::Context; use chipset_device::pci::ByteEnabledDwordRead; use chipset_device::pci::ByteEnabledDwordWrite; @@ -23,21 +25,9 @@ use inspect::Inspect; use inspect::InspectMut; use mesh::rpc::FailableRpc; use mesh::rpc::RpcSend; -use openhcl_tdisp::GuestToHostCommand; -use openhcl_tdisp::GuestToHostCommandExt; use openhcl_tdisp::GuestToHostResponse; -use openhcl_tdisp::GuestToHostResponseExt; -use openhcl_tdisp::TdispCommandResponseBind; -use openhcl_tdisp::TdispCommandResponseGetDeviceInterfaceInfo; -use openhcl_tdisp::TdispCommandResponseGetTdiReport; -use openhcl_tdisp::TdispCommandResponseStartTdi; -use openhcl_tdisp::TdispCommandResponseUnbind; -use openhcl_tdisp::TdispDeviceInterfaceInfo; -use openhcl_tdisp::TdispGuestOperationErrorCode; -use openhcl_tdisp::TdispGuestProtocolType; -use openhcl_tdisp::TdispGuestUnbindReason; -use openhcl_tdisp::TdispReportType; -use openhcl_tdisp::TdispVirtualDeviceInterface; +use openhcl_tdisp::TdispClient; +use openhcl_tdisp::TdispResourceValidationInterface; use pal_async::task::Spawn; use pal_async::task::Task; use parking_lot::Mutex; @@ -47,8 +37,8 @@ use pci_core::spec::hwid::HardwareIds; use std::pin::Pin; use std::sync::Arc; use std::task::Poll; -use tdisp::devicereport::TdiReportStruct; use thiserror::Error; +use virt::IsolationType; use vmbus_async::queue::IncomingPacket; use vmbus_async::queue::OutgoingPacket; use vmbus_async::queue::Queue; @@ -136,8 +126,10 @@ impl VpciConnection { } async fn negotiate(&mut self) -> anyhow::Result { - // Try to negotiate versions in order from newest to oldest - let versions = &[protocol::ProtocolVersion::VB]; + // Try to negotiate versions in order from newest to oldest. Hosts + // that predate `RB` reply with `REVISION_MISMATCH`, so the + // loop falls through to `VB`. + let versions = &[protocol::ProtocolVersion::RB, protocol::ProtocolVersion::VB]; for &version in versions { tracing::debug!(?version, "trying protocol version"); @@ -227,6 +219,7 @@ pub struct VpciDevice { #[inspect(hex, iter_by_index)] /// RAO == Read As One bar_rao: [u32; 6], + tdisp: TdispClient, } #[derive(Inspect)] @@ -350,7 +343,12 @@ impl VpciDeviceDescription { /// Initializes the device, returning a VPCI device instance that can be /// used to interact with it. Also returns an object to use to get notified /// when the device is ejected or surprise removed. - pub async fn init(self) -> anyhow::Result<(VpciDevice, VpciDeviceEject)> { + pub async fn init( + self, + resource_validator: Arc, + isolation_type: IsolationType, + target_vtl: hvdef::Vtl, + ) -> anyhow::Result<(VpciDevice, VpciDeviceEject)> { let requirements = self .req .call_failable(WorkerRequest::QueryResourceRequirements, self.id) @@ -371,6 +369,17 @@ impl VpciDeviceDescription { eject, } = self; + let tdisp = TdispClient::new( + Box::new(tdisp::VpciTdispTransport::new( + req.clone(), + id.slot.into_bits() as u64, + )), + resource_validator, + isolation_type, + target_vtl, + implemented_bars(&requirements.bars), + ); + // After this, the device is considered initialized and the caller is // responsible notifying the worker when the device is no longer in use. let dev = InUseDevice { req, id }; @@ -405,6 +414,7 @@ impl VpciDeviceDescription { numa_node, serial_num, dev, + tdisp, }; Ok((device, VpciDeviceEject(eject))) @@ -440,6 +450,12 @@ impl Stream for VpciDeviceEject { } impl VpciDevice { + /// The device's TDISP client, for driving attestation and resource + /// validation. + pub fn tdisp(&self) -> &TdispClient { + &self.tdisp + } + /// Reads device configuration space. /// /// Some values will be handled without communicating with the host. @@ -536,6 +552,144 @@ impl VpciDevice { } accessor.write(self.dev.id, offset, value); } + + /// Clear the MMIO-enable and bus-master bits in both the shadowed command + /// register and on the host-side device to disable all device functionality + /// and unmap resources. + fn clear_command_register(&self) { + let mut shadows = self.shadows.lock(); + let mut cleared = shadows.command; + cleared.set_mmio_enabled(false); + cleared.set_bus_master(false); + shadows.command = cleared; + drop(shadows); + + tracing::info!( + "clear_command_register: clearing command register MMIO and bus-master bits" + ); + + // Push the update through so the host observes MMIO and bus-master as + // disabled. Avoids re-entering vpci_relay logic. + let mut accessor = self.config_space.lock(); + accessor.write( + self.dev.id, + HeaderType00::STATUS_COMMAND.0, + ByteEnabledDwordWrite::with_all_bytes_enabled(u32::from(u16::from(cleared))), + ); + } + + /// Called on the STATUS_COMMAND MMIO disabled->enabled edge. + /// + /// If the TDI is not already in `Run`, this will drive a bind/attest cycle + /// first. If the TDI is already in `Run`, this will unbind and rebind the + /// TDI to attest the device again. + /// + /// Returns `true` only if attestation and every BAR notification succeeded + /// completely. Otherwise, the device is disabled and `false` is returned. + pub async fn tdisp_on_device_activate(&self, command_value: ByteEnabledDwordWrite) -> bool { + tracing::info!( + "tdisp_on_device_activate: guest enabled MMIO, attesting device and notifying TDISP of MMIO bars" + ); + // Attest the device before enabling the command register. + let attest_result = match self.tdisp.query_capabilities().await { + Ok(interface_info) => self + .tdisp + .attest(interface_info) + .await + .context("attest failed"), + Err(err) => Err(err.context("query_capabilities failed")), + }; + + if let Err(err) = attest_result { + tracing::error!( + error = &*err as &dyn std::error::Error, + "tdisp_on_device_activate: attestation failed, leaving command register off" + ); + return false; + } + + // Attestation succeeded, so enable the command register now. This + // flushes the shadowed BARs to the host device, mapping the MMIO + // ranges for the guest before the unblock operations below run. + // + // On any failure past this point `tdisp_unbind_resources` clears the + // command register again. + self.write_cfg(HeaderType00::STATUS_COMMAND.0, command_value); + + tracing::info!( + ?command_value, + "tdisp_on_device_activate: command register written at {:#x}, MMIO BARs are now mapped", + HeaderType00::STATUS_COMMAND.0, + ); + + let bars = self.shadows.lock().bars; + + tracing::debug!(?bars, ?self.bar_masks, "command register write enabled mmio, notifying TDISP of MMIO bars"); + + for bar in active_mmio_bars(&bars, &self.bar_masks) { + let ActiveMmioBar { + bar_id, + base_address, + length_bytes, + } = bar; + + tracing::info!( + bar_id, + base_address, + length_bytes, + "notifying TDISP state of active MMIO BAR" + ); + if let Err(e) = self + .tdisp + .on_mmio_reconfigured(bar_id, base_address, length_bytes) + .await + { + tracing::error!( + bar_id, + base_address, + length_bytes, + error = %e, + "failed to notify TDISP of active MMIO BAR. Failing activation." + ); + self.tdisp_unbind_resources(TdispGuestUnbindReason::ResourceSetupFailure) + .await; + return false; + } + } + + tracing::info!( + "tdisp_on_device_activate: attestation and MMIO unblock complete, device activated" + ); + + true + } + + /// Common teardown for all device resources. Ensures the device is unbound + /// completely in the host and guest and unmaps all resources. + async fn tdisp_unbind_resources(&self, reason: TdispGuestUnbindReason) { + tracing::error!( + "tdisp_unbind_resources: unbinding TDI back to Unlocked due to device deactivation or attestation failure" + ); + + // Unbind the device from the TDISP interface. This hard ensures that + // the device is returned to the Unlocked state. Any other failure to + // cleanup is a panic. + self.tdisp.unbind(reason).await; + + // Always clear the command register so the device is left in the + // expected off state after a failed activation. + self.clear_command_register(); + } + + /// Notifies TDISP that the guest has disabled MMIO on this device. If the + /// TDI is in `Run`, issues a full `tdisp_unbind` so the TDI returns to + /// `Unlocked` and *all* per-attest state (cached interface report, device + /// id, intercepted BARs, validated MMIO bars, DMA flag) is cleared. + pub async fn tdisp_on_device_deactivate(&self) { + // Pass this lifecycle event directly to unbind_resources + self.tdisp_unbind_resources(TdispGuestUnbindReason::Graceful) + .await; + } } #[derive(Error, Debug)] @@ -624,173 +778,6 @@ impl MapVpciInterrupt for VpciDevice { } } -impl TdispVirtualDeviceInterface for VpciDevice { - async fn send_tdisp_command( - &self, - payload: GuestToHostCommand, - ) -> Result { - let serialized = openhcl_tdisp::serialize_command(&payload); - - // Ensure that the length does not exceed the VMBUS maximum packet size. - // This shouldn't be possible since the host should reject the command anyways, - // but fail earlier for safety. - if serialized.len() > MAX_VPCI_TDISP_COMMAND_SIZE { - return Err(anyhow::anyhow!( - "serialized TDISP command exceeds VMBUS maximum packet size ({} > {})", - serialized.len(), - MAX_VPCI_TDISP_COMMAND_SIZE - )); - } - - // Make a mesh call to send the VMBUS packet to the host and await a response - // packet from the host. - let res = self - .dev - .req - .call_failable( - WorkerRequest::TdispCommand, - protocol::VpciTdispCommand { - header: protocol::VpciTdispCommandHeader { - message_type: protocol::MessageType::VPCI_TDISP_COMMAND, - slot: self.dev.id.slot, - data_length: serialized.len() as u64, - }, - data: serialized, - }, - ) - .await - .map_err(|err: mesh::rpc::RpcError| { - tracing::error!( - error = &err as &dyn std::error::Error, - "failed to send tdisp command" - ); - anyhow::anyhow!("failed to send tdisp command") - })?; - - match res.error_code() { - Some(TdispGuestOperationErrorCode::Success) => Ok(res), - _ => { - let err_msg = format!( - "send_tdisp_command {:?} failed because host responded with an error: {:?}", - payload.type_name(), - res.result - ); - - tracing::error!(msg = err_msg); - Err(anyhow::anyhow!(err_msg)) - } - } - } - - async fn tdisp_get_device_interface_info(&self) -> anyhow::Result { - // TDISP TODO: Configure the correct guest protocol type when TDX support is added. - let target_protocol_type = TdispGuestProtocolType::AmdSevTioV1; - - let res = self - .send_tdisp_command(openhcl_tdisp::new_get_device_interface_info_command( - self.dev.id.slot.into_bits() as u64, - target_protocol_type, - )) - .await?; - - match res.response::() { - Ok(info) => info.interface_info.ok_or_else(|| { - anyhow::anyhow!("missing interface_info after validation, this should never happen") - }), - Err(err) => Err(anyhow::anyhow!( - "error response in get_device_interface_info: {err}" - )), - } - } - - async fn tdisp_bind_interface(&self) -> anyhow::Result<()> { - let res = self - .send_tdisp_command(openhcl_tdisp::new_bind_command( - self.dev.id.slot.into_bits() as u64, - )) - .await?; - - match res.response::() { - Ok(_) => Ok(()), - Err(err) => Err(anyhow::anyhow!( - "error response in tdisp_bind_interface: {err}" - )), - } - } - - async fn tdisp_start_device(&self) -> anyhow::Result<()> { - let res = self - .send_tdisp_command(openhcl_tdisp::new_start_tdi_command( - self.dev.id.slot.into_bits() as u64, - )) - .await?; - - match res.response::() { - Ok(_) => Ok(()), - Err(err) => Err(anyhow::anyhow!( - "error response in tdisp_start_device: {err}" - )), - } - } - - async fn tdisp_get_device_report( - &self, - report_type: &TdispReportType, - ) -> anyhow::Result> { - let res = self - .send_tdisp_command(openhcl_tdisp::new_get_tdi_report_command( - self.dev.id.slot.into_bits() as u64, - *report_type, - )) - .await?; - - match res.response::() { - Ok(r) => Ok(r.report_buffer), - Err(err) => Err(anyhow::anyhow!( - "error response in tdisp_get_device_report: {err}" - )), - } - } - - async fn tdisp_get_tdi_report(&self) -> anyhow::Result { - let buffer = self - .tdisp_get_device_report(&TdispReportType::InterfaceReport) - .await - .context("failed to get TDI report")?; - - tdisp::devicereport::deserialize_tdi_report(&buffer) - .context("failed to deserialize TDI report from host") - } - - async fn tdisp_get_tdi_device_id(&self) -> anyhow::Result { - let buffer = self - .tdisp_get_device_report(&TdispReportType::GuestDeviceId) - .await - .context("failed to get TDI device ID")?; - - // Ensure it's a u64 - if buffer.len() != size_of::() { - return Err(anyhow::anyhow!("unexpected buffer size for TDI device ID")); - } - - Ok(u64::from_le_bytes(buffer.try_into().unwrap())) - } - - async fn tdisp_unbind(&self, reason: TdispGuestUnbindReason) -> anyhow::Result<()> { - let res = self - .send_tdisp_command(openhcl_tdisp::new_unbind_command( - self.dev.id.slot.into_bits() as u64, - reason, - )) - .await?; - - match res.response::() { - Ok(_) => Ok(()), - Err(err) => Err(anyhow::anyhow!("error response in tdisp_unbind: {err}")), - } - } -} - #[derive(InspectMut)] struct VpciClientWorker { conn: VpciConnection, @@ -1407,3 +1394,114 @@ fn index_to_tx_id(index: usize) -> u64 { fn tx_id_to_index(tx_id: u64) -> usize { tx_id.saturating_sub(1) as usize } + +/// One MMIO range the guest has programmed into a BAR, decoded from the +/// shadowed BAR values and the masks the device reported. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ActiveMmioBar { + /// The BAR index. For a 64-bit BAR this is the lower half, which is the + /// index the TDI interface report uses for the pair. + pub bar_id: u16, + /// The guest physical base address the range is mapped at. + pub base_address: u64, + /// The length of the range in bytes. + pub length_bytes: u64, +} + +/// Which BAR indices the device actually implements. +/// +/// A slot is a BAR in its own right only if the device reports a nonzero size +/// mask for it and it is not the upper half of a preceding 64-bit BAR. The +/// upper half is not independently addressable, so nothing refers to it by +/// index, the TDI interface report included. +/// +/// * `bar_masks` - The size masks the device reported for each BAR. +pub(crate) fn implemented_bars(bar_masks: &[u32; 6]) -> [bool; 6] { + let mut present = [false; 6]; + let mut i = 0usize; + + while i < bar_masks.len() { + let mask = bar_masks[i]; + if mask == 0 { + i += 1; + continue; + } + + let bits = pci_core::spec::cfg_space::BarEncodingBits::from(mask); + + let (full_mask, next_i) = if bits.type_64_bit() && i + 1 < 6 { + // Combine both halves before testing for zero. A 64-bit BAR of + // 4GiB or more has no address bits in its low mask at all, with + // the whole size carried in the high one, so testing the halves + // separately would call it unimplemented. + ( + ((bar_masks[i + 1] as u64) << 32) | ((mask & !0xF_u32) as u64), + i + 2, + ) + } else { + ((mask & !0xF_u32) as u64, i + 1) + }; + + present[i] = full_mask != 0; + + i = next_i; + } + + present +} + +/// Decode the guest-programmed BARs into the MMIO ranges that are actually +/// mapped, in BAR order. +/// +/// A 64-bit BAR occupies two consecutive slots and is reported once, under the +/// index of its lower half. The upper half is consumed and never reported on +/// its own. Unimplemented BARs (mask zero) are skipped, as are ranges the guest +/// has not actually mapped, meaning a zero base address or a zero length. +/// +/// * `bars` - The shadowed BAR values as the guest programmed them. +/// * `bar_masks` - The size masks the device reported for each BAR. +pub(crate) fn active_mmio_bars(bars: &[u32; 6], bar_masks: &[u32; 6]) -> Vec { + let mut active = Vec::new(); + let mut i = 0usize; + + while i < bars.len() { + let mask = bar_masks[i]; + if mask == 0 { + i += 1; + continue; + } + + let bits = pci_core::spec::cfg_space::BarEncodingBits::from(mask); + + // Decode the BAR values to determine the base address and length of the + // MMIO range the guest configured. + let (base_address, length_bytes, next_i) = if bits.type_64_bit() && i + 1 < 6 { + // Combine both 32-bit masks and bases into 64-bit values. Mask off + // the low 4 bits, which carry the encoding flags rather than + // address or size. + let base = ((bars[i + 1] as u64) << 32) | ((bars[i] & !0xF_u32) as u64); + let full_mask = ((bar_masks[i + 1] as u64) << 32) | ((mask & !0xF_u32) as u64); + let size = (!full_mask).wrapping_add(1); + (base, size, i + 2) + } else { + let base = (bars[i] & !0xF_u32) as u64; + // Keep the complement in u32 and widen the result. Doing this in + // u64 would turn a mask with no address bits set, which should + // yield zero and be skipped below, into a bogus 4GiB range. + let size = u64::from((!(mask & !0xF_u32)).wrapping_add(1)); + (base, size, i + 1) + }; + + if base_address != 0 && length_bytes != 0 { + active.push(ActiveMmioBar { + bar_id: i as u16, + base_address, + length_bytes, + }); + } + + i = next_i; + } + + active +} diff --git a/vm/devices/pci/vpci_client/src/tdisp.rs b/vm/devices/pci/vpci_client/src/tdisp.rs new file mode 100644 index 00000000000..a83a5f16a2b --- /dev/null +++ b/vm/devices/pci/vpci_client/src/tdisp.rs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Carries TDISP commands for a VPCI device over its vmbus channel. + +use inspect::Inspect; +use mesh::rpc::RpcSend; +use openhcl_tdisp::GuestToHostCommand; +use openhcl_tdisp::GuestToHostResponse; +use openhcl_tdisp::TdispCommandTransport; +use std::future::Future; +use std::pin::Pin; +use vpci_protocol::MAX_VPCI_TDISP_COMMAND_SIZE; +use vpci_protocol::SlotNumber; + +use super::WorkerRequest; + +/// Sends TDISP commands to the host as VPCI packets on the device's channel. +#[derive(Inspect)] +pub(super) struct VpciTdispTransport { + #[inspect(skip)] + worker_req: mesh::Sender, + /// The VPCI slot, which both addresses the packet and identifies the TDI + /// to the host. + slot: u64, +} + +impl VpciTdispTransport { + /// * `worker_req` - Reaches the worker that owns the device's vmbus + /// channel. + /// * `slot` - The device's VPCI slot number. + pub(super) fn new(worker_req: mesh::Sender, slot: u64) -> Self { + Self { worker_req, slot } + } +} + +impl TdispCommandTransport for VpciTdispTransport { + fn bus_device_id(&self) -> u64 { + self.slot + } + + fn send_command<'a>( + &'a self, + command: GuestToHostCommand, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(async move { + let serialized = openhcl_tdisp::serialize_command(&command); + + // Ensure that the length does not exceed the VMBUS maximum packet size. + // This shouldn't be possible since the host should reject the command anyways, + // but fail earlier for safety. + if serialized.len() > MAX_VPCI_TDISP_COMMAND_SIZE { + return Err(anyhow::anyhow!( + "serialized TDISP command exceeds VMBUS maximum packet size ({} > {})", + serialized.len(), + MAX_VPCI_TDISP_COMMAND_SIZE + )); + } + + // Make a mesh call to send the VMBUS packet to the host and await a response + // packet from the host. + self.worker_req + .call_failable( + WorkerRequest::TdispCommand, + vpci_protocol::VpciTdispCommand { + header: vpci_protocol::VpciTdispCommandHeader { + message_type: vpci_protocol::MessageType::VPCI_TDISP_COMMAND, + slot: SlotNumber::from_bits(self.slot as u32), + data_length: serialized.len() as u64, + }, + data: serialized, + }, + ) + .await + .map_err(|err: mesh::rpc::RpcError| { + tracing::error!( + error = &err as &dyn std::error::Error, + "failed to send tdisp command" + ); + anyhow::anyhow!("failed to send tdisp command") + }) + }) + } +} diff --git a/vm/devices/pci/vpci_client/src/tests.rs b/vm/devices/pci/vpci_client/src/tests.rs index d7a1408484a..7dab410b058 100644 --- a/vm/devices/pci/vpci_client/src/tests.rs +++ b/vm/devices/pci/vpci_client/src/tests.rs @@ -14,7 +14,8 @@ use chipset_device::pci::PciConfigSpace; use closeable_mutex::CloseableMutex; use guestmem::GuestMemory; use guid::Guid; -use openhcl_tdisp::TdispVirtualDeviceInterface; +use hvdef::Vtl; +use openhcl_tdisp::noop::TdispNoopResourceValidator; use pal_async::DefaultDriver; use pal_async::async_test; use pal_async::task::Spawn; @@ -26,6 +27,7 @@ use tdisp::test_helpers::TDISP_MOCK_GUEST_PROTOCOL; use tdisp::test_helpers::TDISP_MOCK_SUPPORTED_FEATURES; use tdisp::test_helpers::new_null_tdisp_interface; use test_with_tracing::test; +use virt::IsolationType; use vmbus_channel::simple::SimpleVmbusDevice; use vmcore::vpci_msi::MapVpciInterrupt; use vmcore::vpci_msi::MsiAddressData; @@ -44,7 +46,7 @@ impl ChipsetDevice for NoopDevice { Some(self) } - fn supports_tdisp(&mut self) -> Option<&mut dyn tdisp::TdispHostDeviceTarget> { + fn supports_tdisp_host(&mut self) -> Option<&mut dyn tdisp::TdispHostDeviceTarget> { Some(&mut self.tdisp_interface) } } @@ -121,7 +123,17 @@ async fn test_negotiate_version(driver: DefaultDriver) { .await .unwrap(); - let (device, _removed) = devices.into_iter().next().unwrap().init().await.unwrap(); + let (device, _removed) = devices + .into_iter() + .next() + .unwrap() + .init( + Arc::new(TdispNoopResourceValidator::new()), + IsolationType::None, + Vtl::Vtl0, + ) + .await + .unwrap(); let MsiAddressData { address, data } = device .register_interrupt( 1, @@ -182,8 +194,21 @@ async fn test_tdisp_interface_get_device_interface_info(driver: DefaultDriver) { .await .unwrap(); - let (device, _removed) = devices.into_iter().next().unwrap().init().await.unwrap(); - let interface = device.tdisp_get_device_interface_info().await; + let (device, _removed) = devices + .into_iter() + .next() + .unwrap() + .init( + Arc::new(TdispNoopResourceValidator::new()), + IsolationType::None, + Vtl::Vtl0, + ) + .await + .unwrap(); + let interface = device + .tdisp() + .get_device_interface_info(TDISP_MOCK_GUEST_PROTOCOL) + .await; match interface { Ok(interface) => { @@ -197,3 +222,420 @@ async fn test_tdisp_interface_get_device_interface_info(driver: DefaultDriver) { Err(err) => panic!("unexpected error: {err}"), } } + +mod active_mmio_bars { + use crate::ActiveMmioBar; + use crate::active_mmio_bars; + + /// Build the size mask a device reports for a 32-bit memory BAR of `size` + /// bytes. `size` must be a power of two. + pub(super) fn mask_32(size: u32, prefetchable: bool) -> u32 { + let mut mask = (!(size - 1)) & !0xF; + if prefetchable { + mask |= 0b1000; + } + mask + } + + /// Build the low and high size masks a device reports for a 64-bit memory + /// BAR of `size` bytes. `size` must be a power of two. + pub(super) fn mask_64(size: u64, prefetchable: bool) -> (u32, u32) { + let full = (!(size - 1)) & !0xF; + let mut low = full as u32; + // Bits 2:1 == 0b10 marks the BAR as 64-bit. + low |= 0b0100; + if prefetchable { + low |= 0b1000; + } + ((low) & !0b0010, (full >> 32) as u32) + } + + #[test] + fn no_bars_implemented() { + assert_eq!(active_mmio_bars(&[0; 6], &[0; 6]), vec![]); + } + + #[test] + fn single_32_bit_bar() { + let mut bars = [0u32; 6]; + let mut masks = [0u32; 6]; + masks[0] = mask_32(0x1000, false); + bars[0] = 0xf000_0000; + + assert_eq!( + active_mmio_bars(&bars, &masks), + vec![ActiveMmioBar { + bar_id: 0, + base_address: 0xf000_0000, + length_bytes: 0x1000, + }] + ); + } + + #[test] + fn thirty_two_bit_bar_ignores_encoding_bits_in_the_base() { + let mut bars = [0u32; 6]; + let mut masks = [0u32; 6]; + masks[0] = mask_32(0x1000, true); + // The guest writes the address; the device's encoding bits stay in the + // low nibble and must not leak into the reported base. + bars[0] = 0xf000_0000 | 0b1000; + + assert_eq!( + active_mmio_bars(&bars, &masks), + vec![ActiveMmioBar { + bar_id: 0, + base_address: 0xf000_0000, + length_bytes: 0x1000, + }] + ); + } + + #[test] + fn single_64_bit_bar_consumes_two_slots() { + let mut bars = [0u32; 6]; + let mut masks = [0u32; 6]; + let (low, high) = mask_64(0x20_0000, true); + masks[0] = low; + masks[1] = high; + bars[0] = 0xe000_0000; + bars[1] = 0x0000_0001; + + // Reported once, under the lower half's index, with the two halves + // combined into one address. + assert_eq!( + active_mmio_bars(&bars, &masks), + vec![ActiveMmioBar { + bar_id: 0, + base_address: 0x1_e000_0000, + length_bytes: 0x20_0000, + }] + ); + } + + #[test] + fn sixty_four_bit_bar_with_zero_high_half() { + let mut bars = [0u32; 6]; + let mut masks = [0u32; 6]; + let (low, high) = mask_64(0x1000, false); + masks[0] = low; + masks[1] = high; + bars[0] = 0xf000_0000; + bars[1] = 0; + + assert_eq!( + active_mmio_bars(&bars, &masks), + vec![ActiveMmioBar { + bar_id: 0, + base_address: 0xf000_0000, + length_bytes: 0x1000, + }] + ); + } + + #[test] + fn mixed_32_and_64_bit_bars() { + let mut bars = [0u32; 6]; + let mut masks = [0u32; 6]; + + // BAR 0: 32-bit, 4KiB. + masks[0] = mask_32(0x1000, false); + bars[0] = 0xf000_0000; + + // BAR 1+2: 64-bit, 2MiB. Reported under index 1. + let (low, high) = mask_64(0x20_0000, true); + masks[1] = low; + masks[2] = high; + bars[1] = 0xe000_0000; + bars[2] = 0x0000_0002; + + // BAR 3: unimplemented. + // BAR 4: 32-bit, 64KiB. + masks[4] = mask_32(0x1_0000, false); + bars[4] = 0xd000_0000; + + assert_eq!( + active_mmio_bars(&bars, &masks), + vec![ + ActiveMmioBar { + bar_id: 0, + base_address: 0xf000_0000, + length_bytes: 0x1000, + }, + ActiveMmioBar { + bar_id: 1, + base_address: 0x2_e000_0000, + length_bytes: 0x20_0000, + }, + ActiveMmioBar { + bar_id: 4, + base_address: 0xd000_0000, + length_bytes: 0x1_0000, + }, + ] + ); + } + + #[test] + fn sixty_four_bit_upper_half_is_not_reported_separately() { + let mut bars = [0u32; 6]; + let mut masks = [0u32; 6]; + let (low, high) = mask_64(0x1000, false); + masks[0] = low; + masks[1] = high; + bars[0] = 0xf000_0000; + // An upper half that would decode to a nonzero base of its own, so a + // decoder that failed to consume this slot would emit a second entry + // here rather than folding it into BAR 0's address. + bars[1] = 0x0000_0010; + + assert_eq!( + active_mmio_bars(&bars, &masks), + vec![ActiveMmioBar { + bar_id: 0, + base_address: 0x10_f000_0000, + length_bytes: 0x1000, + }] + ); + } + + #[test] + fn unmapped_bar_is_skipped() { + let mut bars = [0u32; 6]; + let mut masks = [0u32; 6]; + // Implemented but never programmed by the guest: base stays zero. + masks[0] = mask_32(0x1000, false); + bars[0] = 0; + // A programmed one alongside it, to show only the unmapped one drops. + masks[1] = mask_32(0x1000, false); + bars[1] = 0xf000_0000; + + assert_eq!( + active_mmio_bars(&bars, &masks), + vec![ActiveMmioBar { + bar_id: 1, + base_address: 0xf000_0000, + length_bytes: 0x1000, + }] + ); + } + + #[test] + fn sixty_four_bit_bar_larger_than_4gib() { + let mut bars = [0u32; 6]; + let mut masks = [0u32; 6]; + // 8GiB, which a u32 length could not have described at all. + let (low, high) = mask_64(0x2_0000_0000, true); + masks[0] = low; + masks[1] = high; + bars[0] = 0; + bars[1] = 0x0000_0004; + + assert_eq!( + active_mmio_bars(&bars, &masks), + vec![ActiveMmioBar { + bar_id: 0, + base_address: 0x4_0000_0000, + length_bytes: 0x2_0000_0000, + }] + ); + } + + #[test] + fn exactly_4gib_64_bit_bar() { + let mut bars = [0u32; 6]; + let mut masks = [0u32; 6]; + // 4GiB is one byte past what a u32 length could hold, so this is the + // smallest BAR the old u32 plumbing had to reject outright. + let (low, high) = mask_64(0x1_0000_0000, true); + masks[0] = low; + masks[1] = high; + bars[0] = 0; + bars[1] = 0x0000_0008; + + assert_eq!( + active_mmio_bars(&bars, &masks), + vec![ActiveMmioBar { + bar_id: 0, + base_address: 0x8_0000_0000, + length_bytes: 0x1_0000_0000, + }] + ); + } + + #[test] + fn two_gib_64_bit_bar_still_decodes() { + let mut bars = [0u32; 6]; + let mut masks = [0u32; 6]; + // 2GiB is the largest power of two that fit in a u32 length, so it is + // the boundary the old plumbing stopped at. It must still work. + let (low, high) = mask_64(0x8000_0000, true); + masks[0] = low; + masks[1] = high; + bars[0] = 0x8000_0000; + bars[1] = 0; + + assert_eq!( + active_mmio_bars(&bars, &masks), + vec![ActiveMmioBar { + bar_id: 0, + base_address: 0x8000_0000, + length_bytes: 0x8000_0000, + }] + ); + } + + #[test] + fn sixty_four_bit_bar_in_the_last_slot_falls_back_to_32_bit() { + let mut bars = [0u32; 6]; + let mut masks = [0u32; 6]; + // A 64-bit BAR in slot 5 has no upper half to pair with, which is + // malformed. It is decoded as 32-bit rather than reading past the end. + let (low, _high) = mask_64(0x1000, false); + masks[5] = low; + bars[5] = 0xf000_0000; + + assert_eq!( + active_mmio_bars(&bars, &masks), + vec![ActiveMmioBar { + bar_id: 5, + base_address: 0xf000_0000, + length_bytes: 0x1000, + }] + ); + } + + #[test] + fn all_six_slots_used_by_three_64_bit_bars() { + let mut bars = [0u32; 6]; + let mut masks = [0u32; 6]; + for pair in 0..3u32 { + let low_index = pair as usize * 2; + let (low, high) = mask_64(0x1000, false); + masks[low_index] = low; + masks[low_index + 1] = high; + bars[low_index] = 0xf000_0000 + pair * 0x1000; + // Nonzero upper halves, so that a decoder which failed to consume + // the second slot of each pair would emit spurious entries for + // them rather than silently dropping them as zero bases. + bars[low_index + 1] = 0x10 + pair * 0x10; + } + + assert_eq!( + active_mmio_bars(&bars, &masks), + vec![ + ActiveMmioBar { + bar_id: 0, + base_address: 0x10_f000_0000, + length_bytes: 0x1000, + }, + ActiveMmioBar { + bar_id: 2, + base_address: 0x20_f000_1000, + length_bytes: 0x1000, + }, + ActiveMmioBar { + bar_id: 4, + base_address: 0x30_f000_2000, + length_bytes: 0x1000, + }, + ] + ); + } +} + +mod implemented_bars { + use super::active_mmio_bars::mask_32; + use super::active_mmio_bars::mask_64; + use crate::implemented_bars; + + #[test] + fn no_bars_implemented() { + assert_eq!(implemented_bars(&[0; 6]), [false; 6]); + } + + #[test] + fn thirty_two_bit_bars_in_some_slots() { + let mut masks = [0u32; 6]; + masks[0] = mask_32(0x1000, false); + masks[4] = mask_32(0x1_0000, true); + + assert_eq!( + implemented_bars(&masks), + [true, false, false, false, true, false] + ); + } + + #[test] + fn sixty_four_bit_bar_marks_only_its_lower_half() { + let mut masks = [0u32; 6]; + let (low, high) = mask_64(0x20_0000, true); + masks[0] = low; + masks[1] = high; + + // The upper half is not addressable in its own right, so it is not a + // BAR even though its mask is nonzero. + assert_eq!( + implemented_bars(&masks), + [true, false, false, false, false, false] + ); + } + + #[test] + fn exactly_4gib_64_bit_bar_is_implemented() { + let mut masks = [0u32; 6]; + // A 4GiB BAR has no address bits in its low mask at all: the whole + // size sits in the high one. Testing the halves separately would call + // this BAR unimplemented. + let (low, high) = mask_64(0x1_0000_0000, true); + masks[0] = low; + masks[1] = high; + assert_eq!(low & !0xF, 0, "the low mask must have no address bits"); + + assert_eq!( + implemented_bars(&masks), + [true, false, false, false, false, false] + ); + } + + #[test] + fn sixty_four_bit_bar_larger_than_4gib_is_implemented() { + let mut masks = [0u32; 6]; + let (low, high) = mask_64(0x2_0000_0000, true); + masks[0] = low; + masks[1] = high; + + assert_eq!( + implemented_bars(&masks), + [true, false, false, false, false, false] + ); + } + + #[test] + fn all_six_slots_used_by_three_64_bit_bars() { + let mut masks = [0u32; 6]; + for pair in 0..3 { + let (low, high) = mask_64(0x1000, false); + masks[pair * 2] = low; + masks[pair * 2 + 1] = high; + } + + assert_eq!( + implemented_bars(&masks), + [true, false, true, false, true, false] + ); + } + + #[test] + fn sixty_four_bit_bar_in_the_last_slot_has_no_upper_half() { + let mut masks = [0u32; 6]; + // Malformed, but it must not read past the end of the array. + let (low, _high) = mask_64(0x1000, false); + masks[5] = low; + + assert_eq!( + implemented_bars(&masks), + [false, false, false, false, false, true] + ); + } +} diff --git a/vm/devices/pci/vpci_protocol/src/lib.rs b/vm/devices/pci/vpci_protocol/src/lib.rs index c64cb08e660..be656df8eef 100644 --- a/vm/devices/pci/vpci_protocol/src/lib.rs +++ b/vm/devices/pci/vpci_protocol/src/lib.rs @@ -106,8 +106,16 @@ open_enum! { CREATE_INTERRUPT3 = 0x4249001b, /// Reset a device RESET_DEVICE = 0x4249001c, - /// TDISP command from guest to host + /// TDISP command from guest to host. + /// + /// Only valid on protocol version >= `ProtocolVersion::RB`. VPCI_TDISP_COMMAND = 0x4249001D, + /// Query per-BAR and DMA isolation state for a TDISP device. + /// + /// Paravisor-only: this message is intercepted by the OpenHCL paravisor + /// on the guest-facing VPCI channel and is not forwarded to the host VSP. + /// Only valid on protocol version >= `ProtocolVersion::RB`. + VPCI_QUERY_ISOLATED_RESOURCES = 0x4249001E, } } @@ -159,6 +167,9 @@ open_enum! { GE = 0x00010005, /// Windows DT version (allows Windows guests to dynamically map interrupts) DT = 0x00010006, + /// Windows RB version (adds TDISP support: `VPCI_TDISP_COMMAND` + /// and `VPCI_QUERY_ISOLATED_RESOURCES`). + RB = 0x00010007, } } @@ -186,12 +197,36 @@ open_enum! { pub enum Status: u32 { /// Operation completed successfully SUCCESS = 0, + /// Generic failure. + UNSUCCESSFUL = 0xC0000001, /// Protocol revision mismatch REVISION_MISMATCH = 0xC0000059, /// Bad data provided BAD_DATA = 0xC000090B, /// Operation not supported NOT_SUPPORTED = 0xC00000BB, + /// The device is not in a valid state to service the request. + /// Used for `VPCI_QUERY_ISOLATED_RESOURCES` when the TDI has not + /// reached Run, so the paravisor cannot yet classify resources. + INVALID_DEVICE_STATE = 0xC0000184, + } +} + +open_enum! { + /// Isolation classification for a single VPCI resource (a BAR or DMA). + /// + /// Returned per-entry in `VpciIsolatedResourcesReply`. + #[derive(IntoBytes, Immutable, KnownLayout, FromBytes)] + pub enum ResourceIsolation: u32 { + /// Entry not populated / not applicable. On a `Status::SUCCESS` reply + /// this marks a slot that is not part of the device's BAR ID set, as + /// described on `VpciIsolatedResourcesReply`. + INVALID = 0, + /// Host-visible, bounce-buffered. + SHARED = 1, + /// Host-inaccessible after TDI validation; backed by guest-private + /// (encrypted) memory. + PRIVATE = 2, } } @@ -861,3 +896,39 @@ pub struct VpciTdispCommand { /// Maximum size of a TDISP command in bytes. Property of the VMBUS implementation on the host. pub const MAX_VPCI_TDISP_COMMAND_SIZE: usize = MAXIMUM_PACKET_SIZE - size_of::(); + +/// Request for `MessageType::VPCI_QUERY_ISOLATED_RESOURCES`. +/// +/// Sent by the in-guest VPCI VSC to the OpenHCL paravisor to discover, for a +/// given device slot, which of the six BARs and whether the DMA path are +/// host-inaccessible (TDISP-bound) vs host-visible (bounce-buffered). +/// +/// Only valid when the negotiated protocol version is +/// `>= ProtocolVersion::RB`. +#[repr(C)] +#[derive(Debug, Copy, Clone, IntoBytes, Immutable, KnownLayout, FromBytes)] +pub struct VpciQueryIsolatedResources { + /// Must be `MessageType::VPCI_QUERY_ISOLATED_RESOURCES`. + pub message_type: MessageType, + /// Target device's PCI slot number. + pub slot: SlotNumber, +} + +const _: () = assert!(size_of::() == 8); + +/// Reply to `MessageType::VPCI_QUERY_ISOLATED_RESOURCES`. +/// +/// Synthesized entirely by the paravisor from local TDISP state. +#[repr(C)] +#[derive(Debug, Copy, Clone, IntoBytes, Immutable, KnownLayout, FromBytes)] +pub struct VpciIsolatedResourcesReply { + /// NTSTATUS. `Status::SUCCESS` means the per-resource fields are + /// authoritative. + pub status: Status, + /// Classification for each of the device's six BARs. + pub bar_isolation: [ResourceIsolation; 6], + /// Classification for the device's DMA path. + pub dma_isolation: ResourceIsolation, +} + +const _: () = assert!(size_of::() == 32); diff --git a/vm/devices/pci/vpci_relay/Cargo.toml b/vm/devices/pci/vpci_relay/Cargo.toml index 4da57c6b0c3..e17a89af825 100644 --- a/vm/devices/pci/vpci_relay/Cargo.toml +++ b/vm/devices/pci/vpci_relay/Cargo.toml @@ -8,6 +8,7 @@ edition.workspace = true [dependencies] chipset_device.workspace = true +hvdef.workspace = true memory_range.workspace = true pci_core.workspace = true state_unit.workspace = true @@ -16,6 +17,7 @@ openhcl_tdisp.workspace = true user_driver.workspace = true vpci_client.workspace = true vpci.workspace = true +virt.workspace = true vmbus_client.workspace = true vmbus_server.workspace = true vmcore.workspace = true @@ -32,13 +34,20 @@ tracing.workspace = true [target.'cfg(target_os = "linux")'.dependencies] fs-err.workspace = true hcl.workspace = true -hvdef.workspace = true sparse_mmap.workspace = true tracelimit.workspace = true [dev-dependencies] -vpci.workspace = true +closeable_mutex.workspace = true +guestmem.workspace = true +guid.workspace = true mesh.workspace = true +pal_async.workspace = true +parking_lot.workspace = true +task_control.workspace = true +test_with_tracing.workspace = true +vmbus_channel.workspace = true +vpci.workspace = true [lints] workspace = true diff --git a/vm/devices/pci/vpci_relay/src/lib.rs b/vm/devices/pci/vpci_relay/src/lib.rs index dbd2bba8784..2c4a4daa36e 100644 --- a/vm/devices/pci/vpci_relay/src/lib.rs +++ b/vm/devices/pci/vpci_relay/src/lib.rs @@ -12,6 +12,9 @@ #[cfg(target_os = "linux")] pub mod linux_mmio; +mod tdispmock; +mod tests; + // Exported to make it easier to define filters without explicitly pulling in // `pci_core`. pub use pci_core::spec::hwid::ClassCode; @@ -21,20 +24,32 @@ pub use pci_core::spec::hwid::Subclass; use anyhow::Context as _; use chipset_device::ChipsetDevice; use chipset_device::io::IoResult; +use chipset_device::io::deferred::DeferredWrite; +use chipset_device::io::deferred::defer_write; use chipset_device::pci::ByteEnabledDwordRead; use chipset_device::pci::ByteEnabledDwordWrite; use chipset_device::pci::PciConfigSpace; +use chipset_device::poll_device::PollDevice; use futures::StreamExt as _; use inspect::Inspect; use inspect::InspectMut; use memory_range::MemoryRange; -use openhcl_tdisp::TdispVirtualDeviceInterface; +use openhcl_tdisp::new_resource_validator; +use pci_core::spec::cfg_space::HeaderType00; use pci_core::spec::hwid::HardwareIds; use state_unit::StateUnits; +use std::collections::VecDeque; +use std::future::Future; use std::future::poll_fn; +use std::pin::Pin; use std::sync::Arc; use std::task::Poll; +use std::task::Waker; +use tdisp::TdispIsolationReport; +use tdisp::TdispRelayedDeviceTarget; +use tdisp::TdispTdiState; use user_driver::DmaClient; +use virt::IsolationType; use vmbus_client::driver::OpenParams; use vmbus_server::Guid; use vmcore::device_state::ChangeDeviceState; @@ -51,13 +66,6 @@ use vpci_client::VpciClient; use vpci_client::VpciDevice; use vpci_client::VpciDeviceEject; -/// TODO TDISP: Required for the tdisp crate to be built in the meantime. -#[expect(unused_imports)] -use tdisp::TdispHostDeviceInterface; -use tdisp::test_helpers::TDISP_MOCK_DEVICE_ID; -use tdisp::test_helpers::TDISP_MOCK_GUEST_PROTOCOL; -use tdisp::test_helpers::TDISP_MOCK_SUPPORTED_FEATURES; - /// Trait for creating memory access instances. pub trait CreateMemoryAccess: 'static + Send + Sync { /// Creates a new memory access instance for the given guest physical address. @@ -67,6 +75,12 @@ pub trait CreateMemoryAccess: 'static + Send + Sync { /// The size of the MMIO region required for each VPCI device. pub const VPCI_RELAY_MMIO_PER_DEVICE: u64 = vpci_client::MMIO_SIZE; +/// Size and alignment of the window the mocked TDISP flow programs into a +/// device BAR so that it can reach the device's registers. Only reserved when +/// that flow is enabled, and large enough for the BARs the emulated test +/// devices implement. +const MOCK_BAR_MMIO_SIZE: u64 = 0x10000; + /// Flags for controlling optional behavior of the VPCI relay. #[derive(Inspect, Debug, Default, Copy, Clone)] pub struct VpciRelayOptions { @@ -96,7 +110,13 @@ pub struct VpciRelay { allowed_devices: Vec, #[inspect(hex)] vtom: Option, + isolation_type: IsolationType, options: VpciRelayOptions, + /// Base of the window the mocked TDISP flow programs into a device BAR, + /// carved out of `mmio_range` and never handed to a device's config space. + /// Only set when that flow is enabled and the range had room for it. + #[inspect(hex)] + mock_bar_mmio: Option, } #[derive(Inspect)] @@ -104,6 +124,8 @@ struct RelayedDevice { bus_instance_id: Guid, bus_client: VpciClient, #[inspect(skip)] + vpci_device: Arc, + #[inspect(skip)] removed: VpciDeviceEject, #[inspect(skip)] bus_unit: DynamicDeviceUnit, @@ -114,8 +136,20 @@ struct RelayedDevice { impl RelayedDevice { async fn remove(self) { + // Tear down the guest-facing surface first so the guest can no + // longer issue packets against the channel while we unbind the + // TDI on the host side. self.bus_unit.remove().await; self.device_unit.remove().await; + + // Unbind any TDI state if the device is a TDISP device. + if self.vpci_device.tdisp().tdi_state().await != TdispTdiState::Unlocked { + self.vpci_device + .tdisp() + .unbind(tdisp::TdispGuestUnbindReason::DeviceTeardown) + .await; + } + self.bus_client.shutdown().await; } } @@ -180,9 +214,42 @@ impl VpciRelay { dma_client: Arc, mmio_range: MemoryRange, mmio_access: Box, + isolation_type: IsolationType, vtom: Option, options: VpciRelayOptions, ) -> Self { + // Setup test-specific values since TDISP tests don't necessarily take place inside a CVM runner. + let target_isolation_type = if options.test_tdisp_flow { + IsolationType::Snp + } else { + isolation_type + }; + + let target_vtom = if options.test_tdisp_flow { + Some(0x400000000000) // For testing, we can just use VTOM value we expect from most SNP platforms. + } else { + vtom + }; + + // The mocked flow needs an address it can program into a device BAR and + // then reach, and no guest has assigned any BARs by the time it runs. + // Take an aligned block off the top of the relay's own MMIO and keep it + // away from the per-device config space windows below. + let (mmio_range, mock_bar_mmio) = if options.test_tdisp_flow { + match Self::reserve_mock_bar_mmio(mmio_range) { + Some((rest, mock)) => (rest, Some(mock)), + None => { + tracing::warn!( + ?mmio_range, + "not enough relay MMIO to reserve a window for the mocked TDISP flow" + ); + (mmio_range, None) + } + } + } else { + (mmio_range, None) + }; + Self { driver_source, dma_client, @@ -193,11 +260,32 @@ impl VpciRelay { mmio_range, mmio_access, allowed_devices: Vec::new(), - vtom, + vtom: target_vtom, + isolation_type: target_isolation_type, options, + mock_bar_mmio, } } + /// Splits an aligned block off the end of `mmio_range` for the mocked TDISP + /// flow to program into a device BAR, returning the rest of the range and + /// the block's base address. + /// + /// Returns `None` when the range cannot give up an aligned block of that + /// size, in which case the caller keeps the whole range and the mocked flow + /// has no window to use. + /// + /// * `mmio_range` - The relay's MMIO range, which otherwise supplies one + /// config space window per device. + fn reserve_mock_bar_mmio(mmio_range: MemoryRange) -> Option<(MemoryRange, u64)> { + let end = mmio_range.end() & !(MOCK_BAR_MMIO_SIZE - 1); + let base = end.checked_sub(MOCK_BAR_MMIO_SIZE)?; + if base < mmio_range.start() { + return None; + } + Some((MemoryRange::new(mmio_range.start()..base), base)) + } + /// Adds an allowed device to the list. If one of the hardware ID is `!0` /// then it is treated as a wildcard. /// @@ -312,26 +400,67 @@ impl VpciRelay { tracing::info!(%instance_id, vendor_id = hw_ids.vendor_id, device_id = hw_ids.device_id, "vpci relay device arrived"); + // Create a TDISP platform validator based on the environment the relay + // is running in. Validators take care of platform firmware operations + // specific to the isolation technology in use. Test environments use + // mocked firmware interfaces. + let resource_validator = + new_resource_validator(self.isolation_type, self.vtom, self.options.test_tdisp_flow) + .context("failed to create a TDISP resource validator")?; + let (vpci_device, removed) = vpci_device - .init() + .init(resource_validator, self.isolation_type, hvdef::Vtl::Vtl0) .await .context("failed to initialize vpci device")?; let vpci_device = Arc::new(vpci_device); + // The host gets to decide if a device is TDISP capable or not + let mut tdisp_capable = false; + + // If testing the mock TDISP flow... if self.options.test_tdisp_flow { - Self::tdisp_test_mock_flow(vpci_device.clone()) + let bar_mmio = self + .mock_bar_mmio + .expect("the mocked TDISP flow needs a reserved MMIO window"); + tdispmock::run_test_flow(vpci_device.clone(), self.mmio_access.as_ref(), bar_mmio) .await .expect("failed to exercise TDISP flow test"); + + // Do not mark tdisp_capable = true because the test is already done. + } else { + // Probe TDISP capability without attesting. + match vpci_device.tdisp().query_capabilities().await { + Ok(_) => { + tdisp_capable = true; + tracing::info!( + %instance_id, + "TDISP capable device; deferring attestation until first guest interaction" + ); + } + Err(e) => { + tracing::info!( + %instance_id, + failure_reason = ?e, + "TDISP not supported or failed to query capabilities" + ); + } + } } let device_name = format!("assigned_device:vpci-{instance_id}"); let (device_unit, device) = chipset .add_dyn_device(&self.driver_source, state_units, device_name, async |_| { - Ok(RelayedVpciDevice(vpci_device.clone())) + Ok(RelayedVpciDevice { + device: vpci_device.clone(), + pending: None, + queued: VecDeque::new(), + waker: Waker::noop().clone(), + tdisp_capable, + }) }) .await?; - let interrupt_mapper = VpciInterruptMapper::new(vpci_device); + let interrupt_mapper = VpciInterruptMapper::new(vpci_device.clone()); let (bus_unit, _) = { let vpci_bus_name = format!("vpci:{instance_id}"); @@ -364,6 +493,7 @@ impl VpciRelay { entry.insert(RelayedDevice { bus_instance_id: instance_id, bus_client: vpci_client, + vpci_device: vpci_device.clone(), removed, bus_unit, device_unit, @@ -373,66 +503,283 @@ impl VpciRelay { state_units.start_stopped_units().await; Ok(()) } +} - /// Exercises a mocked TDISP flow for emulated TDISP devices produced by OpenVMM tests. - async fn tdisp_test_mock_flow(device: Arc) -> anyhow::Result<()> { - // For now, exercise just the "get device interface" flow and ensure that the device responds as - // TDISP capable and with the right mocked device information. +#[derive(InspectMut)] +struct RelayedVpciDevice { + #[inspect(flatten)] + device: Arc, - tracing::info!( - "tdisp_test_mock_flow: exercising TDISP flow because OPENHCL_TEST_CONFIG=TDISP_VPCI_FLOW_TEST was set" - ); + /// The TDISP operation currently in flight, if any, paired with the config + /// space write that started it. While this is set, no config space write + /// reaches the device. + #[inspect(skip)] + pending: Option<( + DeferredWrite, + Pin + Send + Sync>>, + )>, + + /// Config space writes that arrived while an async operation was in flight, + /// in arrival order. Only ever non-empty while an operation is in flight, + /// so its depth shows how many callers a slow operation is holding up. + #[inspect(with = "|x| x.len()")] + queued: VecDeque, + + /// Waker captured from the most recent poll, used to ask the device unit to + /// poll this device again once an async operation has been started. + #[inspect(skip)] + waker: Waker, - let device_interface_info = device - .tdisp_get_device_interface_info() - .await - .context("tdisp_test_mock_flow: failed to get device interface info over vpci")?; + /// Is the device TDISP capable? + tdisp_capable: bool, +} - tracing::info!( - "tdisp_test_mock_flow: device interface info: {:?}", - device_interface_info - ); +/// A config space write held off because a TDISP operation was in flight. +struct QueuedWrite { + /// The DWORD-aligned offset in config space the write targets. + offset: u16, + /// The value and byte enables to write. + value: ByteEnabledDwordWrite, + /// The write to complete once this has been applied to the device, or, when + /// applying it starts another TDISP operation, once that operation ends. + deferred: DeferredWrite, +} + +/// The result of applying a config space write that had no TDISP operation +/// ahead of it. +enum CfgWriteOutcome { + /// The write reached the device and needs nothing further. + Complete, + /// The write was deferred by cfg handling .The future carries out async + /// work required. Other guest VPs are blocked from writing to cfg space + /// while async work is dispatched and writes are instead queued for later + /// processing. + Started(Pin + Send + Sync>>), +} - assert_eq!( - device_interface_info.guest_protocol_type, - TDISP_MOCK_GUEST_PROTOCOL as i32 +impl RelayedVpciDevice { + /// Applies a config space write to a relayed VPCI device. Special handling + /// for TDISP devices might cause asynchronous operations to be initiated. + /// + /// `offset` is the DWORD-aligned offset in config space the write targets, + /// and `value` the value and byte enables to write. + fn apply_cfg_write(&mut self, offset: u16, value: ByteEnabledDwordWrite) -> CfgWriteOutcome { + // Only a command register write that flips the MMIO-enable bit needs + // async TDISP work. Everything else is a synchronous pass-through. + if !self.tdisp_capable || HeaderType00(offset) != HeaderType00::STATUS_COMMAND { + self.device.write_cfg(offset, value); + return CfgWriteOutcome::Complete; + } + + // Detect the MMIO-enable edge on the command register BEFORE issuing + // the write so we can dispatch the correct TDISP notification. + use pci_core::spec::cfg_space::Command; + let mut current = 0; + self.device.read_cfg( + offset, + ByteEnabledDwordRead::with_all_bytes_enabled(&mut current), ); - assert_eq!(device_interface_info.tdisp_device_id, TDISP_MOCK_DEVICE_ID); - assert_eq!( - device_interface_info.supported_features, - TDISP_MOCK_SUPPORTED_FEATURES + + let prev = Command::from((current & 0xffff) as u16).mmio_enabled(); + // `merge` honors the byte enables, so a partial write that leaves the + // command register untouched yields `next` equal to `prev`. + let next = Command::from((value.merge(current) & 0xffff) as u16).mmio_enabled(); + + match (prev, next) { + // MMIO turning on. Attest before the guest can reach the BARs. + // Activation writes the command register itself once attestation + // succeeds, so the BARs are mapped before the MMIO ranges are + // unblocked. + // + // If BARs are written to during or after this process, they will + // only affect the shadow BARs and not the real device BARs. This + // ensures misbehaving guests cannot remap their private sections + // once attestation is complete. + (false, true) => { + let device = self.device.clone(); + CfgWriteOutcome::Started(Box::pin(async move { + if !device.tdisp_on_device_activate(value).await { + tracing::warn!( + "TDISP attestation failed, leaving the command register off" + ); + } + })) + } + + // MMIO turning off. Tear the TDI back down. Deactivation leaves the + // command register in its off state and unmaps all private BARs. + (true, false) => { + let device = self.device.clone(); + CfgWriteOutcome::Started(Box::pin(async move { + device.tdisp_on_device_deactivate().await; + })) + } + + // No MMIO edge, just pass through. + (false, false) | (true, true) => { + self.device.write_cfg(offset, value); + CfgWriteOutcome::Complete + } + } + } + + /// Makes `fut` the in-flight TDISP operation and asks the device unit to + /// poll this device so that it starts making progress. + /// + /// `deferred` is the config space write that waits on the operation, and + /// `fut` the operation itself. + fn start_operation( + &mut self, + deferred: DeferredWrite, + fut: Pin + Send + Sync>>, + ) { + // Every path here has just observed or made `pending` empty, so this + // cannot displace an operation that is still running. + assert!( + self.pending.is_none(), + "TDISP operation started while another was in flight" ); + self.pending = Some((deferred, fut)); + self.waker.wake_by_ref(); + } - Ok(()) + /// Applies the writes that queued up behind a TDISP operation, in arrival + /// order, stopping at the first one that starts another operation. + /// + /// Must only be called with no operation in flight. + fn drain_queued(&mut self) { + while let Some(QueuedWrite { + offset, + value, + deferred, + }) = self.queued.pop_front() + { + match self.apply_cfg_write(offset, value) { + CfgWriteOutcome::Complete => deferred.complete(), + CfgWriteOutcome::Started(fut) => { + // The rest of the queue stays put, behind this operation. + self.start_operation(deferred, fut); + return; + } + } + } } } -#[derive(InspectMut)] -#[inspect(transparent)] -struct RelayedVpciDevice(Arc); - impl ChipsetDevice for RelayedVpciDevice { fn supports_pci(&mut self) -> Option<&mut dyn PciConfigSpace> { Some(self) } + + fn supports_tdisp_relay(&mut self) -> Option<&mut dyn TdispRelayedDeviceTarget> { + Some(self) + } + + fn supports_poll_device(&mut self) -> Option<&mut dyn PollDevice> { + Some(self) + } +} + +impl PollDevice for RelayedVpciDevice { + fn poll_device(&mut self, cx: &mut std::task::Context<'_>) { + self.waker = cx.waker().clone(); + while let Some((_, fut)) = self.pending.as_mut() { + if fut.as_mut().poll(cx).is_pending() { + break; + } + + // Keep queueing any deferred writes that are ready and re-poll them. + let (deferred, _) = self.pending.take().expect("just checked"); + deferred.complete(); + self.drain_queued(); + } + } +} + +impl TdispRelayedDeviceTarget for RelayedVpciDevice { + // Builds a report for the guest of what device resources for vpci device in + // a CVM are isolated or shared. + fn tdisp_isolation_report( + &mut self, + ) -> Pin + Send + 'static>> { + let device = self.device.clone(); + let tdisp_capable = self.tdisp_capable; + + Box::pin(async move { + // If the device is not TDISP capable, return early with an invalid report. + if !tdisp_capable { + return TdispIsolationReport::NotTdispCapable; + } + + // This might fire an attestation flow if it hasn't already happened yet. + let report = device.tdisp().isolation_snapshot_attested().await; + + // Typically, the guest needs to know the isolation report of the + // device's resources before attempting to configure resources. Once + // the report is retrieved, the device is unbound here and left in + // an unlocked state for resource programming and final + // configuration by the guest. + tracing::info!( + ?report, + "Unbinding after the isolation report to prepare for guest resource programming" + ); + device + .tdisp() + .unbind(tdisp::TdispGuestUnbindReason::Graceful) + .await; + + report + }) + } } impl PciConfigSpace for RelayedVpciDevice { fn pci_cfg_read(&mut self, offset: u16, value: ByteEnabledDwordRead<'_>) -> IoResult { - self.0.read_cfg(offset, value); + self.device.read_cfg(offset, value); IoResult::Ok } fn pci_cfg_write(&mut self, offset: u16, value: ByteEnabledDwordWrite) -> IoResult { - self.0.write_cfg(offset, value); - IoResult::Ok + // An asynchronous operation has to run to completion with nothing else + // touching the device's config space. Writes arriving behind an already + // queued write must wait and be applied in the order they arrived. The + // chipset drops the device lock before waiting on a deferred access, so + // several VPs, plus the VPCI channel worker, can be in here at once. + if self.pending.is_some() || !self.queued.is_empty() { + let (deferred, token) = defer_write(); + self.queued.push_back(QueuedWrite { + offset, + value, + deferred, + }); + return IoResult::Defer(token); + } + + match self.apply_cfg_write(offset, value) { + CfgWriteOutcome::Complete => IoResult::Ok, + CfgWriteOutcome::Started(fut) => { + let (deferred, token) = defer_write(); + self.start_operation(deferred, fut); + IoResult::Defer(token) + } + } } } impl ChangeDeviceState for RelayedVpciDevice { fn start(&mut self) {} - async fn stop(&mut self) {} + async fn stop(&mut self) { + // Nothing polls this device while it is stopped, so finish asynchronous + // operations and everything queued behind them here. Otherwise the callers + // waiting on those writes would be left waiting on a completion that + // never comes. + while let Some((deferred, fut)) = self.pending.take() { + fut.await; + deferred.complete(); + self.drain_queued(); + } + } async fn reset(&mut self) {} } diff --git a/vm/devices/pci/vpci_relay/src/tdispmock.rs b/vm/devices/pci/vpci_relay/src/tdispmock.rs new file mode 100644 index 00000000000..b13c3d6d58c --- /dev/null +++ b/vm/devices/pci/vpci_relay/src/tdispmock.rs @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! A mocked TDISP flow for an emulated OpenVMM TDISP device. This test flow +//! ensures that a full end-to-end flow of attestation and BAR acceptance is +//! correctly handled for an emulated TDISP device relayed over VPCI. +//! +//! Runs when the relay is started with +//! `OPENHCL_TEST_CONFIG=TDISP_VPCI_FLOW_TEST`. This flow is exercised +//! automatically within the openvmm test suite. +//! +//! The expected behavior is: +//! - An emulated TDISP device is created and relayed through the VPCI bus. (At +//! the time of writing, this is an emulated NVMe device). The device requires +//! attestation and the acceptance of its BAR0 range before it answers to any +//! BAR requests. +//! - The device creates and advertises a TDISP capability and synthesized TDI +//! report to the guest. +//! - The guest successfully attests the device over VPCI and accepts its +//! register BAR range. +//! - Once attested and the BAR range accepted, the device's registers become +//! accessible. +//! - Lastly, the TDI is unbound and the device's registers are expected to +//! become inaccessible again. + +use crate::CreateMemoryAccess; +use anyhow::Context as _; +use chipset_device::pci::ByteEnabledDwordRead; +use chipset_device::pci::ByteEnabledDwordWrite; +use chipset_device::pci::PciConfigByteEnable; +use pci_core::spec::cfg_space::BarEncodingBits; +use pci_core::spec::cfg_space::Command; +use pci_core::spec::cfg_space::HeaderType00; +use std::sync::Arc; +use tdisp::TdispGuestUnbindReason; +use tdisp::TdispTdiState; +use tdisp::test_helpers::TDISP_MOCK_DEVICE_ID; +use tdisp::test_helpers::TDISP_MOCK_GUEST_PROTOCOL; +use tdisp::test_helpers::TDISP_MOCK_SUPPORTED_FEATURES; +use vpci_client::MemoryAccess; +use vpci_client::VpciDevice; + +/// The BAR the emulated device keeps its registers behind, which is the range +/// TDISP has to accept into the guest before it answers. +const REGISTER_BAR: u16 = 0; + +/// Exercises the mocked TDISP flow against `device`. Takes the fake relayed +/// TDISP device through a full attestation and teardown flow, checking on the +/// way that its registers are unreachable until the TDI has been attested and +/// the range accepted, and unreachable again once it is unbound. +/// +/// * `device` - The relayed device to exercise. +/// * `mmio_access` - Used to reach the device's registers once its BAR has been +/// programmed. +/// * `bar_mmio` - Base of the window to program into the device's register BAR. +/// Reserved by the relay for this flow, so nothing else decodes there. +pub(crate) async fn run_test_flow( + device: Arc, + mmio_access: &dyn CreateMemoryAccess, + bar_mmio: u64, +) -> anyhow::Result<()> { + tracing::info!( + "tdisp_test_mock_flow: exercising TDISP flow because OPENHCL_TEST_CONFIG=TDISP_VPCI_FLOW_TEST was set" + ); + + assert_eq!(device.tdisp().tdi_state().await, TdispTdiState::Unlocked); + + let device_interface_info = device + .tdisp() + .get_device_interface_info(TDISP_MOCK_GUEST_PROTOCOL) + .await + .context("tdisp_test_mock_flow: failed to get device interface info over vpci")?; + + tracing::info!( + "tdisp_test_mock_flow: device interface info: {:?}", + device_interface_info + ); + + assert_eq!( + device_interface_info.guest_protocol_type, + TDISP_MOCK_GUEST_PROTOCOL as i32 + ); + assert_eq!(device_interface_info.tdisp_device_id, TDISP_MOCK_DEVICE_ID); + assert_eq!( + device_interface_info.supported_features, + TDISP_MOCK_SUPPORTED_FEATURES + ); + assert_eq!(device.tdisp().tdi_state().await, TdispTdiState::Unlocked); + + run_bar_access_flow(device.clone(), mmio_access, bar_mmio) + .await + .context("tdisp_test_mock_flow: failed to exercise TDISP attestation flow")?; + + Ok(()) +} + +/// Programs `bar_mmio` into the device's register BAR and turns on MMIO +/// decoding, so the device answers at a known address. +/// +/// Returns the length the BAR decodes, which TDISP needs in order to accept the +/// range. +/// +/// * `device` - The relayed device to program. +/// * `bar_mmio` - Base address to give the BAR. +fn program_register_bar(device: &VpciDevice, bar_mmio: u64) -> u64 { + let bar_offset = HeaderType00::BAR0.0 + REGISTER_BAR * 4; + + // Size the BAR the way a PCI enumerator does. The client shadows BAR + // writes rather than passing them to the device, so probing here costs + // the device nothing. + device.write_cfg( + bar_offset, + ByteEnabledDwordWrite::with_all_bytes_enabled(!0), + ); + let mut mask = 0; + device.read_cfg( + bar_offset, + ByteEnabledDwordRead::with_all_bytes_enabled(&mut mask), + ); + let length = u64::from(!(mask & !0xf)) + 1; + let is_64_bit = BarEncodingBits::from_bits(mask).type_64_bit(); + + device.write_cfg( + bar_offset, + ByteEnabledDwordWrite::with_all_bytes_enabled(bar_mmio as u32), + ); + if is_64_bit { + device.write_cfg( + bar_offset + 4, + ByteEnabledDwordWrite::with_all_bytes_enabled((bar_mmio >> 32) as u32), + ); + } + + // Enabling MMIO is what pushes the shadowed BAR through to the device. + device.write_cfg( + HeaderType00::STATUS_COMMAND.0, + ByteEnabledDwordWrite::new( + Command::new().with_mmio_enabled(true).into_bits().into(), + PciConfigByteEnable::LOW_WORD, + ), + ); + + tracing::info!( + bar_mmio, + length, + is_64_bit, + "tdisp_test_mock_flow: programmed the register BAR" + ); + + length +} + +/// Returns the device's register BAR to its unprogrammed state, so that the +/// guest that follows starts from a device with nothing mapped. +/// +/// * `device` - The relayed device to clear. +fn clear_register_bar(device: &VpciDevice) { + device.write_cfg( + HeaderType00::STATUS_COMMAND.0, + ByteEnabledDwordWrite::new(0, PciConfigByteEnable::LOW_WORD), + ); + let bar_offset = HeaderType00::BAR0.0 + REGISTER_BAR * 4; + device.write_cfg(bar_offset, ByteEnabledDwordWrite::with_all_bytes_enabled(0)); + device.write_cfg( + bar_offset + 4, + ByteEnabledDwordWrite::with_all_bytes_enabled(0), + ); +} + +/// Attests the device, then checks that its registers are reachable only while +/// TDISP says they are. +/// +/// * `device` - The relayed device to exercise. +/// * `mmio_access` - Used to reach the device's registers. +/// * `bar_mmio` - Base of the window to program into the device's register BAR. +async fn run_bar_access_flow( + device: Arc, + mmio_access: &dyn CreateMemoryAccess, + bar_mmio: u64, +) -> anyhow::Result<()> { + let bar_length = program_register_bar(&device, bar_mmio); + let mut bar = mmio_access + .create_memory_access(bar_mmio) + .context("tdisp_test_mock_flow: failed to map the device's register BAR")?; + + let read_registers = |bar: &mut Box| { + let mut data = [0u8; 4]; + bar.read(bar_mmio, &mut data); + u32::from_ne_bytes(data) + }; + + // Nothing has been attested, so the device must not answer. A window that + // decodes nothing reads as all ones. + let blocked = read_registers(&mut bar); + tracing::info!( + blocked, + "tdisp_test_mock_flow: register BAR before attestation" + ); + assert_eq!( + blocked, !0, + "the device answered with all FFs on its BAR before the TDI was attested" + ); + + run_attest_flow(device.clone()).await?; + + // Attested, so the range can be accepted into the guest's context. This is + // the same call the relay makes when a guest enables MMIO. + device + .tdisp() + .on_mmio_reconfigured(REGISTER_BAR, bar_mmio, bar_length) + .await + .context("tdisp_test_mock_flow: failed to unblock the register BAR")?; + + // The device answers now, and its first register is never all ones. + let unblocked = read_registers(&mut bar); + tracing::info!( + unblocked, + "tdisp_test_mock_flow: register BAR after the range was accepted" + ); + assert_ne!( + unblocked, !0, + "the device answered with FFs on its BAR after the range was accepted" + ); + + // Unbinding takes the range away again, which is what leaves the device + // safe for the guest that follows. + device + .tdisp() + .unbind(TdispGuestUnbindReason::Graceful) + .await; + assert_eq!(device.tdisp().tdi_state().await, TdispTdiState::Unlocked); + + let reblocked = read_registers(&mut bar); + tracing::info!(reblocked, "tdisp_test_mock_flow: register BAR after unbind"); + assert_eq!( + reblocked, !0, + "the device still answered with FFs on its BAR after the TDI was unbound" + ); + + clear_register_bar(&device); + + Ok(()) +} + +/// Attests `device` through the TDISP flow, checking the capabilities it +/// reports on the way and that the TDI ends up in `TdispTdiState::Run`. +async fn run_attest_flow(device: Arc) -> anyhow::Result<()> { + // Ensure the device appears to be tdisp capable + let tdisp_capabilities = device + .tdisp() + .query_capabilities() + .await + .context("tdisp_test_mock_flow: failed to query TDISP capabilities over vpci")?; + + assert_eq!( + tdisp_capabilities.guest_protocol_type, + TDISP_MOCK_GUEST_PROTOCOL as i32 + ); + assert_eq!(tdisp_capabilities.tdisp_device_id, TDISP_MOCK_DEVICE_ID); + assert_eq!( + tdisp_capabilities.supported_features, + TDISP_MOCK_SUPPORTED_FEATURES + ); + assert_eq!(device.tdisp().tdi_state().await, TdispTdiState::Unlocked); + + // If the above interface works, try to attest the device through the TDISP flow and ensure that it succeeds. + device + .tdisp() + .attest(tdisp_capabilities) + .await + .context("tdisp_test_mock_flow: failed to attest device over vpci")?; + + assert_eq!(device.tdisp().tdi_state().await, TdispTdiState::Run); + + Ok(()) +} diff --git a/vm/devices/pci/vpci_relay/src/tests.rs b/vm/devices/pci/vpci_relay/src/tests.rs new file mode 100644 index 00000000000..780e4d4ca24 --- /dev/null +++ b/vm/devices/pci/vpci_relay/src/tests.rs @@ -0,0 +1,424 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Unit tests which exercise TDISP functionality of a relayed VPCI device. +//! These tests ensure at least basic smoke test of TDISP machinery driven by +//! the guest with VPCI. This ensures that a TDISP device behaves correctly in a +//! relayed VPCI environment. + +#![cfg(test)] + +use super::RelayedVpciDevice; +use chipset_device::ChipsetDevice; +use chipset_device::io::IoResult; +use chipset_device::io::deferred::DeferredToken; +use chipset_device::mmio::ExternallyManagedMmioIntercepts; +use chipset_device::pci::ByteEnabledDwordRead; +use chipset_device::pci::ByteEnabledDwordWrite; +use chipset_device::pci::PciConfigByteEnable; +use chipset_device::pci::PciConfigSpace; +use chipset_device::poll_device::PollDevice; +use closeable_mutex::CloseableMutex; +use guestmem::GuestMemory; +use guid::Guid; +use hvdef::Vtl; +use openhcl_tdisp::noop::TdispNoopResourceValidator; +use pal_async::DefaultDriver; +use pal_async::async_test; +use pal_async::task::Spawn; +use parking_lot::Mutex; +use pci_core::spec::cfg_space::Command; +use pci_core::spec::cfg_space::HeaderType00; +use std::collections::HashMap; +use std::collections::VecDeque; +use std::future::Future; +use std::future::poll_fn; +use std::pin::pin; +use std::sync::Arc; +use std::task::Waker; +use task_control::StopTask; +use tdisp::TdispHostDeviceTargetEmulator; +use tdisp::TdispTdiState; +use tdisp::test_helpers::new_null_tdisp_interface; +use test_with_tracing::test; +use virt::IsolationType; +use vmbus_channel::simple::SimpleVmbusDevice; +use vmcore::vpci_msi::VpciInterruptMapper; +use vpci::bus::VpciBusConfig; +use vpci::bus::VpciBusDevice; +use vpci::test_helpers::TestVpciInterruptController; +use vpci_client::MemoryAccess; +use vpci_client::VpciClient; +use vpci_client::VpciDevice; + +/// A config space register unrelated to TDISP, used to check that writes that +/// would otherwise pass straight through still wait their turn. +const SCRATCH_OFFSET: u16 = 0x40; + +/// The VTOM most SNP platforms report, which TDISP setup expects to be present. +const TEST_VTOM: u64 = 0x400000000000; + +/// The config space the emulated host device presents, plus a log of everything +/// the relay has pushed through to it. +#[derive(Default)] +struct HostConfigSpace { + /// Every write that reached the device, in the order it arrived, as + /// (offset, value) pairs. + writes: Vec<(u16, u32)>, + /// The registers written so far, keyed by DWORD-aligned offset. + registers: HashMap, +} + +/// The device on the far side of the relayed VPCI bus. It answers config space +/// accesses out of a plain register map and records every write, so a test can +/// tell exactly what reached the device and when. +struct TestHostDevice { + tdisp_interface: TdispHostDeviceTargetEmulator, + cfg: Arc>, +} + +impl ChipsetDevice for TestHostDevice { + fn supports_pci(&mut self) -> Option<&mut dyn PciConfigSpace> { + Some(self) + } + + fn supports_tdisp_host(&mut self) -> Option<&mut dyn tdisp::TdispHostDeviceTarget> { + Some(&mut self.tdisp_interface) + } +} + +impl PciConfigSpace for TestHostDevice { + fn pci_cfg_read(&mut self, offset: u16, mut value: ByteEnabledDwordRead<'_>) -> IoResult { + value.set(self.cfg.lock().registers.get(&offset).copied().unwrap_or(0)); + IoResult::Ok + } + + fn pci_cfg_write(&mut self, offset: u16, value: ByteEnabledDwordWrite) -> IoResult { + let mut cfg = self.cfg.lock(); + cfg.writes.push((offset, value.extract())); + // The device implements no BARs, so BAR writes are dropped and the + // registers keep reading back as zero. The client probes them for size + // during bring-up and would otherwise see the probe value itself. + if !(HeaderType00::BAR0.0..HeaderType00::BAR5.0 + 4).contains(&offset) { + let current = cfg.registers.get(&offset).copied().unwrap_or(0); + cfg.registers.insert(offset, value.merge(current)); + } + IoResult::Ok + } +} + +/// Bridges the client's MMIO window onto the bus device directly, with no +/// address space in between. +struct BusWrapper(VpciBusDevice); + +impl MemoryAccess for BusWrapper { + fn gpa(&mut self) -> u64 { + 0x123456780000 + } + + fn read(&mut self, addr: u64, value: &mut [u8]) { + self.0 + .supports_mmio() + .unwrap() + .mmio_read(addr, value) + .unwrap(); + } + + fn write(&mut self, addr: u64, value: &[u8]) { + self.0 + .supports_mmio() + .unwrap() + .mmio_write(addr, value) + .unwrap(); + } +} + +/// Everything a test needs to drive one relayed device. +struct TestRelay { + /// The device under test, behind the same kind of lock the chipset uses. + relay: Mutex, + /// The client's handle to the same device, for checking TDI state. + device: Arc, + /// The emulated host device's config space and write log. + cfg: Arc>, + /// Keeps the VPCI channel server running for the lifetime of the test. + _server: pal_async::task::Task<()>, + /// Keeps the client alive for the lifetime of the test. + _client: VpciClient, +} + +/// Brings up a VPCI bus with an emulated TDISP-capable device on it and wraps +/// the resulting client device in the relay under test. +/// +/// `tdisp_capable` sets whether the relay treats the device as TDISP capable, +/// which the relay normally decides by probing the device when the host offers +/// it. +async fn connect_relay(driver: &DefaultDriver, tdisp_capable: bool) -> TestRelay { + let cfg = Arc::new(Mutex::new(HostConfigSpace::default())); + let host_device = Arc::new(CloseableMutex::new(TestHostDevice { + tdisp_interface: new_null_tdisp_interface("vpci-relay-unit-test"), + cfg: cfg.clone(), + })); + + let (bus, mut channel) = VpciBusDevice::new( + VpciBusConfig { + instance_id: Guid::new_random(), + vtom: Some(TEST_VTOM), + vnode: None, + }, + host_device, + &mut ExternallyManagedMmioIntercepts, + VpciInterruptMapper::new(TestVpciInterruptController::new()), + ) + .unwrap(); + + let (host, guest) = vmbus_channel::connected_async_channels(32768); + let mut runner = channel.open(host, GuestMemory::empty()).unwrap(); + let server = driver.spawn("vpci-server", async move { + StopTask::run_with(std::future::pending(), async |stop| { + let _ = channel.run(stop, &mut runner).await; + }) + .await + }); + + let (client, devices) = + VpciClient::connect(driver, guest, Box::new(BusWrapper(bus)), mesh::channel().0) + .await + .unwrap(); + + let (device, _removed) = devices + .into_iter() + .next() + .unwrap() + .init( + Arc::new(TdispNoopResourceValidator::new()), + IsolationType::Snp, + Vtl::Vtl0, + ) + .await + .unwrap(); + let device = Arc::new(device); + + // Drop everything the client did while bringing the device up so that each + // test sees only its own writes. + cfg.lock().writes.clear(); + + TestRelay { + relay: Mutex::new(RelayedVpciDevice { + device: device.clone(), + pending: None, + queued: VecDeque::new(), + waker: Waker::noop().clone(), + tdisp_capable, + }), + device, + cfg, + _server: server, + _client: client, + } +} + +impl TestRelay { + /// Issues a config space write and returns its result, holding the device + /// lock only for the duration of the call, as the chipset does. + fn write(&self, offset: u16, value: u32, byte_enable: PciConfigByteEnable) -> IoResult { + self.relay + .lock() + .pci_cfg_write(offset, ByteEnabledDwordWrite::new(value, byte_enable)) + } + + /// Issues a config space write that is expected to be deferred, and returns + /// the token to wait on. + fn deferred_write( + &self, + offset: u16, + value: u32, + byte_enable: PciConfigByteEnable, + ) -> DeferredToken { + match self.write(offset, value, byte_enable) { + IoResult::Defer(token) => token, + other => panic!("expected offset {offset:#x} to defer, got {other:?}"), + } + } + + /// Issues a config space read and returns the value. + fn read(&self, offset: u16) -> u32 { + let mut value = 0; + self.relay + .lock() + .pci_cfg_read( + offset, + ByteEnabledDwordRead::with_all_bytes_enabled(&mut value), + ) + .unwrap(); + value + } + + /// Runs `fut` to completion while polling the relay, standing in for the + /// chipset device unit that would otherwise drive it. + async fn drive(&self, fut: impl Future) -> T { + let mut fut = pin!(fut); + poll_fn(|cx| { + self.relay.lock().poll_device(cx); + fut.as_mut().poll(cx) + }) + .await + } + + /// The offsets written to the host device so far, in order. + fn written_offsets(&self) -> Vec { + self.cfg.lock().writes.iter().map(|&(o, _)| o).collect() + } +} + +/// A command register value with MMIO enabled or disabled, ready to write to +/// the low word of the status/command DWORD. +fn command(mmio_enabled: bool) -> u32 { + Command::new().with_mmio_enabled(mmio_enabled).into_bits() as u32 +} + +/// Enabling MMIO starts an attestation that takes several round trips, so a +/// write arriving in the meantime has to wait rather than trample the operation +/// already in flight. +#[async_test] +async fn write_during_tdisp_operation_waits_its_turn(driver: DefaultDriver) { + let relay = connect_relay(&driver, true).await; + + let activate = relay.deferred_write( + HeaderType00::STATUS_COMMAND.0, + command(true), + PciConfigByteEnable::LOW_WORD, + ); + + // An unrelated write that would normally pass straight through to the + // device. It must defer instead, and must not reach the device yet. + let scratch = relay.deferred_write(SCRATCH_OFFSET, 0xabcd_0000, PciConfigByteEnable::FULL); + assert!(!relay.written_offsets().contains(&SCRATCH_OFFSET)); + + relay.drive(activate.write_future()).await.unwrap(); + relay.drive(scratch.write_future()).await.unwrap(); + + // Attestation ran to completion and enabled the device... + assert_eq!(relay.device.tdisp().tdi_state().await, TdispTdiState::Run); + // ...and only then did the queued write land. + let offsets = relay.written_offsets(); + let command_index = offsets + .iter() + .position(|&o| o == HeaderType00::STATUS_COMMAND.0) + .expect("activation writes the command register"); + let scratch_index = offsets + .iter() + .position(|&o| o == SCRATCH_OFFSET) + .expect("the queued write reaches the device"); + assert!( + command_index < scratch_index, + "queued write reached the device before the operation finished: {offsets:#x?}" + ); + assert_eq!(relay.read(SCRATCH_OFFSET), 0xabcd_0000); +} + +/// Writes that pile up behind an operation are applied in the order they +/// arrived, and each caller sees its own write complete. +#[async_test] +async fn queued_writes_are_applied_in_arrival_order(driver: DefaultDriver) { + let relay = connect_relay(&driver, true).await; + + let activate = relay.deferred_write( + HeaderType00::STATUS_COMMAND.0, + command(true), + PciConfigByteEnable::LOW_WORD, + ); + let first = relay.deferred_write(SCRATCH_OFFSET, 0x1111_0000, PciConfigByteEnable::FULL); + let second = relay.deferred_write(SCRATCH_OFFSET, 0x2222_0000, PciConfigByteEnable::FULL); + let third = relay.deferred_write(SCRATCH_OFFSET, 0x3333_0000, PciConfigByteEnable::FULL); + + relay.drive(activate.write_future()).await.unwrap(); + relay.drive(first.write_future()).await.unwrap(); + relay.drive(second.write_future()).await.unwrap(); + relay.drive(third.write_future()).await.unwrap(); + + let scratch_writes: Vec = relay + .cfg + .lock() + .writes + .iter() + .filter(|&&(o, _)| o == SCRATCH_OFFSET) + .map(|&(_, v)| v) + .collect(); + assert_eq!(scratch_writes, vec![0x1111_0000, 0x2222_0000, 0x3333_0000]); +} + +/// A queued write can itself cross an MMIO-enable edge. It has to start a fresh +/// operation when its turn comes rather than be applied on top of the one that +/// was already running. +#[async_test] +async fn queued_write_starts_the_next_tdisp_operation(driver: DefaultDriver) { + let relay = connect_relay(&driver, true).await; + + let activate = relay.deferred_write( + HeaderType00::STATUS_COMMAND.0, + command(true), + PciConfigByteEnable::LOW_WORD, + ); + let deactivate = relay.deferred_write( + HeaderType00::STATUS_COMMAND.0, + command(false), + PciConfigByteEnable::LOW_WORD, + ); + + relay.drive(activate.write_future()).await.unwrap(); + relay.drive(deactivate.write_future()).await.unwrap(); + + // The second write saw the state the first one left behind, recognized the + // MMIO-disable edge, and unbound the TDI. + assert_eq!( + relay.device.tdisp().tdi_state().await, + TdispTdiState::Unlocked + ); + assert_eq!( + relay.read(HeaderType00::STATUS_COMMAND.0) & command(true), + 0 + ); +} + +/// Reads are not part of the TDISP flow and keep being answered on the spot. +#[async_test] +async fn reads_are_not_held_off(driver: DefaultDriver) { + let relay = connect_relay(&driver, true).await; + + let activate = relay.deferred_write( + HeaderType00::STATUS_COMMAND.0, + command(true), + PciConfigByteEnable::LOW_WORD, + ); + + // Still reads the pre-activation command register, synchronously. + assert_eq!( + relay.read(HeaderType00::STATUS_COMMAND.0) & command(true), + 0 + ); + + relay.drive(activate.write_future()).await.unwrap(); +} + +/// With nothing in flight, a write that needs no TDISP work is passed straight +/// through, as it was before writes could queue. +#[async_test] +async fn writes_pass_through_when_nothing_is_in_flight(driver: DefaultDriver) { + let relay = connect_relay(&driver, true).await; + + relay + .write(SCRATCH_OFFSET, 0x5555_0000, PciConfigByteEnable::FULL) + .unwrap(); + assert_eq!(relay.read(SCRATCH_OFFSET), 0x5555_0000); + + // A device the host did not offer as TDISP capable never defers at all. + let plain = connect_relay(&driver, false).await; + plain + .write( + HeaderType00::STATUS_COMMAND.0, + command(true), + PciConfigByteEnable::LOW_WORD, + ) + .unwrap(); +} diff --git a/vm/devices/storage/disk_nvme/nvme_driver/src/tests.rs b/vm/devices/storage/disk_nvme/nvme_driver/src/tests.rs index 39d0f32cca0..9a054c0be1f 100644 --- a/vm/devices/storage/disk_nvme/nvme_driver/src/tests.rs +++ b/vm/devices/storage/disk_nvme/nvme_driver/src/tests.rs @@ -472,7 +472,7 @@ async fn test_nvme_fault_injection(driver: DefaultDriver, fault_configuration: F subsystem_id: Guid::new_random(), }, fault_configuration, - None, + false, ); nvme.client() // 2MB namespace diff --git a/vm/devices/storage/nvme_test/Cargo.toml b/vm/devices/storage/nvme_test/Cargo.toml index bce80040321..2afc2bc5887 100644 --- a/vm/devices/storage/nvme_test/Cargo.toml +++ b/vm/devices/storage/nvme_test/Cargo.toml @@ -23,6 +23,7 @@ guestmem.workspace = true vmcore.workspace = true vm_resource.workspace = true +anyhow.workspace = true guid.workspace = true inspect.workspace = true mesh.workspace = true @@ -39,6 +40,7 @@ unicycle.workspace = true zerocopy = { workspace = true, features = ["alloc"] } [dev-dependencies] +tdisp_proto.workspace = true user_driver.workspace = true [lints] diff --git a/vm/devices/storage/nvme_test/src/lib.rs b/vm/devices/storage/nvme_test/src/lib.rs index 5f9687873ba..776f5464e6f 100644 --- a/vm/devices/storage/nvme_test/src/lib.rs +++ b/vm/devices/storage/nvme_test/src/lib.rs @@ -12,6 +12,7 @@ mod pci; mod prp; mod queue; pub mod resolver; +mod tdisp; mod workers; #[cfg(test)] diff --git a/vm/devices/storage/nvme_test/src/pci.rs b/vm/devices/storage/nvme_test/src/pci.rs index 48b1b94b218..43e20c5fcbd 100644 --- a/vm/devices/storage/nvme_test/src/pci.rs +++ b/vm/devices/storage/nvme_test/src/pci.rs @@ -14,6 +14,9 @@ use crate::NvmeFaultControllerClient; use crate::PAGE_MASK; use crate::VENDOR_ID; use crate::spec; +use crate::tdisp::BAR0_RANGE_ID; +use crate::tdisp::TdispMmioRanges; +use crate::tdisp::new_tdisp_interface; use crate::workers::IoQueueEntrySizes; use crate::workers::NvmeWorkers; use chipset_device::ChipsetDevice; @@ -71,6 +74,10 @@ pub struct NvmeFaultController { /// The NVMe fault controller is repurposed for use in TDISP tests. #[inspect(skip)] tdisp_interface: Option>, + /// The MMIO ranges TDISP currently allows the guest to reach. Empty, and so + /// blocking every range, on a controller that is not a TDISP device. + #[inspect(skip)] + tdisp_mmio_ranges: TdispMmioRanges, } #[derive(Inspect)] @@ -127,9 +134,22 @@ impl NvmeFaultController { register_mmio: &mut dyn RegisterMmioIntercept, caps: NvmeFaultControllerCaps, mut fault_configuration: FaultConfiguration, - tdisp_interface: Option>, + enable_tdisp: bool, ) -> Self { let (msix, msix_cap) = MsixEmulator::new(4, caps.msix_count, msi_target); + + // The fault controller is repurposed as an emulated TDISP device. Its + // own TDISP interface reports the BARs below and records which of them + // the guest has been allowed to reach. + let (tdisp_interface, tdisp_mmio_ranges) = if enable_tdisp { + let (emulator, ranges) = new_tdisp_interface("fault-controller-test", msix.bar_len()); + ( + Some(Box::new(emulator) as Box), + ranges, + ) + } else { + (None, TdispMmioRanges::default()) + }; let bars = DeviceBars::new() .bar0( BAR0_LEN, @@ -199,6 +219,7 @@ impl NvmeFaultController { pci_fault_config, fault_active, tdisp_interface, + tdisp_mmio_ranges, } } @@ -492,6 +513,7 @@ impl ChangeDeviceState for NvmeFaultController { pci_fault_config: _, fault_active: _, tdisp_interface: _, + tdisp_mmio_ranges: _, } = self; workers.reset().await; cfg_space.reset(); @@ -510,7 +532,7 @@ impl ChipsetDevice for NvmeFaultController { } /// The NVMe fault controller is repurposed for use in TDISP tests. - fn supports_tdisp(&mut self) -> Option<&mut dyn TdispHostDeviceTarget> { + fn supports_tdisp_host(&mut self) -> Option<&mut dyn TdispHostDeviceTarget> { tracing::debug!( supported = self.tdisp_interface.is_some(), "fault controller TDISP support in ChipsetDevice" @@ -523,9 +545,31 @@ impl ChipsetDevice for NvmeFaultController { } } +impl NvmeFaultController { + /// Whether the guest may reach the register BAR right now. + /// + /// On a TDISP device the register BAR holds TEE memory, so it stays dark + /// until the guest has attested the TDI and accepted the range. A + /// controller that is not acting as a TDISP device has no such restriction. + fn bar0_reachable(&self) -> bool { + self.tdisp_interface.is_none() || self.tdisp_mmio_ranges.is_unblocked(BAR0_RANGE_ID) + } +} + impl MmioIntercept for NvmeFaultController { fn mmio_read(&mut self, addr: u64, data: &mut [u8]) -> IoResult { match self.cfg_space.find_bar(addr) { + Some((0, _)) if !self.bar0_reachable() => { + // Read as an undecoded window rather than an error, so the + // caller sees the same all-ones a real device gives when + // nothing answers. + tracelimit::warn_ratelimited!( + addr, + "read of a TDISP register BAR whose range is blocked" + ); + data.fill(!0); + IoResult::Ok + } Some((0, offset)) => self.read_bar0(offset, data), Some((4, offset)) => { read_as_u32_chunks(offset, data, |offset| self.msix.read_u32(offset)); @@ -537,6 +581,13 @@ impl MmioIntercept for NvmeFaultController { fn mmio_write(&mut self, addr: u64, data: &[u8]) -> IoResult { match self.cfg_space.find_bar(addr) { + Some((0, _)) if !self.bar0_reachable() => { + tracelimit::warn_ratelimited!( + addr, + "write to a TDISP register BAR whose range is blocked" + ); + IoResult::Ok + } Some((0, offset)) => self.write_bar0(offset, data), Some((4, offset)) => { write_as_u32_chunks(offset, data, |offset, ty| match ty { diff --git a/vm/devices/storage/nvme_test/src/resolver.rs b/vm/devices/storage/nvme_test/src/resolver.rs index 8a0a06dbdcc..a1fff98a037 100644 --- a/vm/devices/storage/nvme_test/src/resolver.rs +++ b/vm/devices/storage/nvme_test/src/resolver.rs @@ -12,7 +12,6 @@ use nvme_resources::NamespaceDefinition; use nvme_resources::NvmeFaultControllerHandle; use pci_resources::ResolvePciDeviceHandleParams; use pci_resources::ResolvedPciDevice; -use tdisp::test_helpers::new_null_tdisp_interface; use thiserror::Error; use vm_resource::AsyncResolveResource; use vm_resource::ResolveError; @@ -55,15 +54,6 @@ impl AsyncResolveResource resource: NvmeFaultControllerHandle, input: ResolvePciDeviceHandleParams<'_>, ) -> Result { - // If TDISP tests are enabled, create a mock TDISP interface to expose - // for the device from OpenVMM. - let tdisp_interface: Option> = - if resource.enable_tdisp_tests { - Some(Box::new(new_null_tdisp_interface("fault-controller-test"))) - } else { - None - }; - let controller = NvmeFaultController::new( input.driver_source, input.dma_target.guest_memory().clone(), @@ -75,7 +65,7 @@ impl AsyncResolveResource subsystem_id: resource.subsystem_id, }, resource.fault_config, - tdisp_interface, + resource.enable_tdisp_tests, ); for NamespaceDefinition { nsid, diff --git a/vm/devices/storage/nvme_test/src/tdisp.rs b/vm/devices/storage/nvme_test/src/tdisp.rs new file mode 100644 index 00000000000..1cd8919d6d5 --- /dev/null +++ b/vm/devices/storage/nvme_test/src/tdisp.rs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The host side of TDISP for the fault controller, which OpenVMM repurposes as +//! an emulated TDISP device. +//! +//! Describes the controller's own BARs in the TDI interface report and records +//! which of those ranges TDISP has unblocked, so that the controller can refuse +//! access to a range the guest has not yet attested and accepted. + +use crate::BAR0_LEN; +use parking_lot::Mutex; +use std::collections::HashSet; +use std::sync::Arc; +use tdisp::TdispDeviceInterfaceInfo; +use tdisp::TdispGuestProtocolType; +use tdisp::TdispHostDeviceInterface; +use tdisp::TdispHostDeviceTargetEmulator; +use tdisp::TdispMmioRangeAction; +use tdisp::TdispReportType; +use tdisp::devicereport::TdiReportStruct; +use tdisp::devicereport::TdispTdiReportInterfaceInfo; +use tdisp::devicereport::TdispTdiReportMmioFlags; +use tdisp::devicereport::TdispTdiReportMmioInterfaceInfo; +use tdisp::devicereport::serialize_tdi_report; +use tdisp::test_helpers::TDISP_MOCK_DEVICE_ID; +use tdisp::test_helpers::TDISP_MOCK_GUEST_PROTOCOL; +use tdisp::test_helpers::TDISP_MOCK_SUPPORTED_FEATURES; + +/// The range id the controller's register BAR is reported under, which is also +/// its BAR index. +pub(crate) const BAR0_RANGE_ID: u16 = 0; + +/// The range id the MSI-X table and PBA BAR is reported under, which is also +/// its BAR index. +const MSIX_RANGE_ID: u16 = 4; + +/// The page size MMIO ranges are reported in. +const REPORT_PAGE_SIZE: u64 = 0x1000; + +/// Which of the device's MMIO ranges TDISP currently allows the guest to reach. +/// +/// Shared between the host TDISP interface, which is told when a range is +/// unblocked or blocked, and the controller, which honors it on every MMIO +/// access. A range starts blocked and stays that way until the guest has +/// attested the TDI and accepted the range into its context, and goes back to +/// blocked when the TDI is unbound. +#[derive(Clone, Default)] +pub struct TdispMmioRanges(Arc>>); + +impl TdispMmioRanges { + /// Whether the guest may currently reach `range_id`. + /// + /// * `range_id` - The range to check, which for this device is the BAR + /// index. + pub fn is_unblocked(&self, range_id: u16) -> bool { + self.0.lock().contains(&range_id) + } +} + +/// Builds the TDISP host target the fault controller exposes to a guest, along +/// with the record of unblocked ranges the controller reads on every MMIO +/// access. +/// +/// * `debug_device_id` - Identifies this device in TDISP traces. +/// * `msix_bar_len` - Length in bytes of the MSI-X BAR, which the interface +/// report describes as a non-TEE range. +pub(crate) fn new_tdisp_interface( + debug_device_id: &str, + msix_bar_len: u64, +) -> (TdispHostDeviceTargetEmulator, TdispMmioRanges) { + let ranges = TdispMmioRanges::default(); + let emulator = TdispHostDeviceTargetEmulator::new( + Arc::new(Mutex::new(FaultControllerTdispInterface { + ranges: ranges.clone(), + msix_bar_len, + })), + debug_device_id, + ); + (emulator, ranges) +} + +/// The platform actions a real TDISP host would perform, emulated for the fault +/// controller. +struct FaultControllerTdispInterface { + ranges: TdispMmioRanges, + msix_bar_len: u64, +} + +impl FaultControllerTdispInterface { + /// The report the device gives for itself, describing the two BARs it + /// implements. + fn interface_report(&self) -> TdiReportStruct { + TdiReportStruct { + interface_info: TdispTdiReportInterfaceInfo::new(), + msi_x_message_control: 0, + lnr_control: 0, + tph_control: 0, + mmio_interface_info: vec![ + // The register BAR is TEE memory, so it only becomes reachable + // once the guest has attested the TDI and accepted the range. + TdispTdiReportMmioInterfaceInfo { + first_4k_page_offset: 0, + num_4k_pages: (BAR0_LEN / REPORT_PAGE_SIZE) as u32, + flags: TdispTdiReportMmioFlags::new(), + range_id: BAR0_RANGE_ID, + }, + // The MSI-X table and PBA are emulated by the host and have no + // guest-private backing, so they are reported as non-TEE memory + // and stay reachable throughout. + TdispTdiReportMmioInterfaceInfo { + first_4k_page_offset: 0, + num_4k_pages: self.msix_bar_len.div_ceil(REPORT_PAGE_SIZE) as u32, + flags: TdispTdiReportMmioFlags::new() + .with_range_maps_msix_table(true) + .with_range_maps_msix_pba(true) + .with_is_non_tee_mem(true), + range_id: MSIX_RANGE_ID, + }, + ], + } + } +} + +impl TdispHostDeviceInterface for FaultControllerTdispInterface { + fn tdisp_negotiate_protocol( + &mut self, + _requested_guest_protocol: TdispGuestProtocolType, + ) -> anyhow::Result { + Ok(TdispDeviceInterfaceInfo { + guest_protocol_type: TDISP_MOCK_GUEST_PROTOCOL as i32, + supported_features: TDISP_MOCK_SUPPORTED_FEATURES, + tdisp_device_id: TDISP_MOCK_DEVICE_ID, + }) + } + + fn tdisp_bind_device(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + fn tdisp_start_device(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + fn tdisp_unbind_device(&mut self) -> anyhow::Result<()> { + // Every range the guest had accepted goes away with the binding, so + // the device stops answering on all of them. + let mut ranges = self.ranges.0.lock(); + tracing::info!( + unblocked_ranges = ranges.len(), + "fault controller TDISP unbind, blocking every MMIO range" + ); + ranges.clear(); + Ok(()) + } + + fn tdisp_get_device_report(&mut self, report_type: TdispReportType) -> anyhow::Result> { + match report_type { + // The wire format is a little-endian u64. + TdispReportType::GuestDeviceId => Ok(TDISP_MOCK_DEVICE_ID.to_le_bytes().to_vec()), + TdispReportType::InterfaceReport => Ok(serialize_tdi_report(&self.interface_report())), + other => anyhow::bail!("the fault controller has no {other:?} report to give"), + } + } + + fn tdisp_modify_mmio_range( + &mut self, + action: TdispMmioRangeAction, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + ) -> anyhow::Result<()> { + tracing::info!( + ?action, + range_id, + gpa_base, + range_len_bytes, + "fault controller TDISP MMIO range change" + ); + + match action { + TdispMmioRangeAction::UnblockMmioRange => { + self.ranges.0.lock().insert(range_id); + } + TdispMmioRangeAction::BlockMmioRange => { + self.ranges.0.lock().remove(&range_id); + } + TdispMmioRangeAction::Invalid => { + anyhow::bail!("invalid MMIO range action for range {range_id}") + } + } + + Ok(()) + } +} diff --git a/vm/devices/storage/nvme_test/src/tests.rs b/vm/devices/storage/nvme_test/src/tests.rs index f0a40aba7e5..2f6d8862428 100644 --- a/vm/devices/storage/nvme_test/src/tests.rs +++ b/vm/devices/storage/nvme_test/src/tests.rs @@ -3,4 +3,5 @@ mod controller_tests; mod shadow_doorbell_tests; +mod tdisp_tests; mod test_helpers; diff --git a/vm/devices/storage/nvme_test/src/tests/controller_tests.rs b/vm/devices/storage/nvme_test/src/tests/controller_tests.rs index 675b4ab3eea..461cea32305 100644 --- a/vm/devices/storage/nvme_test/src/tests/controller_tests.rs +++ b/vm/devices/storage/nvme_test/src/tests/controller_tests.rs @@ -54,7 +54,7 @@ fn instantiate_controller( subsystem_id: Guid::new_random(), }, fault_configuration, - None, + false, ); if let Some(intc) = int_controller { diff --git a/vm/devices/storage/nvme_test/src/tests/tdisp_tests.rs b/vm/devices/storage/nvme_test/src/tests/tdisp_tests.rs new file mode 100644 index 00000000000..5043b615215 --- /dev/null +++ b/vm/devices/storage/nvme_test/src/tests/tdisp_tests.rs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for the TDISP gate on the controller's register BAR. + +use super::test_helpers::TestNvmeMmioRegistration; +use crate::NvmeFaultController; +use crate::NvmeFaultControllerCaps; +use chipset_device::ChipsetDevice; +use chipset_device::pci::ByteEnabledDwordWrite; +use chipset_device::pci::PciConfigSpace; +use guestmem::GuestMemory; +use guid::Guid; +use mesh::CellUpdater; +use nvme_resources::fault::FaultConfiguration; +use pal_async::DefaultDriver; +use pal_async::async_test; +use pci_core::msi::MsiConnection; +use tdisp::Command; +use tdisp::GuestToHostCommand; +use tdisp::GuestToHostResponseExt; +use tdisp::TdispGuestOperationErrorCode; +use tdisp::TdispMmioRangeAction; +use tdisp::test_helpers::TDISP_MOCK_GUEST_PROTOCOL; +use tdisp_proto::TdispCommandRequestBind; +use tdisp_proto::TdispCommandRequestGetDeviceInterfaceInfo; +use tdisp_proto::TdispCommandRequestModifyMmioRange; +use tdisp_proto::TdispCommandRequestStartTdi; +use tdisp_proto::TdispCommandRequestUnbind; +use tdisp_proto::TdispGuestUnbindReason; +use vmcore::vm_task::SingleDriverBackend; +use vmcore::vm_task::VmTaskDriverSource; + +/// The base the tests program into BAR 0. +const BAR0_BASE: u64 = 0; + +/// Builds a controller that acts as an emulated TDISP device, with BAR 0 +/// programmed and MMIO decoding enabled. +fn tdisp_controller(driver: DefaultDriver, gm: &GuestMemory) -> NvmeFaultController { + let mut mmio_reg = TestNvmeMmioRegistration {}; + let vm_task_driver = &VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let msi_conn = MsiConnection::new(); + let mut controller = NvmeFaultController::new( + vm_task_driver, + gm.clone(), + &msi_conn.target(), + &mut mmio_reg, + NvmeFaultControllerCaps { + msix_count: 64, + max_io_queues: 64, + subsystem_id: Guid::new_random(), + }, + FaultConfiguration::new(CellUpdater::new(false).cell()), + true, + ); + + controller + .pci_cfg_write( + 0x10, + ByteEnabledDwordWrite::with_all_bytes_enabled(BAR0_BASE as u32), + ) + .unwrap(); + // Enable MMIO decoding and bus mastering. + controller + .pci_cfg_write(4, ByteEnabledDwordWrite::with_all_bytes_enabled(6)) + .unwrap(); + + controller +} + +/// Sends `command` to the controller's TDISP interface and asserts the host +/// accepted it. +fn send_tdisp(controller: &mut NvmeFaultController, command: Command) { + let response = controller + .supports_tdisp_host() + .expect("the controller is acting as a TDISP device") + .tdisp_handle_guest_command(GuestToHostCommand { + device_id: 0, + command: Some(command), + }) + .expect("the host handled the command"); + + assert_eq!( + response.error_code(), + Some(TdispGuestOperationErrorCode::Success), + "command failed: {response:?}" + ); +} + +/// Drives the TDI from Unlocked to Run, which is where a guest is allowed to +/// ask for its MMIO ranges. +fn attest(controller: &mut NvmeFaultController) { + send_tdisp( + controller, + Command::GetDeviceInterfaceInfo(TdispCommandRequestGetDeviceInterfaceInfo { + guest_protocol_type: TDISP_MOCK_GUEST_PROTOCOL as i32, + }), + ); + send_tdisp(controller, Command::Bind(TdispCommandRequestBind {})); + send_tdisp( + controller, + Command::StartTdi(TdispCommandRequestStartTdi {}), + ); +} + +/// Asks the host to unblock or block the register BAR's range. +fn modify_bar0_range(controller: &mut NvmeFaultController, action: TdispMmioRangeAction) { + send_tdisp( + controller, + Command::ModifyMmioRange(TdispCommandRequestModifyMmioRange { + action: action as i32, + range_id: 0, + gpa_base: BAR0_BASE, + range_len_bytes: crate::BAR0_LEN, + }), + ); +} + +/// Reads the first DWORD of the register BAR, which is the low half of the +/// NVMe `CAP` register. +fn read_bar0_start(controller: &mut NvmeFaultController) -> u32 { + let mut data = [0u8; 4]; + controller + .supports_mmio() + .unwrap() + .mmio_read(BAR0_BASE, &mut data) + .unwrap(); + u32::from_ne_bytes(data) +} + +#[async_test] +async fn register_bar_is_dark_until_the_range_is_unblocked(driver: DefaultDriver) { + let gm = GuestMemory::allocate(0x1000); + let mut controller = tdisp_controller(driver, &gm); + + // Nothing has been attested, so the BAR must not answer. + assert_eq!(read_bar0_start(&mut controller), !0); + + // Attestation alone is not enough: the range still has to be accepted. + attest(&mut controller); + assert_eq!(read_bar0_start(&mut controller), !0); + + // Once the range is unblocked the registers are readable, and `CAP` is + // never all ones. + modify_bar0_range(&mut controller, TdispMmioRangeAction::UnblockMmioRange); + let cap = read_bar0_start(&mut controller); + assert_ne!(cap, !0); + + // Blocking the range again closes the window. + modify_bar0_range(&mut controller, TdispMmioRangeAction::BlockMmioRange); + assert_eq!(read_bar0_start(&mut controller), !0); + + // And so does unbinding from a range that is still unblocked. + modify_bar0_range(&mut controller, TdispMmioRangeAction::UnblockMmioRange); + assert_eq!(read_bar0_start(&mut controller), cap); + send_tdisp( + &mut controller, + Command::Unbind(TdispCommandRequestUnbind { + unbind_reason: TdispGuestUnbindReason::Graceful as i32, + }), + ); + assert_eq!(read_bar0_start(&mut controller), !0); +} + +#[async_test] +async fn writes_are_dropped_while_the_range_is_blocked(driver: DefaultDriver) { + let gm = GuestMemory::allocate(0x1000); + let mut controller = tdisp_controller(driver, &gm); + + // The controller's interrupt mask register, which is writable once the + // range is open. + const INTMS: u64 = 0x0c; + let write = |controller: &mut NvmeFaultController, value: u32| { + controller + .supports_mmio() + .unwrap() + .mmio_write(BAR0_BASE + INTMS, &value.to_ne_bytes()) + .unwrap() + }; + + attest(&mut controller); + write(&mut controller, 0x1); + + // The write went nowhere, so the register still reads as its initial value + // once the range is opened. + modify_bar0_range(&mut controller, TdispMmioRangeAction::UnblockMmioRange); + let mut data = [0u8; 4]; + controller + .supports_mmio() + .unwrap() + .mmio_read(BAR0_BASE + INTMS, &mut data) + .unwrap(); + assert_eq!(u32::from_ne_bytes(data), 0); +} + +/// A controller that is not acting as a TDISP device has no gate at all. +#[async_test] +async fn a_plain_controller_answers_without_tdisp(driver: DefaultDriver) { + let gm = GuestMemory::allocate(0x1000); + let mut mmio_reg = TestNvmeMmioRegistration {}; + let vm_task_driver = &VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let msi_conn = MsiConnection::new(); + let mut controller = NvmeFaultController::new( + vm_task_driver, + gm.clone(), + &msi_conn.target(), + &mut mmio_reg, + NvmeFaultControllerCaps { + msix_count: 64, + max_io_queues: 64, + subsystem_id: Guid::new_random(), + }, + FaultConfiguration::new(CellUpdater::new(false).cell()), + false, + ); + controller + .pci_cfg_write( + 0x10, + ByteEnabledDwordWrite::with_all_bytes_enabled(BAR0_BASE as u32), + ) + .unwrap(); + controller + .pci_cfg_write(4, ByteEnabledDwordWrite::with_all_bytes_enabled(6)) + .unwrap(); + + assert!(controller.supports_tdisp_host().is_none()); + assert_ne!(read_bar0_start(&mut controller), !0); +} diff --git a/vm/devices/tdisp/src/devicereport.rs b/vm/devices/tdisp/src/devicereport.rs index 2f8926ace04..078a7173b82 100644 --- a/vm/devices/tdisp/src/devicereport.rs +++ b/vm/devices/tdisp/src/devicereport.rs @@ -4,11 +4,12 @@ use bitfield_struct::bitfield; use zerocopy::FromBytes; use zerocopy::Immutable; +use zerocopy::IntoBytes; use zerocopy::KnownLayout; /// PCI Express Base Specification Revision 6.3 Section 11.3.11 DEVICE_INTERFACE_REPORT #[bitfield(u16)] -#[derive(KnownLayout, FromBytes, Immutable)] +#[derive(KnownLayout, FromBytes, Immutable, IntoBytes)] pub struct TdispTdiReportInterfaceInfo { /// When 1, indicates that device firmware updates are not permitted /// while in CONFIG_LOCKED or RUN. When 0, indicates that firmware @@ -32,7 +33,7 @@ pub struct TdispTdiReportInterfaceInfo { /// PCI Express Base Specification Revision 6.3 Section 11.3.11 DEVICE_INTERFACE_REPORT #[bitfield(u16)] -#[derive(KnownLayout, FromBytes, Immutable)] +#[derive(KnownLayout, FromBytes, Immutable, IntoBytes)] pub struct TdispTdiReportMmioFlags { /// MSI-X Table – if the range maps MSI-X table. This must be reported only if locked by the LOCK_INTERFACE_REQUEST. pub range_maps_msix_table: bool, @@ -51,7 +52,8 @@ pub struct TdispTdiReportMmioFlags { } /// PCI Express Base Specification Revision 6.3 Section 11.3.11 DEVICE_INTERFACE_REPORT -#[derive(KnownLayout, FromBytes, Immutable, Clone, Debug)] +#[derive(KnownLayout, FromBytes, Immutable, IntoBytes, Clone, Debug)] +#[repr(C)] pub struct TdispTdiReportMmioInterfaceInfo { /// First 4K page with offset added pub first_4k_page_offset: u64, @@ -70,7 +72,7 @@ pub struct TdispTdiReportMmioInterfaceInfo { static_assertions::const_assert_eq!(size_of::(), 0x10); /// PCI Express Base Specification Revision 6.3 Section 11.3.11 DEVICE_INTERFACE_REPORT -#[derive(KnownLayout, FromBytes, Immutable, Debug)] +#[derive(KnownLayout, FromBytes, Immutable, IntoBytes, Debug)] #[repr(C)] struct TdiReportStructSerialized { pub interface_info: TdispTdiReportInterfaceInfo, @@ -85,10 +87,14 @@ struct TdiReportStructSerialized { static_assertions::const_assert_eq!(size_of::(), 0x10); +/// Serialized size of the fixed portion of a TDI interface report, which is the +/// whole report for a TDI that claims no MMIO ranges. +pub const TDI_REPORT_HEADER_SIZE: usize = size_of::(); + /// The deserialized form of a TDI interface report. #[derive(Debug)] pub struct TdiReportStruct { - /// See: `TdispTdiReportInterfaceInfo` + /// Capabilities and DMA/ATS/PRS behavior the TDI reports for itself. pub interface_info: TdispTdiReportInterfaceInfo, /// MSI-X capability message control register state. Must be Clear if @@ -134,3 +140,26 @@ pub fn deserialize_tdi_report(data: &[u8]) -> anyhow::Result { mmio_interface_info: read_mmio_elems.0.to_vec(), }) } + +/// Writes a TDI interface report out in the form a host reports it, so that an +/// emulated device can describe the MMIO ranges it claims. +/// +/// * `report` - The report to write out. Its MMIO ranges are written in the +/// order they appear, and their count is taken from the list rather than +/// being supplied separately. +pub fn serialize_tdi_report(report: &TdiReportStruct) -> Vec { + let header = TdiReportStructSerialized { + interface_info: report.interface_info, + _reserved0: 0, + msi_x_message_control: report.msi_x_message_control, + lnr_control: report.lnr_control, + tph_control: report.tph_control, + mmio_range_count: report.mmio_interface_info.len() as u32, + }; + + let mut buffer = header.as_bytes().to_vec(); + for range in &report.mmio_interface_info { + buffer.extend_from_slice(range.as_bytes()); + } + buffer +} diff --git a/vm/devices/tdisp/src/lib.rs b/vm/devices/tdisp/src/lib.rs index 17029d4bfa3..2419358473b 100644 --- a/vm/devices/tdisp/src/lib.rs +++ b/vm/devices/tdisp/src/lib.rs @@ -45,6 +45,8 @@ pub mod test_helpers; use anyhow::Context; use parking_lot::Mutex; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; pub use tdisp_proto::GuestToHostCommand; pub use tdisp_proto::GuestToHostCommandExt; @@ -53,6 +55,7 @@ pub use tdisp_proto::GuestToHostResponseExt; pub use tdisp_proto::TdispCommandResponseBind; pub use tdisp_proto::TdispCommandResponseGetDeviceInterfaceInfo; pub use tdisp_proto::TdispCommandResponseGetTdiReport; +pub use tdisp_proto::TdispCommandResponseModifyMmioRange; pub use tdisp_proto::TdispCommandResponseStartTdi; pub use tdisp_proto::TdispCommandResponseUnbind; pub use tdisp_proto::TdispDeviceInterfaceInfo; @@ -60,6 +63,7 @@ pub use tdisp_proto::TdispGuestOperationError; pub use tdisp_proto::TdispGuestOperationErrorCode; pub use tdisp_proto::TdispGuestProtocolType; pub use tdisp_proto::TdispGuestUnbindReason; +pub use tdisp_proto::TdispMmioRangeAction; pub use tdisp_proto::TdispReportType; pub use tdisp_proto::TdispTdiState; pub use tdisp_proto::guest_to_host_command::Command; @@ -94,17 +98,88 @@ pub trait TdispHostDeviceInterface: Send + Sync { /// Get a device interface report for the device. fn tdisp_get_device_report(&mut self, _report_type: TdispReportType) -> anyhow::Result>; + + /// Block or unblock an MMIO range in the guest's private context. + /// + /// The TDI is guaranteed to be Locked or Run; every other state is + /// rejected before this is reached. + /// + /// * `action` - Whether the range is being blocked or unblocked. Never + /// [`TdispMmioRangeAction::Invalid`]. + /// * `range_id` - Identifies which MMIO range is being modified (the PCI + /// BAR index). + /// * `gpa_base` - The guest physical base address of the range. + /// * `range_len_bytes` - The length of the range, in bytes. + fn tdisp_modify_mmio_range( + &mut self, + action: TdispMmioRangeAction, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + ) -> anyhow::Result<()>; } /// Trait added to host virtual devices to dispatch TDISP commands from guests. pub trait TdispHostDeviceTarget: Send + Sync { - /// Dispatch a TDISP command from a guest. + /// Dispatch a TDISP command received from a guest. fn tdisp_handle_guest_command( &mut self, _command: GuestToHostCommand, ) -> anyhow::Result; } +/// Isolation classification for a single VPCI resource (a BAR or DMA). +/// +/// This mirrors the `VPCI_RESOURCE_ISOLATION` values used on the wire by +/// `VpciMsgQueryIsolatedResources`, but is defined here so that +/// `chipset_device` and `tdisp` can expose an isolation-reporter trait +/// without taking a dependency on `vpci_protocol`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TdispResourceIsolation { + /// Host-visible and modifiable by the host. + Shared, + /// Host-inaccessible after TDI validation and private to the guest. + Private, + /// There is no resource here to classify. Either the BAR is invalid or part + /// of a 64-bit BAR. + Invalid, +} + +/// Classification of a device's BAR and DMA isolation for the VPCI +/// `QueryIsolatedResources` message, reported by the guest-facing VPCI +/// server. +#[derive(Debug, Clone, Copy)] +pub enum TdispIsolationReport { + /// The chipset device wraps a non-TDISP device. + NotTdispCapable, + /// The TDI is not in a state that it can respond to the isolation report + /// request. + NotReady, + /// The TDI has attested and parsed its report successfully. The inner + /// arrays give the six per-BAR classifications and the DMA classification. + /// Guaranteed to contain only `Shared` / `Private`. + Ready { + /// Per-BAR isolation. Index `i` corresponds to BAR `i`. + bars: [TdispResourceIsolation; 6], + /// DMA path isolation. + dma: TdispResourceIsolation, + }, + /// An internal paravisor error prevented reading the isolation state. The + /// paravisor should answer with an error status and log the event. + Error, +} + +/// Trait added to chipset devices that want to relay TDISP on behalf of the +/// guest-facing virtual bus. +pub trait TdispRelayedDeviceTarget: Send + Sync { + /// Return a snapshot of the current isolation state containing what + /// resources were isolated or shared by the TDISP relay and attestation + /// flow. + fn tdisp_isolation_report( + &mut self, + ) -> Pin + Send + 'static>>; +} + /// An emulator which runs the TDISP state machine for a synthetic device. pub struct TdispHostDeviceTargetEmulator { machine: TdispHostStateMachine, @@ -227,6 +302,43 @@ impl TdispHostDeviceTarget for TdispHostDeviceTargetEmulator { } } } + Some(Command::ModifyMmioRange(cmd)) => { + let action = TdispMmioRangeAction::from_i32(cmd.action); + + // `range_id` is a BAR index here, but not necessarily for all devices. + // Future platforms might support sub-BAR ranges by the TDISP spec. + match (action, u16::try_from(cmd.range_id)) { + (Some(action), Ok(range_id)) => { + let modify_res = self.machine.request_modify_mmio_range( + action, + range_id, + cmd.gpa_base, + cmd.range_len_bytes, + ); + if let Err(err) = modify_res { + error = err; + } else { + response = Some(Response::ModifyMmioRange( + TdispCommandResponseModifyMmioRange {}, + )); + } + } + (None, _) => { + tracing::error!( + action = cmd.action, + "ModifyMmioRange action is not a valid TdispMmioRangeAction" + ); + error = TdispGuestOperationError::InvalidGuestCommandId; + } + (_, Err(_)) => { + tracing::error!( + range_id = cmd.range_id, + "ModifyMmioRange range_id does not fit in a u16" + ); + error = TdispGuestOperationError::InvalidGuestCommandId; + } + } + } _ => { error = TdispGuestOperationError::InvalidGuestCommandId; } @@ -491,6 +603,20 @@ pub trait TdispGuestRequestInterface { /// `Locked` state will cause an error and unbind the device. fn request_start_tdi(&mut self) -> Result<(), TdispGuestOperationError>; + /// Block or unblock an MMIO range in the guest's private context. The + /// device must be in the `Locked` or `Run` state. + /// + /// Unlike the transitions above, requesting this in the wrong state returns + /// an error *without* unbinding the device: the guest may legitimately + /// retry as BARs are reprogrammed. This does not transition the device. + fn request_modify_mmio_range( + &mut self, + action: TdispMmioRangeAction, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + ) -> Result<(), TdispGuestOperationError>; + /// Retrieves the attestation report for the device when the device is in the `Locked` or /// `Run` state. The device resources will not be functional until the /// resources have been accepted into the guest while the device is in the @@ -498,6 +624,10 @@ pub trait TdispGuestRequestInterface { /// /// Attempting to retrieve the attestation report while the device is not in /// the `Locked` or `Run` state will cause an error and unbind the device. + /// + /// [`TdispReportType::GuestDeviceId`] is exempt from that state + /// requirement and can be requested in any state, since it identifies the + /// device rather than describing attestation state. fn request_attestation_report( &mut self, report_type: TdispReportType, @@ -524,7 +654,9 @@ impl TdispGuestRequestInterface for TdispHostStateMachine { &mut self, requested_guest_protocol: TdispGuestProtocolType, ) -> Result { - if self.guest_protocol_type != TdispGuestProtocolType::Invalid { + if self.guest_protocol_type != TdispGuestProtocolType::Invalid + && self.guest_protocol_type != requested_guest_protocol + { tracing::error!( "Guest tried to negotiate a protocol with the host while a protocol was already negotiated!" ); @@ -661,6 +793,60 @@ impl TdispGuestRequestInterface for TdispHostStateMachine { Ok(()) } + /// Block or unblock an MMIO range in the guest's private context. + /// + /// Unlike the other state-gated commands, a request in the wrong state is + /// treated as recoverable: it returns an error without unbinding, since the + /// guest may legitimately retry as BARs are reprogrammed. Does not + /// transition the TDI. + #[instrument(fields(device_id = %self.debug_device_id), skip(self))] + fn request_modify_mmio_range( + &mut self, + action: TdispMmioRangeAction, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + ) -> Result<(), TdispGuestOperationError> { + // Ensure the guest protocol is negotiated. + self.ensure_negotiated_protocol() + .map_err(|_| TdispGuestOperationError::InvalidDeviceState)?; + + if action == TdispMmioRangeAction::Invalid { + tracing::error!("ModifyMmioRange requested with an invalid action."); + return Err(TdispGuestOperationError::InvalidGuestCommandId); + } + + if self.current_state != TdispTdiState::Locked && self.current_state != TdispTdiState::Run { + tracing::error!( + current_state = %self.current_state, + "ModifyMmioRange called while device was not in the Locked or Run state." + ); + + return Err(TdispGuestOperationError::InvalidDeviceState); + } + + tracing::info!( + ?action, + range_id, + gpa_base, + range_len_bytes, + "Modifying MMIO range in the guest context" + ); + + let res = self + .host_interface + .lock() + .tdisp_modify_mmio_range(action, range_id, gpa_base, range_len_bytes) + .context("failed to call to modify MMIO range"); + + if let Err(e) = res { + tracing::error!("Failed to modify MMIO range: {e:?}"); + return Err(TdispGuestOperationError::HostFailedToProcessCommand); + } + + Ok(()) + } + #[instrument(fields(device_id = %self.debug_device_id), skip(self))] fn request_attestation_report( &mut self, @@ -670,7 +856,14 @@ impl TdispGuestRequestInterface for TdispHostStateMachine { self.ensure_negotiated_protocol() .map_err(|_| TdispGuestOperationError::InvalidDeviceState)?; - if self.current_state != TdispTdiState::Locked && self.current_state != TdispTdiState::Run { + // The guest device ID identifies the TDI to the host and is retrieved + // as a "report", though it does not need to be Locked or Run to retrieve the device id. + // + // All other report types require the TDI to be in the Locked or Run state. + if report_type != TdispReportType::GuestDeviceId + && self.current_state != TdispTdiState::Locked + && self.current_state != TdispTdiState::Run + { tracing::error!( "Request to retrieve attestation report called while device was not in Locked or Run state." ); @@ -716,7 +909,11 @@ impl TdispGuestRequestInterface for TdispHostStateMachine { // if the guest says it is unbinding due to a host-related error), the reason is discarded and InvalidGuestUnbindReason // is recorded in the unbind history. let reason = match reason { - TdispGuestUnbindReason::Graceful => TdispUnbindReason::GuestInitiated(reason), + TdispGuestUnbindReason::Graceful + | TdispGuestUnbindReason::DeviceTeardown + | TdispGuestUnbindReason::ResourceSetupFailure + | TdispGuestUnbindReason::AttestationFailure + | TdispGuestUnbindReason::StartupFailure => TdispUnbindReason::GuestInitiated(reason), _ => { tracing::error!( "Invalid guest unbind reason {} requested", diff --git a/vm/devices/tdisp/src/serialize_proto.rs b/vm/devices/tdisp/src/serialize_proto.rs index 952e9f1736c..8f1e4fd1cf6 100644 --- a/vm/devices/tdisp/src/serialize_proto.rs +++ b/vm/devices/tdisp/src/serialize_proto.rs @@ -11,6 +11,7 @@ use tdisp_proto::GuestToHostResponse; use tdisp_proto::TdispGuestOperationErrorCode; use tdisp_proto::TdispGuestProtocolType; use tdisp_proto::TdispGuestUnbindReason; +use tdisp_proto::TdispMmioRangeAction; use tdisp_proto::TdispReportType; use tdisp_proto::TdispTdiState; use tdisp_proto::guest_to_host_command::Command; @@ -84,6 +85,8 @@ pub fn validate_command(command: &GuestToHostCommand) -> anyhow::Result<()> { require_enum!(req.report_type, TdispReportType)?; } else if let Some(Command::Unbind(req)) = &command.command { require_enum!(req.unbind_reason, TdispGuestUnbindReason)?; + } else if let Some(Command::ModifyMmioRange(req)) = &command.command { + require_enum!(req.action, TdispMmioRangeAction)?; } Ok(()) diff --git a/vm/devices/tdisp/src/test_helpers.rs b/vm/devices/tdisp/src/test_helpers.rs index bd55ff27e0a..9c576e07603 100644 --- a/vm/devices/tdisp/src/test_helpers.rs +++ b/vm/devices/tdisp/src/test_helpers.rs @@ -3,10 +3,12 @@ use crate::TdispHostDeviceInterface; use crate::TdispHostDeviceTargetEmulator; +use crate::devicereport::TDI_REPORT_HEADER_SIZE; use parking_lot::Mutex; use std::sync::Arc; use tdisp_proto::TdispDeviceInterfaceInfo; use tdisp_proto::TdispGuestProtocolType; +use tdisp_proto::TdispMmioRangeAction; use tdisp_proto::TdispReportType; /// Guest protocol that will be negotiated by the mock device. @@ -44,11 +46,25 @@ impl TdispHostDeviceInterface for NullTdispHostInterface { Ok(()) } - fn tdisp_get_device_report( + fn tdisp_get_device_report(&mut self, report_type: TdispReportType) -> anyhow::Result> { + match report_type { + // The wire format is a little-endian u64. + TdispReportType::GuestDeviceId => Ok(TDISP_MOCK_DEVICE_ID.to_le_bytes().to_vec()), + // A TDI that claims no MMIO ranges, so the report is the fixed + // header with a range count of zero and nothing following it. + TdispReportType::InterfaceReport => Ok(vec![0; TDI_REPORT_HEADER_SIZE]), + other => anyhow::bail!("the mock device has no {other:?} report to give"), + } + } + + fn tdisp_modify_mmio_range( &mut self, - _report_type: TdispReportType, - ) -> anyhow::Result> { - Ok(vec![]) + _action: TdispMmioRangeAction, + _range_id: u16, + _gpa_base: u64, + _range_len_bytes: u64, + ) -> anyhow::Result<()> { + Ok(()) } } diff --git a/vm/devices/tdisp/src/tests/devicereport_tests.rs b/vm/devices/tdisp/src/tests/devicereport_tests.rs new file mode 100644 index 00000000000..05292dc8eb3 --- /dev/null +++ b/vm/devices/tdisp/src/tests/devicereport_tests.rs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Unit tests for reading and writing the TDI interface report. + +use crate::devicereport::TDI_REPORT_HEADER_SIZE; +use crate::devicereport::TdiReportStruct; +use crate::devicereport::TdispTdiReportInterfaceInfo; +use crate::devicereport::TdispTdiReportMmioFlags; +use crate::devicereport::TdispTdiReportMmioInterfaceInfo; +use crate::devicereport::deserialize_tdi_report; +use crate::devicereport::serialize_tdi_report; + +/// A report from a TDI with two MMIO ranges: a 64K range of TEE memory and a +/// 4K range mapping the MSI-X table. +fn two_range_report() -> TdiReportStruct { + TdiReportStruct { + interface_info: TdispTdiReportInterfaceInfo::new().with_generate_dma_without_pasid(true), + msi_x_message_control: 0x8001, + lnr_control: 0x1234, + tph_control: 0xabcd_0123, + mmio_interface_info: vec![ + TdispTdiReportMmioInterfaceInfo { + first_4k_page_offset: 0xf000_0000, + num_4k_pages: 16, + flags: TdispTdiReportMmioFlags::new(), + range_id: 0, + }, + TdispTdiReportMmioInterfaceInfo { + first_4k_page_offset: 0xf001_0000, + num_4k_pages: 1, + flags: TdispTdiReportMmioFlags::new() + .with_range_maps_msix_table(true) + .with_is_non_tee_mem(true), + range_id: 4, + }, + ], + } +} + +#[test] +fn report_survives_a_round_trip() { + let report = two_range_report(); + let parsed = deserialize_tdi_report(&serialize_tdi_report(&report)).unwrap(); + + assert_eq!( + parsed.interface_info.into_bits(), + report.interface_info.into_bits() + ); + assert_eq!(parsed.msi_x_message_control, report.msi_x_message_control); + assert_eq!(parsed.lnr_control, report.lnr_control); + assert_eq!(parsed.tph_control, report.tph_control); + assert_eq!(parsed.mmio_interface_info.len(), 2); + for (parsed, original) in parsed + .mmio_interface_info + .iter() + .zip(report.mmio_interface_info.iter()) + { + assert_eq!(parsed.first_4k_page_offset, original.first_4k_page_offset); + assert_eq!(parsed.num_4k_pages, original.num_4k_pages); + assert_eq!(parsed.flags.into_bits(), original.flags.into_bits()); + assert_eq!(parsed.range_id, original.range_id); + } +} + +#[test] +fn range_count_comes_from_the_range_list() { + let report = two_range_report(); + let buffer = serialize_tdi_report(&report); + + // The header, then one entry per range. + assert_eq!( + buffer.len(), + TDI_REPORT_HEADER_SIZE + 2 * size_of::() + ); +} + +#[test] +fn a_report_with_no_ranges_is_just_the_header() { + let report = TdiReportStruct { + interface_info: TdispTdiReportInterfaceInfo::new(), + msi_x_message_control: 0, + lnr_control: 0, + tph_control: 0, + mmio_interface_info: Vec::new(), + }; + + let buffer = serialize_tdi_report(&report); + assert_eq!(buffer.len(), TDI_REPORT_HEADER_SIZE); + assert!( + deserialize_tdi_report(&buffer) + .unwrap() + .mmio_interface_info + .is_empty() + ); +} diff --git a/vm/devices/tdisp/src/tests/endtoend_tests.rs b/vm/devices/tdisp/src/tests/endtoend_tests.rs index 6e8408dabbe..02af008a615 100644 --- a/vm/devices/tdisp/src/tests/endtoend_tests.rs +++ b/vm/devices/tdisp/src/tests/endtoend_tests.rs @@ -20,11 +20,13 @@ use tdisp_proto::GuestToHostCommand; use tdisp_proto::TdispCommandRequestBind; use tdisp_proto::TdispCommandRequestGetDeviceInterfaceInfo; use tdisp_proto::TdispCommandRequestGetTdiReport; +use tdisp_proto::TdispCommandRequestModifyMmioRange; use tdisp_proto::TdispCommandRequestStartTdi; use tdisp_proto::TdispCommandRequestUnbind; use tdisp_proto::TdispGuestOperationErrorCode; use tdisp_proto::TdispGuestProtocolType; use tdisp_proto::TdispGuestUnbindReason; +use tdisp_proto::TdispMmioRangeAction; use tdisp_proto::TdispReportType; use tdisp_proto::TdispTdiState; use tdisp_proto::guest_to_host_command::Command; @@ -110,6 +112,26 @@ fn get_tdi_report_cmd(device_id: u64, report_type: TdispReportType) -> GuestToHo } } +fn modify_mmio_range_cmd( + device_id: u64, + action: TdispMmioRangeAction, + range_id: u32, + gpa_base: u64, + range_len_bytes: u64, +) -> GuestToHostCommand { + GuestToHostCommand { + device_id, + command: Some(Command::ModifyMmioRange( + TdispCommandRequestModifyMmioRange { + action: action as i32, + range_id, + gpa_base, + range_len_bytes, + }, + )), + } +} + // ── Tests ───────────────────────────────────────────────────────────────────── // ── Protocol negotiation ────────────────────────────────────────────────────── @@ -360,7 +382,7 @@ fn test_rebind_after_full_lifecycle() { unbind_cmd(DEVICE_ID, TdispGuestUnbindReason::Graceful), ); - // Second cycle — device must behave identically + // Second cycle: device must behave identically let resp = dispatch_roundtrip(&mut mock.emulator, bind_cmd(DEVICE_ID)); assert_eq!(resp.result, TdispGuestOperationErrorCode::Success as i32); assert_eq!(resp.tdi_state_before, TdispTdiState::Unlocked as i32); @@ -372,3 +394,169 @@ fn test_rebind_after_full_lifecycle() { assert_eq!(resp.tdi_state_after, TdispTdiState::Run as i32); assert_eq!(*mock.last_call.lock(), Some(LastCall::StartDevice)); } + +// ── ModifyMmioRange ──────────────────────────────────────────────────── + +/// The command is accepted in Locked, forwards the guest's values to the host +/// interface unchanged, and does not transition the TDI. +#[test] +fn test_modify_mmio_range_succeeds_in_locked() { + let mut mock = new_emulator(); + const DEVICE_ID: u64 = 3; + + dispatch_roundtrip(&mut mock.emulator, negotiate_cmd(DEVICE_ID)); + dispatch_roundtrip(&mut mock.emulator, bind_cmd(DEVICE_ID)); + + let resp = dispatch_roundtrip( + &mut mock.emulator, + modify_mmio_range_cmd( + DEVICE_ID, + TdispMmioRangeAction::UnblockMmioRange, + 2, + 0xe000_0000, + 0x10_0000, + ), + ); + assert_eq!(resp.result, TdispGuestOperationErrorCode::Success as i32); + assert_eq!(resp.tdi_state_before, TdispTdiState::Locked as i32); + assert_eq!(resp.tdi_state_after, TdispTdiState::Locked as i32); + assert!(matches!(resp.response, Some(Response::ModifyMmioRange(_)))); + assert_eq!( + *mock.last_call.lock(), + Some(LastCall::ModifyMmioRange { + action: TdispMmioRangeAction::UnblockMmioRange, + range_id: 2, + gpa_base: 0xe000_0000, + range_len_bytes: 0x10_0000, + }) + ); +} + +/// The command is equally valid in Run, and again leaves the state alone. +#[test] +fn test_modify_mmio_range_succeeds_in_run() { + let mut mock = new_emulator(); + const DEVICE_ID: u64 = 3; + + dispatch_roundtrip(&mut mock.emulator, negotiate_cmd(DEVICE_ID)); + dispatch_roundtrip(&mut mock.emulator, bind_cmd(DEVICE_ID)); + dispatch_roundtrip(&mut mock.emulator, start_tdi_cmd(DEVICE_ID)); + + let resp = dispatch_roundtrip( + &mut mock.emulator, + modify_mmio_range_cmd( + DEVICE_ID, + TdispMmioRangeAction::BlockMmioRange, + 0, + 0xf000_0000, + 0x2_0000_0000, + ), + ); + assert_eq!(resp.result, TdispGuestOperationErrorCode::Success as i32); + assert_eq!(resp.tdi_state_before, TdispTdiState::Run as i32); + assert_eq!(resp.tdi_state_after, TdispTdiState::Run as i32); + assert_eq!( + *mock.last_call.lock(), + Some(LastCall::ModifyMmioRange { + action: TdispMmioRangeAction::BlockMmioRange, + range_id: 0, + gpa_base: 0xf000_0000, + // Larger than u32::MAX, confirming the 64-bit length survives the + // whole path. + range_len_bytes: 0x2_0000_0000, + }) + ); +} + +/// In Unlocked the command is rejected with InvalidDeviceState. Unlike +/// StartTdi and GetTdiReport, this must NOT tear the TDI down: the state is +/// still Unlocked afterwards and the host interface was never called. +#[test] +fn test_modify_mmio_range_fails_in_unlocked_without_teardown() { + let mut mock = new_emulator(); + const DEVICE_ID: u64 = 3; + + dispatch_roundtrip(&mut mock.emulator, negotiate_cmd(DEVICE_ID)); + assert_eq!(*mock.last_call.lock(), Some(LastCall::NegotiateProtocol)); + + let resp = dispatch_roundtrip( + &mut mock.emulator, + modify_mmio_range_cmd( + DEVICE_ID, + TdispMmioRangeAction::UnblockMmioRange, + 1, + 0xd000_0000, + 0x1000, + ), + ); + assert_eq!( + resp.result, + TdispGuestOperationErrorCode::InvalidDeviceState as i32 + ); + assert_eq!(resp.tdi_state_before, TdispTdiState::Unlocked as i32); + assert_eq!(resp.tdi_state_after, TdispTdiState::Unlocked as i32); + + // No unbind was issued and the host never saw the range. + assert_eq!(*mock.last_call.lock(), Some(LastCall::NegotiateProtocol)); + + // The TDI is still usable: a bind right after the rejected modify works. + let resp = dispatch_roundtrip(&mut mock.emulator, bind_cmd(DEVICE_ID)); + assert_eq!(resp.result, TdispGuestOperationErrorCode::Success as i32); + assert_eq!(resp.tdi_state_after, TdispTdiState::Locked as i32); +} + +/// A range_id that does not fit in a u16 is rejected before the state machine +/// is consulted, since the wire type is wider than the BAR index it carries. +#[test] +fn test_modify_mmio_range_rejects_oversized_range_id() { + let mut mock = new_emulator(); + const DEVICE_ID: u64 = 3; + + dispatch_roundtrip(&mut mock.emulator, negotiate_cmd(DEVICE_ID)); + dispatch_roundtrip(&mut mock.emulator, bind_cmd(DEVICE_ID)); + + let resp = dispatch_roundtrip( + &mut mock.emulator, + modify_mmio_range_cmd( + DEVICE_ID, + TdispMmioRangeAction::UnblockMmioRange, + u32::MAX, + 0xd000_0000, + 0x1000, + ), + ); + assert_eq!( + resp.result, + TdispGuestOperationErrorCode::InvalidGuestCommandId as i32 + ); + assert_eq!(resp.tdi_state_after, TdispTdiState::Locked as i32); + assert_eq!(*mock.last_call.lock(), Some(LastCall::BindDevice)); +} + +/// The Invalid action is rejected in a valid state, so the guest cannot use a +/// zero-valued action to reach the host interface. +#[test] +fn test_modify_mmio_range_rejects_invalid_action() { + let mut mock = new_emulator(); + const DEVICE_ID: u64 = 3; + + dispatch_roundtrip(&mut mock.emulator, negotiate_cmd(DEVICE_ID)); + dispatch_roundtrip(&mut mock.emulator, bind_cmd(DEVICE_ID)); + + let resp = dispatch_roundtrip( + &mut mock.emulator, + modify_mmio_range_cmd( + DEVICE_ID, + TdispMmioRangeAction::Invalid, + 1, + 0xd000_0000, + 0x1000, + ), + ); + assert_eq!( + resp.result, + TdispGuestOperationErrorCode::InvalidGuestCommandId as i32 + ); + assert_eq!(resp.tdi_state_after, TdispTdiState::Locked as i32); + assert_eq!(*mock.last_call.lock(), Some(LastCall::BindDevice)); +} diff --git a/vm/devices/tdisp/src/tests/mocks.rs b/vm/devices/tdisp/src/tests/mocks.rs index 413d9373939..a9da22cc36e 100644 --- a/vm/devices/tdisp/src/tests/mocks.rs +++ b/vm/devices/tdisp/src/tests/mocks.rs @@ -12,6 +12,7 @@ use parking_lot::Mutex; use std::sync::Arc; use tdisp_proto::TdispDeviceInterfaceInfo; use tdisp_proto::TdispGuestProtocolType; +use tdisp_proto::TdispMmioRangeAction; use tdisp_proto::TdispReportType; #[derive(Debug, PartialEq, Clone)] @@ -21,6 +22,12 @@ pub enum LastCall { StartDevice, UnbindDevice, GetDeviceReport(TdispReportType), + ModifyMmioRange { + action: TdispMmioRangeAction, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + }, } pub struct TrackingHostInterface { @@ -44,16 +51,33 @@ impl TdispHostDeviceInterface for TrackingHostInterface { Ok(()) } + fn tdisp_modify_mmio_range( + &mut self, + action: TdispMmioRangeAction, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + ) -> anyhow::Result<()> { + *self.last_call.lock() = Some(LastCall::ModifyMmioRange { + action, + range_id, + gpa_base, + range_len_bytes, + }); + Ok(()) + } + /// Returns a mock report buffer that is configurable. fn tdisp_get_device_report(&mut self, report_type: TdispReportType) -> anyhow::Result> { - if report_type == TdispReportType::InterfaceReport { - *self.last_call.lock() = Some(LastCall::GetDeviceReport(report_type)); - Ok(self.report_buffer.lock().clone()) - } else { - *self.last_call.lock() = Some(LastCall::GetDeviceReport(report_type)); - Err(anyhow::anyhow!( - "mock test checks only that InterfaceReport is requested" - )) + *self.last_call.lock() = Some(LastCall::GetDeviceReport(report_type)); + match report_type { + TdispReportType::InterfaceReport => Ok(self.report_buffer.lock().clone()), + // The guest device ID is served in any TDI state, so the mock has + // to answer it too. The wire format is a little-endian u64. + TdispReportType::GuestDeviceId => Ok(TDISP_MOCK_DEVICE_ID.to_le_bytes().to_vec()), + _ => Err(anyhow::anyhow!( + "mock test checks only InterfaceReport and GuestDeviceId requests" + )), } } diff --git a/vm/devices/tdisp/src/tests/mod.rs b/vm/devices/tdisp/src/tests/mod.rs index 1873336c960..a05d9748da6 100644 --- a/vm/devices/tdisp/src/tests/mod.rs +++ b/vm/devices/tdisp/src/tests/mod.rs @@ -6,6 +6,9 @@ /// Mocks for the host interface and the emulator. pub mod mocks; +/// Unit tests for reading and writing the TDI interface report. +pub mod devicereport_tests; + /// Unit tests for serialization and deserialization of TDISP guest-to-host commands and responses. pub mod serialize_tests; diff --git a/vm/devices/tdisp/src/tests/serialize_tests.rs b/vm/devices/tdisp/src/tests/serialize_tests.rs index 26132ab637a..a920dbe0760 100644 --- a/vm/devices/tdisp/src/tests/serialize_tests.rs +++ b/vm/devices/tdisp/src/tests/serialize_tests.rs @@ -15,16 +15,19 @@ use tdisp_proto::GuestToHostResponse; use tdisp_proto::TdispCommandRequestBind; use tdisp_proto::TdispCommandRequestGetDeviceInterfaceInfo; use tdisp_proto::TdispCommandRequestGetTdiReport; +use tdisp_proto::TdispCommandRequestModifyMmioRange; use tdisp_proto::TdispCommandRequestStartTdi; use tdisp_proto::TdispCommandRequestUnbind; use tdisp_proto::TdispCommandResponseBind; use tdisp_proto::TdispCommandResponseGetDeviceInterfaceInfo; use tdisp_proto::TdispCommandResponseGetTdiReport; +use tdisp_proto::TdispCommandResponseModifyMmioRange; use tdisp_proto::TdispCommandResponseStartTdi; use tdisp_proto::TdispCommandResponseUnbind; use tdisp_proto::TdispDeviceInterfaceInfo; use tdisp_proto::TdispGuestOperationErrorCode; use tdisp_proto::TdispGuestUnbindReason; +use tdisp_proto::TdispMmioRangeAction; use tdisp_proto::TdispReportType; use tdisp_proto::TdispTdiState; use tdisp_proto::guest_to_host_command::Command; @@ -119,8 +122,57 @@ fn test_command_unbind_roundtrip() { assert_eq!(req.unbind_reason, TdispGuestUnbindReason::Graceful as i32); } -// ── Command validation-failure tests ───────────────────────────────────────── +#[test] +fn test_command_modify_mmio_range_roundtrip() { + for action in [ + TdispMmioRangeAction::UnblockMmioRange, + TdispMmioRangeAction::BlockMmioRange, + ] { + let cmd = GuestToHostCommand { + device_id: 42, + command: Some(Command::ModifyMmioRange( + TdispCommandRequestModifyMmioRange { + action: action as i32, + range_id: 3, + gpa_base: 0xf000_0000, + range_len_bytes: 0x2_0000_0000, + }, + )), + }; + let bytes = serialize_command(&cmd); + let got = deserialize_command(&bytes).unwrap(); + assert_eq!(got.device_id, 42); + let Some(Command::ModifyMmioRange(req)) = got.command else { + panic!("expected ModifyMmioRange command"); + }; + assert_eq!(req.action, action as i32); + assert_eq!(req.range_id, 3); + assert_eq!(req.gpa_base, 0xf000_0000); + // Deliberately larger than u32::MAX to pin the 64-bit length on the wire. + assert_eq!(req.range_len_bytes, 0x2_0000_0000); + } +} +#[test] +fn test_deserialize_command_rejects_invalid_mmio_range_action() { + // An action integer outside the enum must fail validation, matching the + // other enum-carrying commands. + let cmd = GuestToHostCommand { + device_id: 42, + command: Some(Command::ModifyMmioRange( + TdispCommandRequestModifyMmioRange { + action: 99, + range_id: 0, + gpa_base: 0, + range_len_bytes: 0x1000, + }, + )), + }; + let bytes = serialize_command(&cmd); + assert!(deserialize_command(&bytes).is_err()); +} + +// ── Command validation-failure tests ───────────────────────────────────────── #[test] fn test_deserialize_command_rejects_missing_command_field() { // A GuestToHostCommand with no oneof variant set must be rejected. @@ -167,6 +219,16 @@ fn test_response_unbind_roundtrip() { assert!(matches!(got.response, Some(Response::Unbind(_)))); } +#[test] +fn test_response_modify_mmio_range_roundtrip() { + let resp = make_response(Response::ModifyMmioRange( + TdispCommandResponseModifyMmioRange {}, + )); + let bytes = serialize_response(&resp); + let got = deserialize_response(&bytes).unwrap(); + assert!(matches!(got.response, Some(Response::ModifyMmioRange(_)))); +} + #[test] fn test_response_get_device_interface_info_roundtrip() { let resp = make_response(Response::GetDeviceInterfaceInfo( diff --git a/vm/devices/tdisp/src/tests/statemachine_tests.rs b/vm/devices/tdisp/src/tests/statemachine_tests.rs index 6fc84bc033d..3f626536ce1 100644 --- a/vm/devices/tdisp/src/tests/statemachine_tests.rs +++ b/vm/devices/tdisp/src/tests/statemachine_tests.rs @@ -213,6 +213,26 @@ fn test_attestation_report_from_unlocked_fails_and_resets_to_unlocked() { assert_eq!(*mock.last_call.lock(), Some(LastCall::UnbindDevice)); } +#[test] +fn test_guest_device_id_report_from_unlocked_succeeds() { + let mut mock = new_machine(); + + // GuestDeviceId identifies the device rather than describing attestation + // state, so it is exempt from the Locked/Run requirement that every other + // report type is subject to. + let report = mock + .machine + .request_attestation_report(TdispReportType::GuestDeviceId) + .unwrap(); + assert!(!report.is_empty()); + // The exemption must not disturb the state machine or trigger an unbind. + assert_eq!(mock.machine.state(), TdispTdiState::Unlocked); + assert_eq!( + *mock.last_call.lock(), + Some(LastCall::GetDeviceReport(TdispReportType::GuestDeviceId)) + ); +} + #[test] fn test_attestation_report_invalid_type_from_locked_returns_error_without_state_change() { let mut mock = new_machine(); diff --git a/vm/devices/tdisp_proto/src/lib.rs b/vm/devices/tdisp_proto/src/lib.rs index 182d8bf3eba..f7c16591e49 100644 --- a/vm/devices/tdisp_proto/src/lib.rs +++ b/vm/devices/tdisp_proto/src/lib.rs @@ -21,6 +21,8 @@ pub use errorcode::*; use crate::guest_to_host_command::Command; use crate::guest_to_host_response::Response; +use inspect::Inspect; +use std::fmt::Display; include!(concat!(env!("OUT_DIR"), "/tdisp.rs")); @@ -37,6 +39,7 @@ impl GuestToHostCommandExt for GuestToHostCommand { Some(Command::StartTdi(_)) => Some("StartTdi"), Some(Command::Unbind(_)) => Some("Unbind"), Some(Command::GetTdiReport(_)) => Some("GetTdiReport"), + Some(Command::ModifyMmioRange(_)) => Some("ModifyMmioRange"), None => None, } } @@ -93,6 +96,15 @@ impl GuestToHostResponseVariant for TdispCommandResponseUnbind { } } +impl GuestToHostResponseVariant for TdispCommandResponseModifyMmioRange { + fn from_response_variant(response: Response) -> Option { + match response { + Response::ModifyMmioRange(r) => Some(r), + _ => None, + } + } +} + /// Provides helper methods for common operations on [`GuestToHostResponse`]. pub trait GuestToHostResponseExt { /// Returns the error code of the response, if any. @@ -110,6 +122,12 @@ pub trait GuestToHostResponseExt { /// let bind = resp.response::()?; /// ``` fn response(self) -> Result; + + /// Returns the TDI state of the device before the command was processed, if available. + fn tdi_state_before_enum(&self) -> Option; + + /// Returns the TDI state of the device after the command was processed, if available. + fn tdi_state_after_enum(&self) -> Option; } impl GuestToHostResponseExt for GuestToHostResponse { @@ -124,10 +142,39 @@ impl GuestToHostResponseExt for GuestToHostResponse { Some(Response::StartTdi(_)) => Some("StartTdi"), Some(Response::Unbind(_)) => Some("Unbind"), Some(Response::GetTdiReport(_)) => Some("GetTdiReport"), + Some(Response::ModifyMmioRange(_)) => Some("ModifyMmioRange"), None => None, } } + fn tdi_state_before_enum(&self) -> Option { + let old_state = TdispTdiState::from_i32(self.tdi_state_before); + + // These are the only valid states the host can advertise. + if old_state != Some(TdispTdiState::Unlocked) + && old_state != Some(TdispTdiState::Locked) + && old_state != Some(TdispTdiState::Run) + { + return None; + } + + old_state + } + + fn tdi_state_after_enum(&self) -> Option { + let new_state = TdispTdiState::from_i32(self.tdi_state_after); + + // These are the only valid states the host can advertise. + if new_state != Some(TdispTdiState::Unlocked) + && new_state != Some(TdispTdiState::Locked) + && new_state != Some(TdispTdiState::Run) + { + return None; + } + + new_state + } + fn response(self) -> Result { match self.error_code() { Some(TdispGuestOperationErrorCode::Success) => { @@ -141,3 +188,21 @@ impl GuestToHostResponseExt for GuestToHostResponse { } } } + +impl Display for TdispTdiState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let state_str = match self { + TdispTdiState::Uninitialized => "Uninitialized", + TdispTdiState::Unlocked => "Unlocked", + TdispTdiState::Locked => "Locked", + TdispTdiState::Run => "Run", + }; + write!(f, "{}", state_str) + } +} + +impl Inspect for TdispTdiState { + fn inspect(&self, req: inspect::Request<'_>) { + req.value(format!("{}", self)); + } +} diff --git a/vm/devices/tdisp_proto/src/tdisp.proto b/vm/devices/tdisp_proto/src/tdisp.proto index ca371a49e21..f7f5cbaf02a 100644 --- a/vm/devices/tdisp_proto/src/tdisp.proto +++ b/vm/devices/tdisp_proto/src/tdisp.proto @@ -77,6 +77,23 @@ enum TdispGuestUnbindReason { // The guest requested to unbind the device because the device is being // detached. TDISP_GUEST_UNBIND_REASON_GRACEFUL = 1; + + // The paravisor is tearing down the VPCI channel for this device (e.g. + // guest eject, host-initiated eject, or relay shutdown). Sent + // automatically from `RelayedDevice::remove` so the host returns the + // TDI to Unlocked. + TDISP_GUEST_UNBIND_REASON_DEVICE_TEARDOWN = 2; + + // The paravisor is unbinding the TDI because post-attestation resource + // setup failed during activation (for example, unblocking MMIO or DMA). + TDISP_GUEST_UNBIND_REASON_RESOURCE_SETUP_FAILURE = 3; + + // The paravisor is unbinding the TDI because of a failure during the + // attestation of the device. + TDISP_GUEST_UNBIND_REASON_ATTESTATION_FAILURE = 4; + + // The paravisor failed during initial setup/bind of the TDI. + TDISP_GUEST_UNBIND_REASON_STARTUP_FAILURE = 5; } // Represents a type of report that can be requested from the TDI (VF). @@ -101,6 +118,18 @@ enum TdispReportType { TDISP_REPORT_TYPE_IS_REGISTERED = 5; } +// The operation a TdispCommandRequestModifyMmioRange applies to an MMIO range. +enum TdispMmioRangeAction { + // Invalid action. All usages of this action should be treated as an error. + TDISP_MMIO_RANGE_ACTION_INVALID = 0; + + // Unblock the range, making it accessible to the guest's private context. + TDISP_MMIO_RANGE_ACTION_UNBLOCK_MMIO_RANGE = 1; + + // Block the range, reversing a previous unblock. + TDISP_MMIO_RANGE_ACTION_BLOCK_MMIO_RANGE = 2; +} + // Represents the type of CVM technology the guest requests to use. // As new features are added by respective OEMs, this enum will be extended // if these protocols change. @@ -137,6 +166,7 @@ message GuestToHostCommand { TdispCommandRequestGetTdiReport get_tdi_report = 4; TdispCommandRequestStartTdi start_tdi = 5; TdispCommandRequestUnbind unbind = 6; + TdispCommandRequestModifyMmioRange modify_mmio_range = 7; } } @@ -160,6 +190,7 @@ message GuestToHostResponse { TdispCommandResponseGetTdiReport get_tdi_report = 6; TdispCommandResponseStartTdi start_tdi = 7; TdispCommandResponseUnbind unbind = 8; + TdispCommandResponseModifyMmioRange modify_mmio_range = 9; } } @@ -194,6 +225,23 @@ message TdispCommandRequestUnbind { TdispGuestUnbindReason unbind_reason = 1; } +// Block or unblock an MMIO range in the guest's private context. The TDI must +// be in the Locked or Run state; any other state is an error. Does not +// transition the TDI. +message TdispCommandRequestModifyMmioRange { + // The operation to apply to the range. + TdispMmioRangeAction action = 1; + + // Identifies which MMIO range is being modified. This is the PCI BAR index. + uint32 range_id = 2; + + // The guest physical base address of the range. + uint64 gpa_base = 3; + + // The length of the range, in bytes. + uint64 range_len_bytes = 4; +} + // ---------------------------------------------------------------------------- // Response messages (one per command) // ---------------------------------------------------------------------------- @@ -222,6 +270,9 @@ message TdispCommandResponseStartTdi {} // Response to TdispCommandRequestUnbind. Carries no payload. message TdispCommandResponseUnbind {} +// Response to TdispCommandRequestModifyMmioRange. Carries no payload. +message TdispCommandResponseModifyMmioRange {} + // ---------------------------------------------------------------------------- // Shared info structs // ---------------------------------------------------------------------------- diff --git a/workers/chipset_device_worker/src/worker.rs b/workers/chipset_device_worker/src/worker.rs index 49d445bffae..3bf0e7512fa 100644 --- a/workers/chipset_device_worker/src/worker.rs +++ b/workers/chipset_device_worker/src/worker.rs @@ -146,7 +146,7 @@ impl Worker for RemoteChipsetDeviceWorker { if device.supports_acknowledge_pic_interrupt().is_some() || device.supports_handle_eoi().is_some() || device.supports_line_interrupt_target().is_some() - || device.supports_tdisp().is_some() + || device.supports_tdisp_host().is_some() { anyhow::bail!("remote device requires unimplemented functionality"); }