From d8dd34360a598a63d80640aec2c7e9e04d01f7df Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Thu, 10 Sep 2026 15:08:17 -0700 Subject: [PATCH 01/31] tdisp_proto: add the ModifyMmioRange command and TDI state accessors --- vm/devices/tdisp_proto/src/lib.rs | 45 ++++++++++++++++++++++++++ vm/devices/tdisp_proto/src/tdisp.proto | 44 +++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/vm/devices/tdisp_proto/src/lib.rs b/vm/devices/tdisp_proto/src/lib.rs index 182d8bf3eba..f0f3a53bb87 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,19 @@ 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 { + TdispTdiState::from_i32(self.tdi_state_before) + } + + fn tdi_state_after_enum(&self) -> Option { + TdispTdiState::from_i32(self.tdi_state_after) + } + fn response(self) -> Result { match self.error_code() { Some(TdispGuestOperationErrorCode::Success) => { @@ -141,3 +168,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..654db625727 100644 --- a/vm/devices/tdisp_proto/src/tdisp.proto +++ b/vm/devices/tdisp_proto/src/tdisp.proto @@ -77,6 +77,16 @@ 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; } // Represents a type of report that can be requested from the TDI (VF). @@ -101,6 +111,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 +159,7 @@ message GuestToHostCommand { TdispCommandRequestGetTdiReport get_tdi_report = 4; TdispCommandRequestStartTdi start_tdi = 5; TdispCommandRequestUnbind unbind = 6; + TdispCommandRequestModifyMmioRange modify_mmio_range = 7; } } @@ -160,6 +183,7 @@ message GuestToHostResponse { TdispCommandResponseGetTdiReport get_tdi_report = 6; TdispCommandResponseStartTdi start_tdi = 7; TdispCommandResponseUnbind unbind = 8; + TdispCommandResponseModifyMmioRange modify_mmio_range = 9; } } @@ -194,6 +218,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 +263,9 @@ message TdispCommandResponseStartTdi {} // Response to TdispCommandRequestUnbind. Carries no payload. message TdispCommandResponseUnbind {} +// Response to TdispCommandRequestModifyMmioRange. Carries no payload. +message TdispCommandResponseModifyMmioRange {} + // ---------------------------------------------------------------------------- // Shared info structs // ---------------------------------------------------------------------------- From ff643ff394293cede0080d41e52f2ab7eee92336 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Thu, 10 Sep 2026 15:08:17 -0700 Subject: [PATCH 02/31] tdisp: add the isolation reporter trait and MMIO range block and unblock --- vm/devices/tdisp/src/devicereport.rs | 2 +- vm/devices/tdisp/src/lib.rs | 214 +++++++++++++++++- vm/devices/tdisp/src/serialize_proto.rs | 3 + vm/devices/tdisp/src/test_helpers.rs | 11 + vm/devices/tdisp/src/tests/endtoend_tests.rs | 190 +++++++++++++++- vm/devices/tdisp/src/tests/mocks.rs | 40 +++- vm/devices/tdisp/src/tests/serialize_tests.rs | 64 +++++- .../tdisp/src/tests/statemachine_tests.rs | 20 ++ 8 files changed, 530 insertions(+), 14 deletions(-) diff --git a/vm/devices/tdisp/src/devicereport.rs b/vm/devices/tdisp/src/devicereport.rs index 2f8926ace04..09f58c8c2fe 100644 --- a/vm/devices/tdisp/src/devicereport.rs +++ b/vm/devices/tdisp/src/devicereport.rs @@ -88,7 +88,7 @@ static_assertions::const_assert_eq!(size_of::(), 0x10 /// 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 diff --git a/vm/devices/tdisp/src/lib.rs b/vm/devices/tdisp/src/lib.rs index 17029d4bfa3..52ce5fa5fb9 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,6 +98,25 @@ 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. @@ -105,6 +128,68 @@ pub trait TdispHostDeviceTarget: Send + Sync { ) -> 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, bounce-buffered. + Shared, + /// Host-inaccessible after TDI validation; backed by guest-private memory. + Private, + /// There is no resource here to classify: the device does not implement + /// this BAR, which includes the upper half of a 64-bit BAR since that is + /// not addressable in its own right, or the paravisor holds no interface + /// report for the device at all. A BAR the device does have but the report + /// omits is `Shared`, not this. + 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. The paravisor should + /// answer the guest query with all `Shared` + `SUCCESS`, matching the + /// host VSP's behavior for non-confidential VMs. + NotTdispCapable, + /// The TDI is not in the Run state, or is in Run but no resource has + /// been unblocked yet. The paravisor should answer with an error + /// status; the guest may retry later. + NotReady, + /// The TDI is in Run and resources have been unblocked. 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 report their VPCI +/// resource-isolation state on behalf of the guest-facing VPCI server. +pub trait TdispIsolationReporter: Send + Sync { + /// Return a snapshot of the current isolation state, suitable for + /// populating a `VpciIsolatedResourcesReply`. + /// + /// To retrieve the report, this may need to drive a fresh attestation cycle + /// (Unlocked -> Locked -> Run -> cached report -> Unlocked) before + /// answering. To avoid forcing callers to hold a sync device guard across + /// the await, this returns a `'static` boxed future. + 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 +312,43 @@ impl TdispHostDeviceTarget for TdispHostDeviceTargetEmulator { } } } + Some(Command::ModifyMmioRange(cmd)) => { + let action = TdispMmioRangeAction::from_i32(cmd.action); + + // `range_id` is a BAR index; the wire widens it to u32 because + // protobuf has no 16-bit type. + 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 +613,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 +634,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 +664,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 +803,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 +866,15 @@ 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 rather than describing any + // attestation state, and the guest needs it before it can address the + // device in platform calls (for example to build a TDX Connect + // FUNCTION_ID ahead of the bind). Allow it in any state; every other + // report describes state that only exists once the TDI is Locked. + 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 +920,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 => { + 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..2ad8ecba26f 100644 --- a/vm/devices/tdisp/src/test_helpers.rs +++ b/vm/devices/tdisp/src/test_helpers.rs @@ -7,6 +7,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; /// Guest protocol that will be negotiated by the mock device. @@ -50,6 +51,16 @@ impl TdispHostDeviceInterface for NullTdispHostInterface { ) -> anyhow::Result> { Ok(vec![]) } + + fn tdisp_modify_mmio_range( + &mut self, + _action: TdispMmioRangeAction, + _range_id: u16, + _gpa_base: u64, + _range_len_bytes: u64, + ) -> anyhow::Result<()> { + Ok(()) + } } /// Implements the host side of the TDISP interface for a mock device that does nothing. 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/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(); From 7d1e839e8ae63e70ad6dba32dca74a1127891839 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Thu, 10 Sep 2026 15:08:18 -0700 Subject: [PATCH 03/31] chipset_device: add an accessor for a device's TDISP isolation reporter --- vm/chipset_device/src/lib.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/vm/chipset_device/src/lib.rs b/vm/chipset_device/src/lib.rs index 5762ed65769..af13dee3e0f 100644 --- a/vm/chipset_device/src/lib.rs +++ b/vm/chipset_device/src/lib.rs @@ -68,6 +68,18 @@ pub trait ChipsetDevice: 'static + Send /* see DEVNOTE before adding bounds */ { fn supports_tdisp(&mut self) -> Option<&mut dyn tdisp::TdispHostDeviceTarget> { None } + + /// Optionally returns a trait object which can report the device's VPCI + /// resource-isolation state for `VpciMsgQueryIsolatedResources`. + /// + /// This is implemented only by the OpenHCL VPCI relay's + /// `RelayedVpciDevice`. Emulated devices return `None` by default (and + /// therefore trigger the "no reporter" reply path on the guest-facing VPCI + /// server). + #[inline(always)] + fn supports_tdisp_isolation(&mut self) -> Option<&mut dyn tdisp::TdispIsolationReporter> { + None + } } /// Shared by `mmio` and `pio` From 4024b26673349c46a200e5b27532c0067cc46c36 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Thu, 10 Sep 2026 15:08:18 -0700 Subject: [PATCH 04/31] chipset_device_resources: forward the TDISP isolation reporter accessor --- vm/chipset_device_resources/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vm/chipset_device_resources/src/lib.rs b/vm/chipset_device_resources/src/lib.rs index f8ba3fa07a0..f1196e87e31 100644 --- a/vm/chipset_device_resources/src/lib.rs +++ b/vm/chipset_device_resources/src/lib.rs @@ -177,6 +177,10 @@ impl ChipsetDevice for ErasedChipsetDevice { fn supports_tdisp(&mut self) -> Option<&mut dyn tdisp::TdispHostDeviceTarget> { self.0.supports_tdisp() } + + fn supports_tdisp_isolation(&mut self) -> Option<&mut dyn tdisp::TdispIsolationReporter> { + self.0.supports_tdisp_isolation() + } } impl ProtobufSaveRestore for ErasedChipsetDevice { From 8535728036f400c165f5c79816c2f56185e12966 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Thu, 10 Sep 2026 15:08:18 -0700 Subject: [PATCH 05/31] vpci_protocol: add the isolated resources query and the RB version --- vm/devices/pci/vpci_protocol/src/lib.rs | 79 ++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/vm/devices/pci/vpci_protocol/src/lib.rs b/vm/devices/pci/vpci_protocol/src/lib.rs index c64cb08e660..fe95ecdce5e 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,45 @@ 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. If `status == +/// Status::SUCCESS`, each entry in `bar_isolation` is one of `SHARED`, +/// `PRIVATE`, or `INVALID`. `INVALID` is used for BAR slots that are not part +/// of the device's known BAR ID set (including the upper halves of 64-bit BARs, +/// which are not tracked independently). `dma_isolation` is always `SHARED` or +/// `PRIVATE` on success. On any non-success status, all BAR entries are +/// `INVALID`. +#[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); From 49a91f2df2c123896f081790184052ea4d02f002 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Thu, 10 Sep 2026 15:08:30 -0700 Subject: [PATCH 06/31] openhcl_tdisp: add the resource validation interface and no-op validator --- Cargo.lock | 3 + openhcl/openhcl_tdisp/Cargo.toml | 3 + openhcl/openhcl_tdisp/src/lib.rs | 248 +++++++++++++++++++++++++++++- openhcl/openhcl_tdisp/src/noop.rs | 196 +++++++++++++++++++++++ 4 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 openhcl/openhcl_tdisp/src/noop.rs diff --git a/Cargo.lock b/Cargo.lock index c7876681c41..bea71b39ed6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5590,8 +5590,11 @@ name = "openhcl_tdisp" version = "0.0.0" dependencies = [ "anyhow", + "hvdef", + "parking_lot", "tdisp", "tdisp_proto", + "tracing", ] [[package]] diff --git a/openhcl/openhcl_tdisp/Cargo.toml b/openhcl/openhcl_tdisp/Cargo.toml index 73617f827c2..38795c593a7 100644 --- a/openhcl/openhcl_tdisp/Cargo.toml +++ b/openhcl/openhcl_tdisp/Cargo.toml @@ -7,10 +7,13 @@ rust-version.workspace = true edition.workspace = true [dependencies] +hvdef.workspace = true +parking_lot.workspace = true tdisp.workspace = true tdisp_proto.workspace = true anyhow.workspace = true +tracing.workspace = true [lints] workspace = true diff --git a/openhcl/openhcl_tdisp/src/lib.rs b/openhcl/openhcl_tdisp/src/lib.rs index 0cb6351e994..9f728a7e66d 100644 --- a/openhcl/openhcl_tdisp/src/lib.rs +++ b/openhcl/openhcl_tdisp/src/lib.rs @@ -8,7 +8,7 @@ //! //! See: `vm/devices/tdisp` for more information. -use std::future::Future; +pub mod noop; // 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,16 +26,23 @@ 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 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; @@ -54,6 +61,7 @@ pub trait TdispVirtualDeviceInterface: Send + Sync { /// Get the TDISP interface info for the device. fn tdisp_get_device_interface_info( &self, + target_protocol: TdispGuestProtocolType, ) -> impl Future> + Send; /// Bind the device to the current partition and transition to Locked. @@ -84,6 +92,183 @@ pub trait TdispVirtualDeviceInterface: Send + Sync { &self, reason: TdispGuestUnbindReason, ) -> impl Future> + Send; + + /// Tell the host to block an MMIO range, reversing a previous unblock. The + /// TDI must be Locked or Run. + /// + /// This only notifies the host over the VPCI channel. It does not perform + /// the platform-side block, which is a separate step on the resource + /// validation interface. + /// + /// * `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. + fn tdisp_host_block_mmio_range( + &self, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + ) -> impl Future> + Send; + + /// Tell the host to unblock an MMIO range, so its view matches the + /// platform's. The TDI must be Locked or Run. + /// + /// This only notifies the host over the VPCI channel. It does not perform + /// the platform-side unblock, which is a separate step on the resource + /// validation interface. + /// + /// * `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. + fn tdisp_host_unblock_mmio_range( + &self, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + ) -> impl Future> + Send; +} + +/// Provides platform-specific methods for unblocking device resources after +/// TDISP attestation. +/// +/// After a device has been attested and placed in the Run state via +/// [`TdispVirtualDeviceInterface`], platform-specific operations are required +/// to make device resources (MMIO, DMA) accessible to the guest. This trait +/// abstracts those operations. +pub trait TdispResourceValidationInterface: Send + Sync { + /// 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 a VPCI ID). + fn on_pre_bind(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()>; + + /// Called 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; the + /// device is left bound and the caller unbinds it as part of clearing the + /// failed attestation. + /// + /// * `target_vtl` - The VTL the device is being attested for. + /// * `device_id` - Identifies the TDI device (not a VPCI ID). + fn on_pre_start(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()>; + + /// 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; the device is left running and the caller unbinds it as + /// part of clearing the failed attestation. + /// + /// * `target_vtl` - The VTL the device is being attested for. + /// * `device_id` - Identifies the TDI device (not a VPCI 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 is an independent answer to the same question the host answers in + /// its command responses, so a caller can hold the two against each other + /// rather than having to take the host's word for it. + /// + /// Returns `Ok(None)` on a platform that cannot report the state, which + /// leaves the caller with nothing to compare 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 has no equivalent here. + /// + /// * `target_vtl` - The VTL the device is assigned to. + /// * `device_id` - Identifies the TDI device (not a VPCI ID). + fn get_tsm_tdi_state( + &self, + target_vtl: Vtl, + device_id: u16, + ) -> anyhow::Result>; + + /// Record the TDI interface report for a device. + /// + /// Called during attestation once the report has been fetched, and again on + /// each re-attest. Platforms that must resolve report-relative identifiers + /// keep what they need from it; the rest ignore it. + /// + /// * `device_id` - Identifies the TDI device (not a VPCI ID). + /// * `report` - The device's TDI interface report. + fn tdisp_set_tdi_report(&self, device_id: u16, report: &TdiReportStruct); + + /// Drop the TDI interface report recorded for a device. + /// + /// Called during unbind, so that nothing kept from the old report outlives + /// the attestation it came from. + /// + /// * `device_id` - Identifies the TDI device (not a VPCI ID). + fn tdisp_clear_tdi_report(&self, device_id: u16); + + /// Unblock MMIO access for a specific resource on the device. + /// + /// * `target_vtl` - The VTL to unblock the range for. + /// * `device_id` - Identifies the TDI device (not a VPCI 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>>; + + /// Unblock DMA access for the device's IOMMU domain. + /// + /// * `target_vtl` - The VTL to unblock DMA for. + /// * `device_id` - Identifies the TDI device (not a VPCI ID). + fn tdisp_unblock_dma(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()>; + + /// 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 a VPCI 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>>; + + /// 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 a VPCI ID). + fn tdisp_block_dma(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()>; } /// Creates a [`GuestToHostCommand`] for the `GetDeviceInterfaceInfo` command. @@ -139,3 +324,64 @@ pub fn new_unbind_command(device_id: u64, reason: TdispGuestUnbindReason) -> Gue })), } } + +/// Creates a [`GuestToHostCommand`] for the `ModifyMmioRange` command with the +/// `UnblockMmioRange` action. +/// +/// `range_id` is widened to a `u32` because protobuf has no 16-bit type; the +/// host narrows it back before dispatching. +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. +/// +/// `range_id` is widened to a `u32` because protobuf has no 16-bit type; the +/// host narrows it back before dispatching. +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..fd27cd45630 --- /dev/null +++ b/openhcl/openhcl_tdisp/src/noop.rs @@ -0,0 +1,196 @@ +// 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 a VPCI 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. 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(()) + } +} From 1b60aaf4443abdfffdd6eefd56c5da849d6319fd Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Thu, 10 Sep 2026 15:08:30 -0700 Subject: [PATCH 07/31] vpci_client: drive the TDISP attestation flow and report device isolation --- Cargo.lock | 2 + vm/devices/pci/vpci_client/Cargo.toml | 2 + vm/devices/pci/vpci_client/src/lib.rs | 521 +++++--- vm/devices/pci/vpci_client/src/tdisp.rs | 1581 +++++++++++++++++++++++ vm/devices/pci/vpci_client/src/tests.rs | 450 ++++++- 5 files changed, 2368 insertions(+), 188 deletions(-) create mode 100644 vm/devices/pci/vpci_client/src/tdisp.rs diff --git a/Cargo.lock b/Cargo.lock index bea71b39ed6..6a859b8f9b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11025,6 +11025,7 @@ dependencies = [ "futures-concurrency", "guestmem", "guid", + "hvdef", "inspect", "mesh", "openhcl_tdisp", @@ -11038,6 +11039,7 @@ dependencies = [ "thiserror 2.0.16", "tracelimit", "tracing", + "virt", "vmbus_async", "vmbus_channel", "vmbus_ring", 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..42257c68ecc 100644 --- a/vm/devices/pci/vpci_client/src/lib.rs +++ b/vm/devices/pci/vpci_client/src/lib.rs @@ -9,8 +9,11 @@ //! resource and power management, like Linux does, as opposed to the //! message-based interface, like Windows does. +pub mod tdisp; mod tests; +pub use tdisp::VpciClientTdispState; + use anyhow::Context; use chipset_device::pci::ByteEnabledDwordRead; use chipset_device::pci::ByteEnabledDwordWrite; @@ -23,21 +26,8 @@ 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::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"); @@ -194,6 +186,17 @@ pub trait MemoryAccess: Send { /// The amount of MMIO space required by the VPCI bus. pub const MMIO_SIZE: u64 = 0x2000; +struct InspectableAsyncMutex(futures::lock::Mutex); + +impl Inspect for InspectableAsyncMutex { + fn inspect(&self, req: inspect::Request<'_>) { + match self.0.try_lock() { + Some(guard) => guard.inspect(req), + None => req.value("locked"), + } + } +} + /// A device description, which represents a VPCI device available on a bus. #[derive(Inspect)] pub struct VpciDeviceDescription { @@ -227,6 +230,7 @@ pub struct VpciDevice { #[inspect(hex, iter_by_index)] /// RAO == Read As One bar_rao: [u32; 6], + tdisp: InspectableAsyncMutex, } #[derive(Inspect)] @@ -350,7 +354,13 @@ 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, + vtom: u64, + target_vtl: hvdef::Vtl, + ) -> anyhow::Result<(VpciDevice, VpciDeviceEject)> { let requirements = self .req .call_failable(WorkerRequest::QueryResourceRequirements, self.id) @@ -371,6 +381,16 @@ impl VpciDeviceDescription { eject, } = self; + let tdisp = VpciClientTdispState::new( + req.clone(), + id.slot.into_bits() as u64, + resource_validator, + isolation_type, + vtom, + 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 +425,7 @@ impl VpciDeviceDescription { numa_node, serial_num, dev, + tdisp: InspectableAsyncMutex(futures::lock::Mutex::new(tdisp)), }; Ok((device, VpciDeviceEject(eject))) @@ -536,6 +557,192 @@ 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, as if the guest + /// had written a STATUS_COMMAND value with MMIO and bus-master + /// disabled. Called on the unbind path + /// ([`Self::tdisp_on_device_deactivate`]) to explicitly leave the command + /// register in the expected off state after the device is unbound. + /// + /// This is not safety critical, this is for cleanup purposes only. + 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. + /// + /// Then writes `command_value` to the command register, which flushes the + /// shadowed BARs to the host device, and only after that notifies TDISP + /// about each currently active MMIO BAR via + /// [`tdisp::VpciClientTdispState::tdisp_on_mmio_reconfigured`]. The BARs + /// must be mapped for the guest before the unblock operations run. + /// + /// Returns `true` only if attestation and every BAR notification succeeded + /// completely. On `false` the command register is left off: either it was + /// never written, or [`Self::tdisp_fail_attestation`] cleared it. + pub async fn tdisp_on_device_activate(&self, command_value: ByteEnabledDwordWrite) -> bool { + use tdisp::TdispVpciAttestationInterface; + + tracing::info!( + "tdisp_on_device_activate: guest enabled MMIO, attesting device and notifying TDISP of MMIO bars" + ); + + // Attest the device. + let attest_result = match self.tdisp_query_capabilities().await { + Ok(interface_info) => self + .tdisp_attest_device(interface_info) + .await + .context("tdisp_attest_device failed"), + Err(err) => Err(err.context("tdisp_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" + ); + self.tdisp_fail_attestation().await; + 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_fail_attestation` 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_fail_attestation().await; + return false; + } + } + + tracing::info!( + "tdisp_on_device_activate: attestation and MMIO unblock complete, device activated" + ); + + true + } + + /// Common teardown for any failure during the MMIO-enable activation + /// path: post-Bind attestation failure, per-BAR unblock failure, or + /// similar. Unbinds the device, clears the command register, and cleans up + /// resources. + async fn tdisp_fail_attestation(&self) { + use openhcl_tdisp::TdispGuestUnbindReason; + use openhcl_tdisp::TdispVirtualDeviceInterface; + + tracing::error!( + "tdisp_fail_attestation: unbinding TDI back to Unlocked due to attestation failure" + ); + + let unbind_result = self + .tdisp_unbind(TdispGuestUnbindReason::ResourceSetupFailure) + .await; + + if let Err(unbind_err) = &unbind_result { + tracing::warn!( + error = unbind_err.as_ref() as &dyn std::error::Error, + "tdisp_fail_attestation: unbind failed" + ); + } + + // 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. Mirrors + /// [`Self::tdisp_on_device_activate`] for the disable edge. + /// + /// Called when STATUS_COMMAND transitions MMIO from enabled to disabled. If + /// the TDI is not in `Run` (e.g. `Uninitialized`, `Unlocked`, or `Locked`), + /// this is a no-op. + pub async fn tdisp_on_device_deactivate(&self) { + use openhcl_tdisp::TdispGuestUnbindReason; + use openhcl_tdisp::TdispVirtualDeviceInterface; + use tdisp::TdispVpciAttestationInterface; + + let state = self.tdisp_tdi_state().await; + + tracing::info!( + ?state, + "tdisp_on_device_deactivate: guest disabled MMIO, unbinding TDI back to Unlocked" + ); + + if let Err(err) = self.tdisp_unbind(TdispGuestUnbindReason::Graceful).await { + tracing::warn!( + error = &*err as &dyn std::error::Error, + "tdisp_on_device_deactivate: unbind failed" + ); + } + + // Always clear the command register explicitly on the unbind path so the + // device is left in the expected off state regardless of the value the + // guest wrote or the outcome of the unbind. + self.clear_command_register(); + } } #[derive(Error, Debug)] @@ -624,173 +831,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 +1447,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..56724bb5bce --- /dev/null +++ b/vm/devices/pci/vpci_client/src/tdisp.rs @@ -0,0 +1,1581 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TDISP interface implementation for VPCI devices. + +use anyhow::Context; +use hvdef::Vtl; +use inspect::Inspect; +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::TdispCommandResponseModifyMmioRange; +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 tdisp::TdispIsolationReport; +use tdisp::TdispResourceIsolation; +use tdisp::TdispTdiState; +use tdisp::devicereport::TdiReportStruct; +use virt::IsolationType; +use vpci_protocol::MAX_VPCI_TDISP_COMMAND_SIZE; +use vpci_protocol::SlotNumber; + +use super::VpciDevice; +use super::WorkerRequest; +use openhcl_tdisp::TdispResourceValidationInterface; +use std::collections::HashSet; +use std::sync::Arc; + +#[derive(Inspect)] +struct VpciClientTdispMutableState { + tdi_state: TdispTdiState, + #[inspect(debug)] + guest_device_id: TdispDeviceId, + /// Map of BAR ID to the range the guest configured and how it was + /// classified. Populated whenever a BAR is reconfigured in the `Run` + /// state, both for ranges that were unblocked and for ones that were + /// deliberately skipped. Used during unbind to call `tdisp_block_mmio` + /// with the same parameters, for the ranges that need it, so private + /// pages can be flipped back to shared. 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. + #[inspect(iter_by_index)] + intercepted_bars: HashSet, +} + +/// Identifies the TDI device, as distinct from the VPCI slot id. +/// +/// A TDI only has an id once attestation has fetched one from the host, and it +/// loses it again on unbind, so "no id" is a real state of the device rather +/// than a particular id value. +#[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. + /// Platform interfaces address a real TDI, so they take the inner value. + 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. `Private` means it was passed to + /// `tdisp_unblock_mmio` and must be blocked back on unbind; `Shared` + /// means it was deliberately skipped and there is nothing to undo. + #[inspect(debug)] + isolation: TdispResourceIsolation, +} + +impl VpciClientTdispMutableState { + 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; + } +} + +/// TDISP state for a VPCI device. +#[derive(Inspect)] +pub struct VpciClientTdispState { + #[inspect(skip)] + worker_req: mesh::Sender, + // The device ID if the VPCI channel. Not to be confused with the guest device ID returned by the host in TDISP reports. + vpci_device_id: u64, + isolation_type: IsolationType, + vtom: u64, + #[inspect(debug)] + target_vtl: Vtl, + mutable_state: VpciClientTdispMutableState, + /// Which BAR indices the device actually implements, so a BAR the TDI + /// report omits can be told apart from a slot that is not a BAR at all. + /// Fixed for the life of the device. + #[inspect(iter_by_index)] + present_bars: [bool; 6], + /// Platform hooks used to gate attestation and unblock device resources. + /// Required: a device driven through the TDISP flow must always have a + /// validator, so that no platform silently skips validation. Platforms with + /// nothing to do use `noop::TdispNoopResourceValidator`. + #[inspect(skip)] + resource_validator: Arc, +} + +/// Manages the TDISP protocol for a TDISP-capable VPCI device. +impl VpciClientTdispState { + pub(super) fn new( + worker_req: mesh::Sender, + device_id: u64, + resource_validator: Arc, + isolation_type: IsolationType, + vtom: u64, + target_vtl: Vtl, + present_bars: [bool; 6], + ) -> Self { + Self { + worker_req, + vpci_device_id: device_id, + mutable_state: VpciClientTdispMutableState { + 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, + vtom, + target_vtl, + present_bars, + 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`. + /// + /// The host's reported state and the firmware's state are two independent + /// answers to the same question, and at these points in the flow there is + /// exactly one answer that is correct. 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 leaves only the host's answer to check. + /// + /// * `expected` - The state the TDI must be in. + /// * `device_id` - Identifies the TDI device (not a VPCI ID). When no TDI + /// has been identified there is nothing to ask the firmware about, and + /// only the host's answer is checked. + fn require_tdi_state( + &self, + expected: TdispTdiState, + device_id: TdispDeviceId, + ) -> anyhow::Result<()> { + 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) + .context("require_tdi_state: failed to read the TDI state from the firmware")?, + None => 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" + ), + } + + Ok(()) + } + + pub(super) async fn send_tdisp_command( + &mut self, + payload: GuestToHostCommand, + ) -> anyhow::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 + .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.vpci_device_id 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") + })?; + + // 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 => tracing::warn!("host did not return valid 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_tdisp_command {:?} failed because host responded with an error: {}", + payload.type_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. + pub async fn tdisp_get_device_interface_info( + &mut self, + target_protocol: TdispGuestProtocolType, + ) -> anyhow::Result { + let res = self + .send_tdisp_command(openhcl_tdisp::new_get_device_interface_info_command( + self.vpci_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. + pub async fn tdisp_bind_interface(&mut self) -> anyhow::Result<()> { + let state_before = self.tdi_state(); + let res = self + .send_tdisp_command(openhcl_tdisp::new_bind_command(self.vpci_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. + pub async fn tdisp_start_device(&mut self) -> anyhow::Result<()> { + let state_before = self.tdi_state(); + let res = self + .send_tdisp_command(openhcl_tdisp::new_start_tdi_command(self.vpci_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. + pub async fn tdisp_get_device_report( + &mut self, + report_type: &TdispReportType, + ) -> anyhow::Result> { + let res = self + .send_tdisp_command(openhcl_tdisp::new_get_tdi_report_command( + self.vpci_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. + pub async fn tdisp_get_tdi_report(&mut self) -> anyhow::Result { + let buffer = self + .tdisp_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!( + vpci_device_id = self.vpci_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!( + vpci_device_id = self.vpci_device_id, + ?report, + "tdisp_get_tdi_report: decoded TDI interface report" + ); + + // Break the MMIO ranges out individually: these decide each BAR's + // PRIVATE/SHARED classification and whether it is auto-marked + // intercepted, so they are what needs reading at a glance. + for range in &report.mmio_interface_info { + tracing::info!( + "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) + } + + /// Fetch the device's TDI device id, which identifies the TDI in platform + /// calls. Available in any TDI state, unlike the other reports. + pub async fn tdisp_get_tdi_device_id(&mut 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())) + } + + /// 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. + pub async fn tdisp_host_block_mmio_range( + &mut self, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + ) -> anyhow::Result<()> { + self.send_modify_mmio_range( + openhcl_tdisp::new_block_mmio_range_command( + self.vpci_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. + pub async fn tdisp_host_unblock_mmio_range( + &mut self, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + ) -> anyhow::Result<()> { + self.send_modify_mmio_range( + openhcl_tdisp::new_unblock_mmio_range_command( + self.vpci_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_tdisp_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. + /// + /// * `reason` - Reported to the host to explain why the TDI is unbinding. + pub async fn tdisp_unbind(&mut self, reason: TdispGuestUnbindReason) -> anyhow::Result<()> { + // Flip all unblocked MMIO ranges and DMA back to shared before we tell + // the host to unbind the TDI. This is best-effort: a failure here is + // logged but doesn't abort the unbind. A new attestation won't proceed + // if all resources were not successfully torn down. + let validator = self.resource_validator.clone(); + let device_id = self.mutable_state.guest_device_id; + + // The teardown below addresses a real TDI through the platform. Without + // an id there is nothing attested to tear down: no range was ever + // unblocked, DMA was never unblocked, and no report was ever recorded. + 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 => {} + } + + let block_mmio_res = validator + .tdisp_block_mmio( + Vtl::Vtl2, + 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" + ); + } else { + // Tell the host only once the platform actually blocked the + // range, so the host's view never runs ahead of the platform's. + // Best-effort, like the block above. + if let Err(e) = self + .tdisp_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" + ); + } + + // 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(Vtl::Vtl2, raw_device_id) { + tracing::error!( + raw_device_id, + error = &*e as &dyn std::error::Error, + "tdisp_unbind: failed to re-block DMA" + ); + } 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_tdisp_command(openhcl_tdisp::new_unbind_command( + self.vpci_device_id, + reason, + )) + .await?; + + if let Err(err) = res.response::() { + return Err(anyhow::anyhow!("error response in tdisp_unbind: {err}")); + } + + // The TDI must be back in Unlocked, and the firmware has to agree that + // it actually came back rather than the host merely saying so. + self.require_tdi_state(TdispTdiState::Unlocked, device_id)?; + + Ok(()) + } + + /// 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. + /// + /// The result is not retained, so each call queries the device afresh. + pub 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 + .tdisp_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. + pub async fn attest(&mut self, interface_info: TdispDeviceInterfaceInfo) -> anyhow::Result<()> { + 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.tdisp_unbind(TdispGuestUnbindReason::Graceful) + .await + .context("tdisp_attest_device: failed to unbind device from running state")?; + } + + // 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() + { + anyhow::bail!( + "tdisp_attest_device: failed to clear existing attestation state, cannot proceed with new attestation" + ); + } + + // Request the guest device ID before binding so the pre-bind and + // pre-start validator hooks can identify the TDI they are gating. + let guest_device_id = self + .tdisp_get_tdi_device_id() + .await + .context("tdisp_attest_device: failed to get TDI device ID before binding device")?; + + // Platforms require a u16 device ID even though the report returns a + // u64. Ensure the returned device ID 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")?; + + self.resource_validator + .on_pre_bind(self.target_vtl, guest_device_id_u16) + .context("tdisp_attest_device: pre-bind validation failed")?; + + self.tdisp_bind_interface() + .await + .context("tdisp_attest_device: failed to bind device interface")?; + + self.require_tdi_state( + TdispTdiState::Locked, + TdispDeviceId::Valid(guest_device_id_u16), + ) + .context("tdisp_attest_device: failed to confirm the TDI is Locked after the bind")?; + + self.resource_validator + .on_pre_start(self.target_vtl, guest_device_id_u16) + .context("tdisp_attest_device: pre-start validation failed")?; + + self.tdisp_start_device() + .await + .context("tdisp_attest_device: failed to start device")?; + + self.require_tdi_state( + TdispTdiState::Run, + TdispDeviceId::Valid(guest_device_id_u16), + ) + .context("tdisp_attest_device: failed to confirm the TDI is in Run after the start")?; + + self.resource_validator + .on_post_start(self.target_vtl, guest_device_id_u16) + .context("tdisp_attest_device: post-start validation failed")?; + + // Fetch and save the TDI interface report so callers can inspect the + // attested device's reported capabilities and MMIO ranges. + let tdi_report = self.tdisp_get_tdi_report().await.context( + "tdisp_attest_device: failed to get TDI interface report after starting device", + )?; + + tracing::info!( + ?tdi_report, + %guest_device_id, + "tdisp_attest_device: device attestation flow completed successfully, waiting on resources to be assigned" + ); + + self.mutable_state + .update_guest_device_id(TdispDeviceId::Valid(guest_device_id_u16)); + + // 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(()) + } + + /// Get the TDI state of the device. This is used for testing and validation purposes, and is not part of the standard TDISP flow. + pub fn tdisp_get_tdi_state(&self) -> TdispTdiState { + self.tdi_state() + } + + /// 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. + pub 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" + ); + } + } + + /// Returns true if the given BAR has been marked intercepted. + pub fn is_bar_intercepted(&self, bar_id: u16) -> bool { + self.mutable_state.intercepted_bars.contains(&bar_id) + } + + /// Classify a single BAR's isolation from the cached TDI interface report + /// and the set of intercepted BARs. + /// + /// This is the single source of truth for the question, so that what is + /// reported to the guest and what is actually unblocked cannot disagree: + /// `Private` is exactly a BAR that gets unblocked, `Shared` is one that is + /// deliberately skipped, and `Invalid` means there is nothing to classify, + /// either because the device does not implement the BAR or because no + /// interface report is cached. + /// + /// * `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; + }; + + // `range_id` == PCI BAR index for the guest protocols we support. A + // BAR the report does not list is one the TDI does not claim as + // protected memory, which makes it host-visible like any other non-TEE + // range. A slot the device does not implement, the upper half of a + // 64-bit BAR included, is not a resource at all and has no + // classification. + let Some(range) = report + .mmio_interface_info + .iter() + .find(|r| r.range_id == bar_id) + else { + return if self.present_bars[usize::from(bar_id)] { + TdispResourceIsolation::Shared + } else { + TdispResourceIsolation::Invalid + }; + }; + + // `is_non_tee_mem` ranges have no protected backing and must + // never be passed to `tdisp_unblock_mmio`. Report SHARED and + // skip. 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, + /// suitable for populating a `VpciIsolatedResourcesReply` on the + /// guest-facing side. + /// + /// This is pure classification over the currently cached state: it + /// never drives attestation, so it only ever returns + /// [`TdispIsolationReport::NotReady`] or + /// [`TdispIsolationReport::Ready`]. `NotTdispCapable` and `Error` + /// are decided by the callers above. + /// + /// Returns `NotReady` iff no TDI interface report is currently + /// cached, which is the case both before the first attestation and + /// after any unbind, since unbinding drops the report along with the + /// rest of the per-attest state. Callers that need a classification + /// from an unattested device have to attest it first. + pub 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: none of them is protected memory, so there is + /// nothing to flip. Having no report at all is an error, since it means the + /// device was never attested. + /// + /// Doing this on reconfiguration rather than at attestation time is what + /// lets the platform validate against the addresses the guest actually + /// programmed. + /// + /// # 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. + pub async fn tdisp_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 BAR only classifies Private once the interface report is cached, + // which happens during attestation alongside the device id, so reaching + // here without one means the two have gone out of step. + 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. The host is what puts the + // range's pages into a state the platform can then accept, so here the + // host has to lead, which is the reverse of the ordering on the block + // path in `tdisp_unbind`. + self.tdisp_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 so the device can issue DMA traffic to the + // guest. 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(()) + } +} + +impl TdispVirtualDeviceInterface for VpciDevice { + async fn send_tdisp_command( + &self, + payload: GuestToHostCommand, + ) -> Result { + let mut guard = self.tdisp.0.lock().await; + guard.send_tdisp_command(payload).await + } + + async fn tdisp_get_device_interface_info( + &self, + target_protocol: TdispGuestProtocolType, + ) -> anyhow::Result { + let mut guard = self.tdisp.0.lock().await; + guard.tdisp_get_device_interface_info(target_protocol).await + } + + async fn tdisp_bind_interface(&self) -> anyhow::Result<()> { + let mut guard = self.tdisp.0.lock().await; + guard.tdisp_bind_interface().await + } + + async fn tdisp_start_device(&self) -> anyhow::Result<()> { + let mut guard = self.tdisp.0.lock().await; + guard.tdisp_start_device().await + } + + async fn tdisp_get_device_report( + &self, + report_type: &TdispReportType, + ) -> anyhow::Result> { + let mut guard = self.tdisp.0.lock().await; + guard.tdisp_get_device_report(report_type).await + } + + async fn tdisp_get_tdi_report(&self) -> anyhow::Result { + let mut guard = self.tdisp.0.lock().await; + guard.tdisp_get_tdi_report().await + } + + async fn tdisp_get_tdi_device_id(&self) -> anyhow::Result { + let mut guard = self.tdisp.0.lock().await; + guard.tdisp_get_tdi_device_id().await + } + + async fn tdisp_unbind(&self, reason: TdispGuestUnbindReason) -> anyhow::Result<()> { + let mut guard = self.tdisp.0.lock().await; + guard.tdisp_unbind(reason).await + } + + async fn tdisp_host_block_mmio_range( + &self, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + ) -> anyhow::Result<()> { + let mut guard = self.tdisp.0.lock().await; + guard + .tdisp_host_block_mmio_range(range_id, gpa_base, range_len_bytes) + .await + } + + async fn tdisp_host_unblock_mmio_range( + &self, + range_id: u16, + gpa_base: u64, + range_len_bytes: u64, + ) -> anyhow::Result<()> { + let mut guard = self.tdisp.0.lock().await; + guard + .tdisp_host_unblock_mmio_range(range_id, gpa_base, range_len_bytes) + .await + } +} + +/// Higher level interface for TDISP operations on a VPCI device. +#[expect(async_fn_in_trait)] +pub trait TdispVpciAttestationInterface: Sync + Send { + /// Attests the device using the TDISP flow. This includes binding the + /// device, starting it, and any other validation steps on reports that are + /// necessary for the device to be considered attested. + /// + /// The whole flow is performed atomically from the caller's point of view: + /// on success (`Ok`) the device is left in Run, and on failure (`Err`) it + /// is left Unlocked with no attestation state retained. + /// + /// Device resources are not yet accessible on return. They are unblocked + /// later, when the guest enables MMIO. + /// + /// * `interface_info` - The negotiated capabilities for this device. + async fn tdisp_attest_device( + &self, + interface_info: TdispDeviceInterfaceInfo, + ) -> anyhow::Result<()>; + + /// 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 tdisp_query_capabilities(&self) -> anyhow::Result; + + /// Get the TDI state of the device. + async fn tdisp_tdi_state(&self) -> TdispTdiState; + + /// Called when a BAR MMIO range is reconfigured by the guest, to make the + /// range accessible to the guest if it is private memory. + /// + /// # Arguments + /// + /// * `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. + async fn tdisp_on_mmio_reconfigured( + &self, + bar_id: u16, + base_address: u64, + length: u64, + ) -> anyhow::Result<()>; + + /// Mark a BAR as paravisor-intercepted, so that it is never made private + /// on MMIO reconfiguration. Use this for BARs whose memory is registered + /// as a paravisor MMIO intercept region (e.g. the MSI-X table / PBA BAR) + /// and therefore has no host-side RAM backing that could be flipped. + /// + /// * `bar_id` - The PCI BAR index to mark. + async fn tdisp_mark_bar_intercepted(&self, bar_id: u16); +} + +impl TdispVpciAttestationInterface for VpciDevice { + async fn tdisp_attest_device( + &self, + interface_info: TdispDeviceInterfaceInfo, + ) -> anyhow::Result<()> { + let mut guard = self.tdisp.0.lock().await; + guard.attest(interface_info).await + } + + async fn tdisp_query_capabilities(&self) -> anyhow::Result { + let mut guard = self.tdisp.0.lock().await; + guard.query_capabilities().await + } + + async fn tdisp_tdi_state(&self) -> TdispTdiState { + let guard = self.tdisp.0.lock().await; + guard.tdi_state() + } + + async fn tdisp_on_mmio_reconfigured( + &self, + bar_id: u16, + base_address: u64, + length: u64, + ) -> anyhow::Result<()> { + let mut guard = self.tdisp.0.lock().await; + guard + .tdisp_on_mmio_reconfigured(bar_id, base_address, length) + .await + } + + async fn tdisp_mark_bar_intercepted(&self, bar_id: u16) { + let mut guard = self.tdisp.0.lock().await; + guard.mark_bar_intercepted(bar_id); + } +} + +impl VpciDevice { + /// Return a classification of BAR and DMA isolation for this device, + /// suitable for answering `VPCI_QUERY_ISOLATED_RESOURCES` on the + /// guest-facing VPCI channel. + /// + /// An unattested device has no interface report to classify, so this + /// attests it first to produce one. The whole sequence runs under a single + /// hold of the per-device TDISP mutex, so the state observed here cannot + /// change before it is acted on. + pub async fn tdisp_isolation_snapshot(&self) -> TdispIsolationReport { + let mut guard = self.tdisp.0.lock().await; + + if guard.tdi_state() == TdispTdiState::Unlocked { + let info = match guard.query_capabilities().await { + Ok(info) => info, + Err(err) => { + tracing::error!( + "tdisp_isolation_snapshot: query_capabilities failed (tdisp not supported or host errored out): {err}" + ); + return TdispIsolationReport::NotTdispCapable; + } + }; + if let Err(err) = guard.attest(info).await { + tracing::error!( + error = &*err as &dyn std::error::Error, + "tdisp_isolation_snapshot: attest from Unlocked failed", + ); + return TdispIsolationReport::Error; + } + } + + guard.isolation_snapshot() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use tdisp::devicereport::TdispTdiReportInterfaceInfo; + use tdisp::devicereport::TdispTdiReportMmioFlags; + use tdisp::devicereport::TdispTdiReportMmioInterfaceInfo; + + /// Build a `VpciClientTdispState` with default fields and a dangling + /// worker sender. `send_tdisp_command` must not be called on the + /// returned value, but `isolation_snapshot` and the mutable-state + /// fields it inspects are safe to poke directly. + fn new_state() -> VpciClientTdispState { + 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]) -> VpciClientTdispState { + let (worker_req, _worker_recv) = mesh::channel::(); + VpciClientTdispState::new( + worker_req, + /* device_id = */ 0, + /* resource_validator = */ + Arc::new(openhcl_tdisp::noop::TdispNoopResourceValidator::new()), + IsolationType::None, + /* vtom = */ 0, + 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/vm/devices/pci/vpci_client/src/tests.rs b/vm/devices/pci/vpci_client/src/tests.rs index d7a1408484a..951ef173280 100644 --- a/vm/devices/pci/vpci_client/src/tests.rs +++ b/vm/devices/pci/vpci_client/src/tests.rs @@ -14,7 +14,9 @@ use chipset_device::pci::PciConfigSpace; use closeable_mutex::CloseableMutex; use guestmem::GuestMemory; use guid::Guid; +use hvdef::Vtl; use openhcl_tdisp::TdispVirtualDeviceInterface; +use openhcl_tdisp::noop::TdispNoopResourceValidator; use pal_async::DefaultDriver; use pal_async::async_test; use pal_async::task::Spawn; @@ -26,6 +28,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; @@ -121,7 +124,18 @@ 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, + 0, + Vtl::Vtl0, + ) + .await + .unwrap(); let MsiAddressData { address, data } = device .register_interrupt( 1, @@ -182,8 +196,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, + 0, + Vtl::Vtl0, + ) + .await + .unwrap(); + let interface = device + .tdisp_get_device_interface_info(TDISP_MOCK_GUEST_PROTOCOL) + .await; match interface { Ok(interface) => { @@ -197,3 +224,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] + ); + } +} From 07cf058ba4b4ed144b6933abfaa42d28330b0d1c Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Thu, 10 Sep 2026 15:08:30 -0700 Subject: [PATCH 08/31] vpci: answer the isolated resources query on the guest-facing server --- vm/devices/pci/vpci/src/device.rs | 419 +++++++++++++++++++++++++++++- 1 file changed, 414 insertions(+), 5 deletions(-) diff --git a/vm/devices/pci/vpci/src/device.rs b/vm/devices/pci/vpci/src/device.rs index 7cb806b7bff..bdfa58af3c4 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,6 +1017,51 @@ 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_isolation() + .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 } => { let command = match tdisp::serialize_proto::deserialize_command(&data) { Ok(cmd) => cmd, @@ -1079,6 +1147,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 +1699,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; @@ -1945,6 +2069,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 +2239,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 +2535,11 @@ mod tests { struct TestDevice { config_space: ConfigSpaceType0Emulator, tdisp_interface: TdispHostDeviceTargetEmulator, + /// If `Some`, the device also advertises + /// `TdispIsolationReporter` and returns the stored report from + /// `tdisp_isolation_report()`. If `None`, the device behaves as + /// non-TDISP-isolation-aware (the chipset-device default). + isolation_report: Option, } impl TestDevice { fn new(register_mmio: &mut dyn RegisterMmioIntercept) -> Self { @@ -2306,9 +2568,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 @@ -2362,6 +2630,26 @@ mod tests { fn supports_tdisp(&mut self) -> Option<&mut dyn tdisp::TdispHostDeviceTarget> { Some(&mut self.tdisp_interface) } + + fn supports_tdisp_isolation(&mut self) -> Option<&mut dyn tdisp::TdispIsolationReporter> { + if self.isolation_report.is_some() { + Some(self) + } else { + None + } + } + } + + impl tdisp::TdispIsolationReporter 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_isolation returns Some"); + Box::pin(async move { report }) + } } impl MmioIntercept for TestDevice { @@ -2498,9 +2786,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,12 +2805,131 @@ 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 + ), + } + } + + /// 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] From a4b3ffe5a2d787aeb90872ac1e17b8d71895b81b Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Thu, 10 Sep 2026 15:08:30 -0700 Subject: [PATCH 09/31] vpci_relay: report isolation and attest on a command register write --- Cargo.lock | 1 + vm/devices/pci/vpci_client/src/lib.rs | 76 ++----- vm/devices/pci/vpci_relay/Cargo.toml | 3 +- vm/devices/pci/vpci_relay/src/lib.rs | 279 +++++++++++++++++++++++++- 4 files changed, 292 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a859b8f9b8..5db400881de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11081,6 +11081,7 @@ dependencies = [ "tracelimit", "tracing", "user_driver", + "virt", "vmbus_client", "vmbus_server", "vmcore", diff --git a/vm/devices/pci/vpci_client/src/lib.rs b/vm/devices/pci/vpci_client/src/lib.rs index 42257c68ecc..0be03d98a80 100644 --- a/vm/devices/pci/vpci_client/src/lib.rs +++ b/vm/devices/pci/vpci_client/src/lib.rs @@ -558,14 +558,9 @@ 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, as if the guest - /// had written a STATUS_COMMAND value with MMIO and bus-master - /// disabled. Called on the unbind path - /// ([`Self::tdisp_on_device_deactivate`]) to explicitly leave the command - /// register in the expected off state after the device is unbound. - /// - /// This is not safety critical, this is for cleanup purposes only. + /// 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; @@ -588,21 +583,14 @@ impl VpciDevice { ); } - /// Called on the STATUS_COMMAND MMIO disabled→enabled edge. + /// 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. /// - /// Then writes `command_value` to the command register, which flushes the - /// shadowed BARs to the host device, and only after that notifies TDISP - /// about each currently active MMIO BAR via - /// [`tdisp::VpciClientTdispState::tdisp_on_mmio_reconfigured`]. The BARs - /// must be mapped for the guest before the unblock operations run. - /// /// Returns `true` only if attestation and every BAR notification succeeded - /// completely. On `false` the command register is left off: either it was - /// never written, or [`Self::tdisp_fail_attestation`] cleared it. + /// completely. Otherwise, the device is disabled and `false` is returned. pub async fn tdisp_on_device_activate(&self, command_value: ByteEnabledDwordWrite) -> bool { use tdisp::TdispVpciAttestationInterface; @@ -610,7 +598,7 @@ impl VpciDevice { "tdisp_on_device_activate: guest enabled MMIO, attesting device and notifying TDISP of MMIO bars" ); - // Attest the device. + // Attest the device before enabling the command register. let attest_result = match self.tdisp_query_capabilities().await { Ok(interface_info) => self .tdisp_attest_device(interface_info) @@ -624,7 +612,7 @@ impl VpciDevice { error = &*err as &dyn std::error::Error, "tdisp_on_device_activate: attestation failed, leaving command register off" ); - self.tdisp_fail_attestation().await; + self.tdisp_unbind_resources().await; return false; } @@ -632,7 +620,7 @@ impl VpciDevice { // 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_fail_attestation` clears the + // On any failure past this point `tdisp_unbind_resources` clears the // command register again. self.write_cfg(HeaderType00::STATUS_COMMAND.0, command_value); @@ -670,7 +658,7 @@ impl VpciDevice { error = %e, "failed to notify TDISP of active MMIO BAR. Failing activation." ); - self.tdisp_fail_attestation().await; + self.tdisp_unbind_resources().await; return false; } } @@ -682,26 +670,27 @@ impl VpciDevice { true } - /// Common teardown for any failure during the MMIO-enable activation - /// path: post-Bind attestation failure, per-BAR unblock failure, or - /// similar. Unbinds the device, clears the command register, and cleans up - /// resources. - async fn tdisp_fail_attestation(&self) { + /// 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) { use openhcl_tdisp::TdispGuestUnbindReason; use openhcl_tdisp::TdispVirtualDeviceInterface; tracing::error!( - "tdisp_fail_attestation: unbinding TDI back to Unlocked due to attestation failure" + "tdisp_unbind_resources: unbinding TDI back to Unlocked due to device deactivation or attestation failure" ); let unbind_result = self .tdisp_unbind(TdispGuestUnbindReason::ResourceSetupFailure) .await; + // Unbind failing means that the device is in a broken state. Leave the + // command register off and leave the device as-is. Future attestation + // during device enablement might not work. if let Err(unbind_err) = &unbind_result { tracing::warn!( error = unbind_err.as_ref() as &dyn std::error::Error, - "tdisp_fail_attestation: unbind failed" + "tdisp_unbind_resources: unbind failed" ); } @@ -713,35 +702,10 @@ impl VpciDevice { /// 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. Mirrors - /// [`Self::tdisp_on_device_activate`] for the disable edge. - /// - /// Called when STATUS_COMMAND transitions MMIO from enabled to disabled. If - /// the TDI is not in `Run` (e.g. `Uninitialized`, `Unlocked`, or `Locked`), - /// this is a no-op. + /// id, intercepted BARs, validated MMIO bars, DMA flag) is cleared. pub async fn tdisp_on_device_deactivate(&self) { - use openhcl_tdisp::TdispGuestUnbindReason; - use openhcl_tdisp::TdispVirtualDeviceInterface; - use tdisp::TdispVpciAttestationInterface; - - let state = self.tdisp_tdi_state().await; - - tracing::info!( - ?state, - "tdisp_on_device_deactivate: guest disabled MMIO, unbinding TDI back to Unlocked" - ); - - if let Err(err) = self.tdisp_unbind(TdispGuestUnbindReason::Graceful).await { - tracing::warn!( - error = &*err as &dyn std::error::Error, - "tdisp_on_device_deactivate: unbind failed" - ); - } - - // Always clear the command register explicitly on the unbind path so the - // device is left in the expected off state regardless of the value the - // guest wrote or the outcome of the unbind. - self.clear_command_register(); + // Pass this lifecycle event directly to unbind_resources + self.tdisp_unbind_resources().await; } } diff --git a/vm/devices/pci/vpci_relay/Cargo.toml b/vm/devices/pci/vpci_relay/Cargo.toml index 4da57c6b0c3..734da3a2e2f 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,7 +34,6 @@ 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 diff --git a/vm/devices/pci/vpci_relay/src/lib.rs b/vm/devices/pci/vpci_relay/src/lib.rs index dbd2bba8784..6cf85f91881 100644 --- a/vm/devices/pci/vpci_relay/src/lib.rs +++ b/vm/devices/pci/vpci_relay/src/lib.rs @@ -21,20 +21,28 @@ 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::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::TdispResourceValidationInterface; use openhcl_tdisp::TdispVirtualDeviceInterface; +use pci_core::spec::cfg_space::HeaderType00; use pci_core::spec::hwid::HardwareIds; use state_unit::StateUnits; +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 user_driver::DmaClient; +use virt::IsolationType; use vmbus_client::driver::OpenParams; use vmbus_server::Guid; use vmcore::device_state::ChangeDeviceState; @@ -50,10 +58,14 @@ use vpci_client::MemoryAccess; use vpci_client::VpciClient; use vpci_client::VpciDevice; use vpci_client::VpciDeviceEject; +use vpci_client::tdisp::TdispVpciAttestationInterface; /// TODO TDISP: Required for the tdisp crate to be built in the meantime. #[expect(unused_imports)] use tdisp::TdispHostDeviceInterface; +use tdisp::TdispIsolationReport; +use tdisp::TdispIsolationReporter; +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; @@ -96,7 +108,10 @@ pub struct VpciRelay { allowed_devices: Vec, #[inspect(hex)] vtom: Option, + isolation_type: IsolationType, options: VpciRelayOptions, + #[inspect(skip)] + resource_validator: Arc, } #[derive(Inspect)] @@ -104,6 +119,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 +131,30 @@ 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; + + // Only devices that actually completed at least a Bind have a + // TDI on the host side to unbind. Non-TDISP devices stay in + // `Uninitialized` and must be left alone. `tdisp_unbind` on + // them would return a host error. + if self.vpci_device.tdisp_tdi_state().await != TdispTdiState::Uninitialized { + if let Err(err) = self + .vpci_device + .tdisp_unbind(tdisp::TdispGuestUnbindReason::DeviceTeardown) + .await + { + tracing::warn!( + bus_instance_id = %self.bus_instance_id, + error = &*err as &dyn std::error::Error, + "tdisp_unbind during relay teardown failed" + ); + } + } + self.bus_client.shutdown().await; } } @@ -180,9 +219,24 @@ impl VpciRelay { dma_client: Arc, mmio_range: MemoryRange, mmio_access: Box, + resource_validator: Arc, + 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 + }; + Self { driver_source, dma_client, @@ -193,7 +247,9 @@ impl VpciRelay { mmio_range, mmio_access, allowed_devices: Vec::new(), - vtom, + vtom: target_vtom, + isolation_type: target_isolation_type, + resource_validator, options, } } @@ -313,25 +369,57 @@ impl VpciRelay { tracing::info!(%instance_id, vendor_id = hw_ids.vendor_id, device_id = hw_ids.device_id, "vpci relay device arrived"); let (vpci_device, removed) = vpci_device - .init() + .init( + self.resource_validator.clone(), + self.isolation_type, + self.vtom.unwrap_or(0), + 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()) .await .expect("failed to exercise TDISP flow test"); + } 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, + 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 +452,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, @@ -383,8 +472,10 @@ impl VpciRelay { "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::Uninitialized); + let device_interface_info = device - .tdisp_get_device_interface_info() + .tdisp_get_device_interface_info(TDISP_MOCK_GUEST_PROTOCOL) .await .context("tdisp_test_mock_flow: failed to get device interface info over vpci")?; @@ -402,30 +493,198 @@ impl VpciRelay { device_interface_info.supported_features, TDISP_MOCK_SUPPORTED_FEATURES ); + assert_eq!(device.tdisp_tdi_state().await, TdispTdiState::Unlocked); + + Self::tdisp_test_mock_attest_flow(device.clone()) + .await + .context("tdisp_test_mock_flow: failed to exercise TDISP attestation flow")?; + + Ok(()) + } + + async fn tdisp_test_mock_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_device(tdisp_capabilities) + .await + .context("tdisp_test_mock_flow: failed to attest device over vpci")?; + + assert_eq!(device.tdisp_tdi_state().await, TdispTdiState::Run); Ok(()) } } #[derive(InspectMut)] -#[inspect(transparent)] -struct RelayedVpciDevice(Arc); +struct RelayedVpciDevice { + #[inspect(flatten)] + device: Arc, + + /// In-flight deferred config space write. Driven by [`PollDevice`]. + #[inspect(skip)] + pending: Option<( + DeferredWrite, + Pin + Send + Sync>>, + )>, + + /// Waker captured from the most recent `PollDevice::poll_device` call. + /// We wake it from `pci_cfg_write` when we install a new pending future + /// so the chipset device unit re-polls us. + #[inspect(skip)] + waker: Waker, + + /// Is the device TDISP capable? + tdisp_capable: bool, +} impl ChipsetDevice for RelayedVpciDevice { fn supports_pci(&mut self) -> Option<&mut dyn PciConfigSpace> { Some(self) } + + fn supports_tdisp_isolation(&mut self) -> Option<&mut dyn TdispIsolationReporter> { + 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(); + if let Some((_, fut)) = self.pending.as_mut() { + if fut.as_mut().poll(cx).is_ready() { + // Future done; complete the deferred write so the bus can + // continue draining any queued config writes. + let (deferred, _) = self.pending.take().expect("just checked"); + deferred.complete(); + } + } + } +} + +impl TdispIsolationReporter for RelayedVpciDevice { + // Builds a report 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 { + // Whether the device is TDISP capable at all is decided once, when + // the host offers the device, so answer that here rather than + // asking the client. Everything else, including attesting when the + // TDI is `Unlocked`, is the client's job. + if !tdisp_capable { + return TdispIsolationReport::NotTdispCapable; + } + + device.tdisp_isolation_snapshot().await + }) + } } 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 + // Only a command register write that flips the MMIO-enable bit needs + // async TDISP work. Everything else is a synchronous pass-through. + // + // This matters beyond efficiency: `probe_bar_masks` sizes the BARs from + // a synchronous context with no executor available, so it cannot honor + // a deferred write. + if !self.tdisp_capable || HeaderType00(offset) != HeaderType00::STATUS_COMMAND { + self.device.write_cfg(offset, value); + return IoResult::Ok; + } + + // Detect the MMIO-enable edge BEFORE issuing the write so we can + // dispatch the correct TDISP notification. + // + // The STATUS_COMMAND dword packs the 16-bit Command register in the low + // two bytes and the 16-bit Status register in the high two bytes. Only + // the Command register is relevant here, so mask off the Status half + // before truncating to `u16`. + use pci_core::spec::cfg_space::Command; + let mut current = 0; + self.device.read_cfg( + offset, + ByteEnabledDwordRead::with_all_bytes_enabled(&mut current), + ); + 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) { + (false, true) => {} + (true, false) => { + // Once MMIO is on the TDI is bound and its ranges have been + // unblocked and accepted into the guest. Drop the write rather + // than letting the guest walk that back: the disable edge would + // unbind the device and re-block every range. + tracing::warn!( + ?offset, + ?value, + "dropping a config space write that would disable MMIO; the command \ + register does not transition back to off once it is on" + ); + return IoResult::Ok; + } + // No MMIO edge, so there is no TDISP notification to dispatch. + _ => { + self.device.write_cfg(offset, value); + return IoResult::Ok; + } + } + + let device = self.device.clone(); + let fut = Box::pin(async move { + // Attest while the command register is still off. + // `tdisp_on_device_activate` enables the command register itself + // once attestation succeeds, so the BARs are mapped before it + // notifies TDISP of the MMIO ranges. + if !device.tdisp_on_device_activate(value).await { + // The command register is left off if attestation failed. + tracing::warn!("TDISP attestation failed. Not enabling STATUS_COMMAND."); + } + }); + + // Overwriting an in-flight deferral would drop its `DeferredWrite`, + // which the caller sees as `IoError::NoResponse`. Every caller waits for + // its own deferred write to complete, so this should not happen. + debug_assert!( + self.pending.is_none(), + "config space write deferred while another deferred write is in flight" + ); + + let (write, token) = chipset_device::io::deferred::defer_write(); + self.pending = Some((write, fut)); + self.waker.wake_by_ref(); + IoResult::Defer(token) } } From 57231a1b9f1e81bd8e0d5b3c53818534637d81b7 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Thu, 10 Sep 2026 15:08:30 -0700 Subject: [PATCH 10/31] underhill_core: give the vpci relay a TDISP resource validator --- Cargo.lock | 1 + openhcl/underhill_core/Cargo.toml | 1 + openhcl/underhill_core/src/worker.rs | 23 +++++++++++++++++++---- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5db400881de..76ce6119e0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9071,6 +9071,7 @@ dependencies = [ "nvme_spec", "openhcl_attestation_protocol", "openhcl_dma_manager", + "openhcl_tdisp", "pal", "pal_async", "pal_uring", diff --git a/openhcl/underhill_core/Cargo.toml b/openhcl/underhill_core/Cargo.toml index 42132aa7819..ded6d3dedf9 100644 --- a/openhcl/underhill_core/Cargo.toml +++ b/openhcl/underhill_core/Cargo.toml @@ -82,6 +82,7 @@ netvsp.workspace = true nvme_driver.workspace = true nvme_resources.workspace = true openhcl_dma_manager.workspace = true +openhcl_tdisp.workspace = true scsi_core.workspace = true scsidisk.workspace = true scsidisk_resources.workspace = true diff --git a/openhcl/underhill_core/src/worker.rs b/openhcl/underhill_core/src/worker.rs index 1ed765b1737..08bd2fd8410 100644 --- a/openhcl/underhill_core/src/worker.rs +++ b/openhcl/underhill_core/src/worker.rs @@ -3376,8 +3376,24 @@ 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 openhcl_tdisp::TdispResourceValidationInterface; + use openhcl_tdisp::noop::TdispNoopResourceValidator; + use vpci_relay::*; + // A device driven through the TDISP flow always has a + // validator, so no platform can silently skip resource + // validation. Isolation types with no platform validator of + // their own get the no-op one. + let resource_validator: Arc = + Arc::new(TdispNoopResourceValidator::new()); + let mut relay = VpciRelay::new( driver_source.clone(), vpci_filter.take(), @@ -3404,13 +3420,12 @@ async fn new_underhill_vm( .context("failed to create direct mmio accessor")?, ) }, + resource_validator, + 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, }, ); From 0c9d27f795b20b79984e0cd5519f2ae71ba5f4c1 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Mon, 14 Sep 2026 12:06:58 -0700 Subject: [PATCH 11/31] rewording --- openhcl/openhcl_tdisp/src/lib.rs | 70 +++++++++++++------------------ openhcl/openhcl_tdisp/src/noop.rs | 3 +- 2 files changed, 32 insertions(+), 41 deletions(-) diff --git a/openhcl/openhcl_tdisp/src/lib.rs b/openhcl/openhcl_tdisp/src/lib.rs index 9f728a7e66d..c71ce68ecc2 100644 --- a/openhcl/openhcl_tdisp/src/lib.rs +++ b/openhcl/openhcl_tdisp/src/lib.rs @@ -131,14 +131,9 @@ pub trait TdispVirtualDeviceInterface: Send + Sync { /// Provides platform-specific methods for unblocking device resources after /// TDISP attestation. -/// -/// After a device has been attested and placed in the Run state via -/// [`TdispVirtualDeviceInterface`], platform-specific operations are required -/// to make device resources (MMIO, DMA) accessible to the guest. This trait -/// abstracts those operations. pub trait TdispResourceValidationInterface: Send + Sync { - /// Called immediately before the device is bound, while the TDI is still - /// Unlocked. + /// 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. /// @@ -146,41 +141,39 @@ pub trait TdispResourceValidationInterface: Send + Sync { /// * `device_id` - Identifies the TDI device (not a VPCI ID). fn on_pre_bind(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()>; - /// Called after the device has been bound and is Locked, immediately before - /// it is started. + /// 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; the - /// device is left bound and the caller unbinds it as part of clearing the - /// failed attestation. + /// 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 a VPCI ID). fn on_pre_start(&self, target_vtl: Vtl, device_id: u16) -> anyhow::Result<()>; - /// Called after the host has started the device and reports it running. + /// 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; the device is left running and the caller unbinds it as - /// part of clearing the failed attestation. + /// attestation. /// /// * `target_vtl` - The VTL the device is being attested for. /// * `device_id` - Identifies the TDI device (not a VPCI 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. + /// Manager, without the host's involvement. This provides a safe channel to + /// verify the TDI state. /// - /// This is an independent answer to the same question the host answers in - /// its command responses, so a caller can hold the two against each other - /// rather than having to take the host's word for it. + /// 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, which - /// leaves the caller with nothing to compare 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 has no equivalent here. + /// 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 a VPCI ID). @@ -192,15 +185,14 @@ pub trait TdispResourceValidationInterface: Send + Sync { /// Record the TDI interface report for a device. /// - /// Called during attestation once the report has been fetched, and again on - /// each re-attest. Platforms that must resolve report-relative identifiers - /// keep what they need from it; the rest ignore it. + /// Called during the attestation flow to allow the validator interface to + /// cache the TDI interface report. /// /// * `device_id` - Identifies the TDI device (not a VPCI ID). /// * `report` - The device's TDI interface report. fn tdisp_set_tdi_report(&self, device_id: u16, report: &TdiReportStruct); - /// Drop the TDI interface report recorded for a device. + /// 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. @@ -208,7 +200,8 @@ pub trait TdispResourceValidationInterface: Send + Sync { /// * `device_id` - Identifies the TDI device (not a VPCI ID). fn tdisp_clear_tdi_report(&self, device_id: u16); - /// Unblock MMIO access for a specific resource on the device. + /// 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 a VPCI ID). @@ -218,11 +211,13 @@ pub trait TdispResourceValidationInterface: Send + Sync { /// 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`. + /// * `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, @@ -233,7 +228,8 @@ pub trait TdispResourceValidationInterface: Send + Sync { range_id: u16, ) -> Pin> + Send + Sync + 'a>>; - /// Unblock DMA access for the device's IOMMU domain. + /// 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 a VPCI ID). @@ -327,9 +323,6 @@ pub fn new_unbind_command(device_id: u64, reason: TdispGuestUnbindReason) -> Gue /// Creates a [`GuestToHostCommand`] for the `ModifyMmioRange` command with the /// `UnblockMmioRange` action. -/// -/// `range_id` is widened to a `u32` because protobuf has no 16-bit type; the -/// host narrows it back before dispatching. pub fn new_unblock_mmio_range_command( device_id: u64, range_id: u16, @@ -347,9 +340,6 @@ pub fn new_unblock_mmio_range_command( /// Creates a [`GuestToHostCommand`] for the `ModifyMmioRange` command with the /// `BlockMmioRange` action. -/// -/// `range_id` is widened to a `u32` because protobuf has no 16-bit type; the -/// host narrows it back before dispatching. pub fn new_block_mmio_range_command( device_id: u64, range_id: u16, diff --git a/openhcl/openhcl_tdisp/src/noop.rs b/openhcl/openhcl_tdisp/src/noop.rs index fd27cd45630..000e1994c6e 100644 --- a/openhcl/openhcl_tdisp/src/noop.rs +++ b/openhcl/openhcl_tdisp/src/noop.rs @@ -38,7 +38,8 @@ pub struct UnblockedMmioRange { /// 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. Every MMIO and +/// 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 { From b2992eb9b0c2662d8e704a50c22014e396384f59 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Mon, 14 Sep 2026 12:21:54 -0700 Subject: [PATCH 12/31] openhcl_tdisp: add a resource validator selector keyed on partition isolation --- Cargo.lock | 1 + openhcl/openhcl_tdisp/Cargo.toml | 1 + openhcl/openhcl_tdisp/src/lib.rs | 36 ++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 76ce6119e0a..ff930441153 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5595,6 +5595,7 @@ dependencies = [ "tdisp", "tdisp_proto", "tracing", + "virt", ] [[package]] diff --git a/openhcl/openhcl_tdisp/Cargo.toml b/openhcl/openhcl_tdisp/Cargo.toml index 38795c593a7..5281567a543 100644 --- a/openhcl/openhcl_tdisp/Cargo.toml +++ b/openhcl/openhcl_tdisp/Cargo.toml @@ -11,6 +11,7 @@ hvdef.workspace = true parking_lot.workspace = true tdisp.workspace = true tdisp_proto.workspace = true +virt.workspace = true anyhow.workspace = true tracing.workspace = true diff --git a/openhcl/openhcl_tdisp/src/lib.rs b/openhcl/openhcl_tdisp/src/lib.rs index c71ce68ecc2..b0864374529 100644 --- a/openhcl/openhcl_tdisp/src/lib.rs +++ b/openhcl/openhcl_tdisp/src/lib.rs @@ -40,12 +40,14 @@ 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 @@ -267,6 +269,40 @@ pub trait TdispResourceValidationInterface: Send + Sync { 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, so a +/// partition whose isolation type has no validator of its own is given one that +/// performs no validation rather than none at all. +/// +/// * `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. +/// * `test_tdisp_flow` - Whether the mocked TDISP flow is in use. +pub fn new_resource_validator( + isolation: IsolationType, + vtom: Option, + test_tdisp_flow: bool, +) -> anyhow::Result> { + tracing::info!( + ?isolation, + ?vtom, + test_tdisp_flow, + "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 whatever the partition + // reports. + if test_tdisp_flow { + return Ok(Arc::new(noop::TdispNoopResourceValidator::new())); + } + + Ok(Arc::new(noop::TdispNoopResourceValidator::new())) +} + /// Creates a [`GuestToHostCommand`] for the `GetDeviceInterfaceInfo` command. pub fn new_get_device_interface_info_command( device_id: u64, From f55f84e361fcc998ebe2b8345a5e16f18d057bf9 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Mon, 14 Sep 2026 12:22:31 -0700 Subject: [PATCH 13/31] underhill_core: select the TDISP resource validator through openhcl_tdisp --- openhcl/openhcl_tdisp/src/lib.rs | 23 ++++++++++++----------- openhcl/underhill_core/src/worker.rs | 14 +++++--------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/openhcl/openhcl_tdisp/src/lib.rs b/openhcl/openhcl_tdisp/src/lib.rs index b0864374529..cffb2cc2faf 100644 --- a/openhcl/openhcl_tdisp/src/lib.rs +++ b/openhcl/openhcl_tdisp/src/lib.rs @@ -270,36 +270,37 @@ pub trait TdispResourceValidationInterface: Send + Sync { } /// 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, so a -/// partition whose isolation type has no validator of its own is given one that -/// performs no validation rather than none at all. +/// 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. -/// * `test_tdisp_flow` - Whether the mocked TDISP flow is in use. +/// * `is_test_environment` - `true` if running in a test environment. pub fn new_resource_validator( isolation: IsolationType, vtom: Option, - test_tdisp_flow: bool, + is_test_environment: bool, ) -> anyhow::Result> { tracing::info!( ?isolation, ?vtom, - test_tdisp_flow, + 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 whatever the partition - // reports. - if test_tdisp_flow { + // 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())) } diff --git a/openhcl/underhill_core/src/worker.rs b/openhcl/underhill_core/src/worker.rs index 08bd2fd8410..30c8d7e467f 100644 --- a/openhcl/underhill_core/src/worker.rs +++ b/openhcl/underhill_core/src/worker.rs @@ -3382,17 +3382,13 @@ async fn new_underhill_vm( Some(TestScenarioConfig::VpciTdispFlow) ); - use openhcl_tdisp::TdispResourceValidationInterface; - use openhcl_tdisp::noop::TdispNoopResourceValidator; - use vpci_relay::*; - // A device driven through the TDISP flow always has a - // validator, so no platform can silently skip resource - // validation. Isolation types with no platform validator of - // their own get the no-op one. - let resource_validator: Arc = - Arc::new(TdispNoopResourceValidator::new()); + // Choose the appropriate TDISP resource validator based on the + // isolation type, VTOM, and whether we're in a test + // environment. + let resource_validator = + openhcl_tdisp::new_resource_validator(isolation, vtom, test_tdisp_flow)?; let mut relay = VpciRelay::new( driver_source.clone(), From 326fd5fe70c87f5b21277aad43537ba054531553 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Mon, 14 Sep 2026 12:49:15 -0700 Subject: [PATCH 14/31] chipset_device: rename the TDISP accessors to separate host from relayed devices --- vm/chipset_device/src/lib.rs | 20 ++-- vm/chipset_device_resources/src/lib.rs | 8 +- vm/devices/pci/vpci/src/device.rs | 18 +-- vm/devices/pci/vpci_client/src/lib.rs | 2 +- vm/devices/pci/vpci_client/src/tdisp.rs | 123 +++++++------------- vm/devices/pci/vpci_client/src/tests.rs | 2 +- vm/devices/pci/vpci_protocol/src/lib.rs | 8 +- vm/devices/pci/vpci_relay/src/lib.rs | 62 +++------- vm/devices/storage/nvme_test/src/pci.rs | 2 +- vm/devices/tdisp/src/lib.rs | 47 +++----- vm/devices/tdisp_proto/src/lib.rs | 24 +++- workers/chipset_device_worker/src/worker.rs | 2 +- 12 files changed, 126 insertions(+), 192 deletions(-) diff --git a/vm/chipset_device/src/lib.rs b/vm/chipset_device/src/lib.rs index af13dee3e0f..8d8736323be 100644 --- a/vm/chipset_device/src/lib.rs +++ b/vm/chipset_device/src/lib.rs @@ -62,22 +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 can report the device's VPCI - /// resource-isolation state for `VpciMsgQueryIsolatedResources`. - /// - /// This is implemented only by the OpenHCL VPCI relay's - /// `RelayedVpciDevice`. Emulated devices return `None` by default (and - /// therefore trigger the "no reporter" reply path on the guest-facing VPCI - /// server). + /// 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_isolation(&mut self) -> Option<&mut dyn tdisp::TdispIsolationReporter> { + 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 f1196e87e31..536a0446a69 100644 --- a/vm/chipset_device_resources/src/lib.rs +++ b/vm/chipset_device_resources/src/lib.rs @@ -174,12 +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_isolation(&mut self) -> Option<&mut dyn tdisp::TdispIsolationReporter> { - self.0.supports_tdisp_isolation() + 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 bdfa58af3c4..6d1b064b7d4 100644 --- a/vm/devices/pci/vpci/src/device.rs +++ b/vm/devices/pci/vpci/src/device.rs @@ -1038,7 +1038,7 @@ impl ReadyState { let fut = { let mut locked_dev = dev.device.lock(); locked_dev - .supports_tdisp_isolation() + .supports_tdisp_relay() .map(|r| r.tdisp_isolation_report()) }; let report = match fut { @@ -1082,7 +1082,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" ); @@ -2536,9 +2536,9 @@ mod tests { config_space: ConfigSpaceType0Emulator, tdisp_interface: TdispHostDeviceTargetEmulator, /// If `Some`, the device also advertises - /// `TdispIsolationReporter` and returns the stored report from - /// `tdisp_isolation_report()`. If `None`, the device behaves as - /// non-TDISP-isolation-aware (the chipset-device default). + /// `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 { @@ -2627,11 +2627,11 @@ 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_isolation(&mut self) -> Option<&mut dyn tdisp::TdispIsolationReporter> { + fn supports_tdisp_relay(&mut self) -> Option<&mut dyn tdisp::TdispRelayedDeviceTarget> { if self.isolation_report.is_some() { Some(self) } else { @@ -2640,14 +2640,14 @@ mod tests { } } - impl tdisp::TdispIsolationReporter for TestDevice { + 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_isolation returns Some"); + .expect("isolation_report must be set when supports_tdisp_relay returns Some"); Box::pin(async move { report }) } } diff --git a/vm/devices/pci/vpci_client/src/lib.rs b/vm/devices/pci/vpci_client/src/lib.rs index 0be03d98a80..ce178b784cb 100644 --- a/vm/devices/pci/vpci_client/src/lib.rs +++ b/vm/devices/pci/vpci_client/src/lib.rs @@ -1471,7 +1471,7 @@ pub(crate) fn implemented_bars(bar_masks: &[u32; 6]) -> [bool; 6] { /// 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 +/// 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. /// diff --git a/vm/devices/pci/vpci_client/src/tdisp.rs b/vm/devices/pci/vpci_client/src/tdisp.rs index 56724bb5bce..20f502f1ba0 100644 --- a/vm/devices/pci/vpci_client/src/tdisp.rs +++ b/vm/devices/pci/vpci_client/src/tdisp.rs @@ -42,12 +42,8 @@ struct VpciClientTdispMutableState { tdi_state: TdispTdiState, #[inspect(debug)] guest_device_id: TdispDeviceId, - /// Map of BAR ID to the range the guest configured and how it was - /// classified. Populated whenever a BAR is reconfigured in the `Run` - /// state, both for ranges that were unblocked and for ones that were - /// deliberately skipped. Used during unbind to call `tdisp_block_mmio` - /// with the same parameters, for the ranges that need it, so private - /// pages can be flipped back to shared. Cleared on unbind. + /// 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 @@ -59,16 +55,14 @@ struct VpciClientTdispMutableState { 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. + /// by guest RAM on the host and are always marked SHARED. #[inspect(iter_by_index)] intercepted_bars: HashSet, } -/// Identifies the TDI device, as distinct from the VPCI slot id. +/// Identifies the TDI to the host (distinct from the VPCI slot id). /// -/// A TDI only has an id once attestation has fetched one from the host, and it -/// loses it again on unbind, so "no id" is a real state of the device rather -/// than a particular id value. +/// 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 @@ -80,7 +74,6 @@ enum TdispDeviceId { impl TdispDeviceId { /// The underlying device id, or `None` when no TDI has been identified. - /// Platform interfaces address a real TDI, so they take the inner value. fn id(self) -> Option { match self { TdispDeviceId::Invalid => None, @@ -98,9 +91,8 @@ struct ValidatedMmio { base_gpa: u64, #[inspect(hex)] length_in_bytes: u64, - /// How the range was classified. `Private` means it was passed to - /// `tdisp_unblock_mmio` and must be blocked back on unbind; `Shared` - /// means it was deliberately skipped and there is nothing to undo. + + /// How the range was classified from the report. #[inspect(debug)] isolation: TdispResourceIsolation, } @@ -130,7 +122,8 @@ impl VpciClientTdispMutableState { pub struct VpciClientTdispState { #[inspect(skip)] worker_req: mesh::Sender, - // The device ID if the VPCI channel. Not to be confused with the guest device ID returned by the host in TDISP reports. + // The device ID if the VPCI channel is established. Not to be confused with + // the guest device ID returned by the host in TDISP reports. vpci_device_id: u64, isolation_type: IsolationType, vtom: u64, @@ -143,9 +136,6 @@ pub struct VpciClientTdispState { #[inspect(iter_by_index)] present_bars: [bool; 6], /// Platform hooks used to gate attestation and unblock device resources. - /// Required: a device driven through the TDISP flow must always have a - /// validator, so that no platform silently skips validation. Platforms with - /// nothing to do use `noop::TdispNoopResourceValidator`. #[inspect(skip)] resource_validator: Arc, } @@ -192,17 +182,18 @@ impl VpciClientTdispState { /// /// Panics if either source reports anything other than `expected`. /// - /// The host's reported state and the firmware's state are two independent - /// answers to the same question, and at these points in the flow there is - /// exactly one answer that is correct. 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 leaves only the host's answer to check. + /// 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 answers to the same question, and at these points in the + /// flow there is exactly one answer that is correct. 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 leaves only the host's answer + /// to check. /// /// * `expected` - The state the TDI must be in. - /// * `device_id` - Identifies the TDI device (not a VPCI ID). When no TDI - /// has been identified there is nothing to ask the firmware about, and - /// only the host's answer is checked. + /// * `device_id` - Identifies the TDI device (not a VPCI ID). Only valid if + /// the platform supports reporting its own TDI state. fn require_tdi_state( &self, expected: TdispTdiState, @@ -296,7 +287,7 @@ impl VpciClientTdispState { // 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 => tracing::warn!("host did not return valid TDI state in response"), + None => std::panic!("tdisp: host returned a completely unknown TDI state in response"), } match res.error_code() { @@ -718,8 +709,6 @@ impl VpciClientTdispState { /// 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. - /// - /// The result is not retained, so each call queries the device afresh. pub async fn query_capabilities(&mut self) -> anyhow::Result { tracing::info!( ?self.isolation_type, @@ -933,13 +922,6 @@ impl VpciClientTdispState { /// Classify a single BAR's isolation from the cached TDI interface report /// and the set of intercepted BARs. /// - /// This is the single source of truth for the question, so that what is - /// reported to the guest and what is actually unblocked cannot disagree: - /// `Private` is exactly a BAR that gets unblocked, `Shared` is one that is - /// deliberately skipped, and `Invalid` means there is nothing to classify, - /// either because the device does not implement the BAR or because no - /// interface report is cached. - /// /// * `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 @@ -955,12 +937,8 @@ impl VpciClientTdispState { return TdispResourceIsolation::Invalid; }; - // `range_id` == PCI BAR index for the guest protocols we support. A - // BAR the report does not list is one the TDI does not claim as - // protected memory, which makes it host-visible like any other non-TEE - // range. A slot the device does not implement, the upper half of a - // 64-bit BAR included, is not a resource at all and has no - // classification. + // 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() @@ -973,9 +951,8 @@ impl VpciClientTdispState { }; }; - // `is_non_tee_mem` ranges have no protected backing and must - // never be passed to `tdisp_unblock_mmio`. Report SHARED and - // skip. Everything else is TEE memory the TDI owns → PRIVATE. + // `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 { @@ -983,21 +960,12 @@ impl VpciClientTdispState { } } - /// Classify BAR and DMA isolation for this device at this instant, - /// suitable for populating a `VpciIsolatedResourcesReply` on the - /// guest-facing side. + /// Classify BAR and DMA isolation for this device at this instant in the + /// flow. /// - /// This is pure classification over the currently cached state: it - /// never drives attestation, so it only ever returns - /// [`TdispIsolationReport::NotReady`] or - /// [`TdispIsolationReport::Ready`]. `NotTdispCapable` and `Error` - /// are decided by the callers above. - /// - /// Returns `NotReady` iff no TDI interface report is currently - /// cached, which is the case both before the first attestation and - /// after any unbind, since unbinding drops the report along with the - /// rest of the per-attest state. Callers that need a classification - /// from an unattested device have to attest it first. + /// Returns `NotReady` iff no TDI interface report is currently cached. + /// Callers that need a classification from an unattested device have to + /// attest it first. pub fn isolation_snapshot(&self) -> TdispIsolationReport { if self.mutable_state.tdi_report.is_none() { return TdispIsolationReport::NotReady; @@ -1027,14 +995,11 @@ impl VpciClientTdispState { /// /// 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: none of them is protected memory, so there is - /// nothing to flip. Having no report at all is an error, since it means the - /// device was never attested. + /// intercepted, and BARs the device implements but the report does not list + /// are all skipped. /// - /// Doing this on reconfiguration rather than at attestation time is what - /// lets the platform validate against the addresses the guest actually - /// programmed. + /// Note: We have chosen to only allow MMIO reconfiguration only after the + /// Run state is reached. This is an implementation decision. /// /// # Arguments /// @@ -1120,25 +1085,23 @@ impl VpciClientTdispState { TdispResourceIsolation::Private => {} } - // A BAR only classifies Private once the interface report is cached, - // which happens during attestation alongside the device id, so reaching - // here without one means the two have gone out of step. + // 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. The host is what puts the - // range's pages into a state the platform can then accept, so here the - // host has to lead, which is the reverse of the ordering on the block - // path in `tdisp_unbind`. + // 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.tdisp_host_unblock_mmio_range(bar_id, base_address, length) .await .context("tdisp_on_mmio_reconfigured: failed to unblock MMIO on the host")?; @@ -1163,9 +1126,8 @@ impl VpciClientTdispState { ); // After the first successful MMIO unblock following attestation, - // unblock DMA as well so the device can issue DMA traffic to the - // guest. Guard with `dma_unblocked` so it only fires once per - // bind/attest cycle (cleared on unbind). + // 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}"); @@ -1354,11 +1316,6 @@ impl VpciDevice { /// Return a classification of BAR and DMA isolation for this device, /// suitable for answering `VPCI_QUERY_ISOLATED_RESOURCES` on the /// guest-facing VPCI channel. - /// - /// An unattested device has no interface report to classify, so this - /// attests it first to produce one. The whole sequence runs under a single - /// hold of the per-device TDISP mutex, so the state observed here cannot - /// change before it is acted on. pub async fn tdisp_isolation_snapshot(&self) -> TdispIsolationReport { let mut guard = self.tdisp.0.lock().await; diff --git a/vm/devices/pci/vpci_client/src/tests.rs b/vm/devices/pci/vpci_client/src/tests.rs index 951ef173280..af68ed24c2b 100644 --- a/vm/devices/pci/vpci_client/src/tests.rs +++ b/vm/devices/pci/vpci_client/src/tests.rs @@ -47,7 +47,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) } } diff --git a/vm/devices/pci/vpci_protocol/src/lib.rs b/vm/devices/pci/vpci_protocol/src/lib.rs index fe95ecdce5e..be656df8eef 100644 --- a/vm/devices/pci/vpci_protocol/src/lib.rs +++ b/vm/devices/pci/vpci_protocol/src/lib.rs @@ -918,13 +918,7 @@ const _: () = assert!(size_of::() == 8); /// Reply to `MessageType::VPCI_QUERY_ISOLATED_RESOURCES`. /// -/// Synthesized entirely by the paravisor from local TDISP state. If `status == -/// Status::SUCCESS`, each entry in `bar_isolation` is one of `SHARED`, -/// `PRIVATE`, or `INVALID`. `INVALID` is used for BAR slots that are not part -/// of the device's known BAR ID set (including the upper halves of 64-bit BARs, -/// which are not tracked independently). `dma_isolation` is always `SHARED` or -/// `PRIVATE` on success. On any non-success status, all BAR entries are -/// `INVALID`. +/// Synthesized entirely by the paravisor from local TDISP state. #[repr(C)] #[derive(Debug, Copy, Clone, IntoBytes, Immutable, KnownLayout, FromBytes)] pub struct VpciIsolatedResourcesReply { diff --git a/vm/devices/pci/vpci_relay/src/lib.rs b/vm/devices/pci/vpci_relay/src/lib.rs index 6cf85f91881..e8ce3fe310d 100644 --- a/vm/devices/pci/vpci_relay/src/lib.rs +++ b/vm/devices/pci/vpci_relay/src/lib.rs @@ -64,7 +64,7 @@ use vpci_client::tdisp::TdispVpciAttestationInterface; #[expect(unused_imports)] use tdisp::TdispHostDeviceInterface; use tdisp::TdispIsolationReport; -use tdisp::TdispIsolationReporter; +use tdisp::TdispRelayedDeviceTarget; use tdisp::TdispTdiState; use tdisp::test_helpers::TDISP_MOCK_DEVICE_ID; use tdisp::test_helpers::TDISP_MOCK_GUEST_PROTOCOL; @@ -137,11 +137,8 @@ impl RelayedDevice { self.bus_unit.remove().await; self.device_unit.remove().await; - // Only devices that actually completed at least a Bind have a - // TDI on the host side to unbind. Non-TDISP devices stay in - // `Uninitialized` and must be left alone. `tdisp_unbind` on - // them would return a host error. - if self.vpci_device.tdisp_tdi_state().await != TdispTdiState::Uninitialized { + // Unbind any TDI state if the device is a TDISP device. + if self.vpci_device.tdisp_tdi_state().await != TdispTdiState::Unlocked { if let Err(err) = self .vpci_device .tdisp_unbind(tdisp::TdispGuestUnbindReason::DeviceTeardown) @@ -559,7 +556,7 @@ impl ChipsetDevice for RelayedVpciDevice { Some(self) } - fn supports_tdisp_isolation(&mut self) -> Option<&mut dyn TdispIsolationReporter> { + fn supports_tdisp_relay(&mut self) -> Option<&mut dyn TdispRelayedDeviceTarget> { Some(self) } @@ -582,7 +579,7 @@ impl PollDevice for RelayedVpciDevice { } } -impl TdispIsolationReporter for RelayedVpciDevice { +impl TdispRelayedDeviceTarget for RelayedVpciDevice { // Builds a report of what device resources for vpci device in a CVM are isolated or shared. fn tdisp_isolation_report( &mut self, @@ -613,10 +610,6 @@ impl PciConfigSpace for RelayedVpciDevice { fn pci_cfg_write(&mut self, offset: u16, value: ByteEnabledDwordWrite) -> IoResult { // Only a command register write that flips the MMIO-enable bit needs // async TDISP work. Everything else is a synchronous pass-through. - // - // This matters beyond efficiency: `probe_bar_masks` sizes the BARs from - // a synchronous context with no executor available, so it cannot honor - // a deferred write. if !self.tdisp_capable || HeaderType00(offset) != HeaderType00::STATUS_COMMAND { self.device.write_cfg(offset, value); return IoResult::Ok; @@ -625,57 +618,40 @@ impl PciConfigSpace for RelayedVpciDevice { // Detect the MMIO-enable edge BEFORE issuing the write so we can // dispatch the correct TDISP notification. // - // The STATUS_COMMAND dword packs the 16-bit Command register in the low - // two bytes and the 16-bit Status register in the high two bytes. Only - // the Command register is relevant here, so mask off the Status half - // before truncating to `u16`. + // The write contains both the Command and Status registers packed into + // a single 32-bit value. Only the Command register is relevant for + // detecting the MMIO-enable edge. use pci_core::spec::cfg_space::Command; let mut current = 0; self.device.read_cfg( offset, ByteEnabledDwordRead::with_all_bytes_enabled(&mut current), ); + 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) { - (false, true) => {} - (true, false) => { - // Once MMIO is on the TDI is bound and its ranges have been - // unblocked and accepted into the guest. Drop the write rather - // than letting the guest walk that back: the disable edge would - // unbind the device and re-block every range. - tracing::warn!( - ?offset, - ?value, - "dropping a config space write that would disable MMIO; the command \ - register does not transition back to off once it is on" - ); - return IoResult::Ok; - } - // No MMIO edge, so there is no TDISP notification to dispatch. - _ => { - self.device.write_cfg(offset, value); - return IoResult::Ok; - } + + // No change was detected, complete the request anyways. + if prev == next { + self.device.write_cfg(offset, value); + return IoResult::Ok; } let device = self.device.clone(); let fut = Box::pin(async move { - // Attest while the command register is still off. - // `tdisp_on_device_activate` enables the command register itself - // once attestation succeeds, so the BARs are mapped before it - // notifies TDISP of the MMIO ranges. + // Attest while the command register is still off, then turn it on + // after it succeeds. if !device.tdisp_on_device_activate(value).await { // The command register is left off if attestation failed. + // Otherwise, command register is enabled. tracing::warn!("TDISP attestation failed. Not enabling STATUS_COMMAND."); } }); - // Overwriting an in-flight deferral would drop its `DeferredWrite`, - // which the caller sees as `IoError::NoResponse`. Every caller waits for - // its own deferred write to complete, so this should not happen. + // Every caller waits for its own deferred write to complete, so this + // should not happen. debug_assert!( self.pending.is_none(), "config space write deferred while another deferred write is in flight" diff --git a/vm/devices/storage/nvme_test/src/pci.rs b/vm/devices/storage/nvme_test/src/pci.rs index 48b1b94b218..7fefe6e1cc5 100644 --- a/vm/devices/storage/nvme_test/src/pci.rs +++ b/vm/devices/storage/nvme_test/src/pci.rs @@ -510,7 +510,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" diff --git a/vm/devices/tdisp/src/lib.rs b/vm/devices/tdisp/src/lib.rs index 52ce5fa5fb9..f619afe871b 100644 --- a/vm/devices/tdisp/src/lib.rs +++ b/vm/devices/tdisp/src/lib.rs @@ -121,7 +121,7 @@ pub trait TdispHostDeviceInterface: Send + Sync { /// 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, @@ -136,15 +136,12 @@ pub trait TdispHostDeviceTarget: Send + Sync { /// without taking a dependency on `vpci_protocol`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TdispResourceIsolation { - /// Host-visible, bounce-buffered. + /// Host-visible and modifiable by the host. Shared, - /// Host-inaccessible after TDI validation; backed by guest-private memory. + /// Host-inaccessible after TDI validation and private to the guest. Private, - /// There is no resource here to classify: the device does not implement - /// this BAR, which includes the upper half of a 64-bit BAR since that is - /// not addressable in its own right, or the paravisor holds no interface - /// report for the device at all. A BAR the device does have but the report - /// omits is `Shared`, not this. + /// There is no resource here to classify. Either the BAR is invalid or part + /// of a 64-bit BAR. Invalid, } @@ -153,13 +150,10 @@ pub enum TdispResourceIsolation { /// server. #[derive(Debug, Clone, Copy)] pub enum TdispIsolationReport { - /// The chipset device wraps a non-TDISP device. The paravisor should - /// answer the guest query with all `Shared` + `SUCCESS`, matching the - /// host VSP's behavior for non-confidential VMs. + /// The chipset device wraps a non-TDISP device. NotTdispCapable, - /// The TDI is not in the Run state, or is in Run but no resource has - /// been unblocked yet. The paravisor should answer with an error - /// status; the guest may retry later. + /// The TDI is not in a state that it can respond to the isolation report + /// request. NotReady, /// The TDI is in Run and resources have been unblocked. The inner /// arrays give the six per-BAR classifications and the DMA @@ -175,16 +169,12 @@ pub enum TdispIsolationReport { Error, } -/// Trait added to chipset devices that want to report their VPCI -/// resource-isolation state on behalf of the guest-facing VPCI server. -pub trait TdispIsolationReporter: Send + Sync { - /// Return a snapshot of the current isolation state, suitable for - /// populating a `VpciIsolatedResourcesReply`. - /// - /// To retrieve the report, this may need to drive a fresh attestation cycle - /// (Unlocked -> Locked -> Run -> cached report -> Unlocked) before - /// answering. To avoid forcing callers to hold a sync device guard across - /// the await, this returns a `'static` boxed future. +/// 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>>; @@ -866,11 +856,10 @@ impl TdispGuestRequestInterface for TdispHostStateMachine { self.ensure_negotiated_protocol() .map_err(|_| TdispGuestOperationError::InvalidDeviceState)?; - // The guest device ID identifies the TDI rather than describing any - // attestation state, and the guest needs it before it can address the - // device in platform calls (for example to build a TDX Connect - // FUNCTION_ID ahead of the bind). Allow it in any state; every other - // report describes state that only exists once the TDI is Locked. + // 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 diff --git a/vm/devices/tdisp_proto/src/lib.rs b/vm/devices/tdisp_proto/src/lib.rs index f0f3a53bb87..f7c16591e49 100644 --- a/vm/devices/tdisp_proto/src/lib.rs +++ b/vm/devices/tdisp_proto/src/lib.rs @@ -148,11 +148,31 @@ impl GuestToHostResponseExt for GuestToHostResponse { } fn tdi_state_before_enum(&self) -> Option { - TdispTdiState::from_i32(self.tdi_state_before) + 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 { - TdispTdiState::from_i32(self.tdi_state_after) + 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 { 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"); } From e1a21ab2f195c04f4348c03cfbc8780260e3e25a Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Mon, 14 Sep 2026 14:26:04 -0700 Subject: [PATCH 15/31] tdisp: panic on all unbind paths if they fail --- vm/devices/pci/vpci_client/src/tdisp.rs | 71 +++++++++++++++---------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/vm/devices/pci/vpci_client/src/tdisp.rs b/vm/devices/pci/vpci_client/src/tdisp.rs index 20f502f1ba0..832ef02d622 100644 --- a/vm/devices/pci/vpci_client/src/tdisp.rs +++ b/vm/devices/pci/vpci_client/src/tdisp.rs @@ -598,18 +598,26 @@ impl VpciClientTdispState { /// 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. pub async fn tdisp_unbind(&mut self, reason: TdispGuestUnbindReason) -> anyhow::Result<()> { - // Flip all unblocked MMIO ranges and DMA back to shared before we tell - // the host to unbind the TDI. This is best-effort: a failure here is - // logged but doesn't abort the unbind. A new attestation won't proceed - // if all resources were not successfully torn down. let validator = self.resource_validator.clone(); let device_id = self.mutable_state.guest_device_id; - // The teardown below addresses a real TDI through the platform. Without - // an id there is nothing attested to tear down: no range was ever - // unblocked, DMA was never unblocked, and no report was ever recorded. + // 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 { @@ -623,9 +631,10 @@ impl VpciClientTdispState { TdispResourceIsolation::Private => {} } + // Block the MMIO range again to return it to shared isolation. let block_mmio_res = validator .tdisp_block_mmio( - Vtl::Vtl2, + self.target_vtl, raw_device_id, mmio.base_gpa, 0, @@ -642,35 +651,38 @@ impl VpciClientTdispState { error = &*e as &dyn std::error::Error, "tdisp_unbind: failed to re-block MMIO range" ); - } else { - // Tell the host only once the platform actually blocked the - // range, so the host's view never runs ahead of the platform's. - // Best-effort, like the block above. - if let Err(e) = self - .tdisp_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: {e}"); + } - // Successful re-block, remove the bar from the validated list. - self.mutable_state.validated_mmio_bars.remove(&bar_id); + // 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 + .tdisp_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(Vtl::Vtl2, raw_device_id) { + 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; @@ -695,11 +707,12 @@ impl VpciClientTdispState { .await?; if let Err(err) = res.response::() { - return Err(anyhow::anyhow!("error response in tdisp_unbind: {err}")); + std::panic!("tdisp_unbind: error response from host, cannot continue: {err}"); } // The TDI must be back in Unlocked, and the firmware has to agree that - // it actually came back rather than the host merely saying so. + // 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)?; Ok(()) From 3bb46c5f70823b25e665e27e06690cacff19c9b8 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Mon, 14 Sep 2026 14:29:07 -0700 Subject: [PATCH 16/31] tdisp: cleanup error handling/panic around unbind, report better errors to host --- openhcl/openhcl_tdisp/src/lib.rs | 5 +- vm/devices/pci/vpci_client/src/lib.rs | 28 ++--- vm/devices/pci/vpci_client/src/tdisp.rs | 145 ++++++++++++++++++------ vm/devices/pci/vpci_relay/src/lib.rs | 14 +-- vm/devices/tdisp_proto/src/tdisp.proto | 7 ++ 5 files changed, 131 insertions(+), 68 deletions(-) diff --git a/openhcl/openhcl_tdisp/src/lib.rs b/openhcl/openhcl_tdisp/src/lib.rs index cffb2cc2faf..102979c2566 100644 --- a/openhcl/openhcl_tdisp/src/lib.rs +++ b/openhcl/openhcl_tdisp/src/lib.rs @@ -90,10 +90,7 @@ pub trait TdispVirtualDeviceInterface: Send + Sync { fn tdisp_get_tdi_device_id(&self) -> impl Future> + Send; /// Request to unbind the device and return to the Unlocked state. - fn tdisp_unbind( - &self, - reason: TdispGuestUnbindReason, - ) -> impl Future> + Send; + fn tdisp_unbind(&self, reason: TdispGuestUnbindReason) -> impl Future + Send; /// Tell the host to block an MMIO range, reversing a previous unblock. The /// TDI must be Locked or Run. diff --git a/vm/devices/pci/vpci_client/src/lib.rs b/vm/devices/pci/vpci_client/src/lib.rs index ce178b784cb..a9a51eb9a61 100644 --- a/vm/devices/pci/vpci_client/src/lib.rs +++ b/vm/devices/pci/vpci_client/src/lib.rs @@ -14,6 +14,7 @@ mod tests; pub use tdisp::VpciClientTdispState; +use ::tdisp::TdispGuestUnbindReason; use anyhow::Context; use chipset_device::pci::ByteEnabledDwordRead; use chipset_device::pci::ByteEnabledDwordWrite; @@ -612,7 +613,6 @@ impl VpciDevice { error = &*err as &dyn std::error::Error, "tdisp_on_device_activate: attestation failed, leaving command register off" ); - self.tdisp_unbind_resources().await; return false; } @@ -658,7 +658,8 @@ impl VpciDevice { error = %e, "failed to notify TDISP of active MMIO BAR. Failing activation." ); - self.tdisp_unbind_resources().await; + self.tdisp_unbind_resources(TdispGuestUnbindReason::ResourceSetupFailure) + .await; return false; } } @@ -672,27 +673,17 @@ impl VpciDevice { /// 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) { - use openhcl_tdisp::TdispGuestUnbindReason; + async fn tdisp_unbind_resources(&self, reason: TdispGuestUnbindReason) { use openhcl_tdisp::TdispVirtualDeviceInterface; tracing::error!( "tdisp_unbind_resources: unbinding TDI back to Unlocked due to device deactivation or attestation failure" ); - let unbind_result = self - .tdisp_unbind(TdispGuestUnbindReason::ResourceSetupFailure) - .await; - - // Unbind failing means that the device is in a broken state. Leave the - // command register off and leave the device as-is. Future attestation - // during device enablement might not work. - if let Err(unbind_err) = &unbind_result { - tracing::warn!( - error = unbind_err.as_ref() as &dyn std::error::Error, - "tdisp_unbind_resources: unbind failed" - ); - } + // 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. @@ -705,7 +696,8 @@ impl VpciDevice { /// 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().await; + self.tdisp_unbind_resources(TdispGuestUnbindReason::Graceful) + .await; } } diff --git a/vm/devices/pci/vpci_client/src/tdisp.rs b/vm/devices/pci/vpci_client/src/tdisp.rs index 832ef02d622..15e6970c52a 100644 --- a/vm/devices/pci/vpci_client/src/tdisp.rs +++ b/vm/devices/pci/vpci_client/src/tdisp.rs @@ -16,7 +16,6 @@ use openhcl_tdisp::TdispCommandResponseGetDeviceInterfaceInfo; use openhcl_tdisp::TdispCommandResponseGetTdiReport; use openhcl_tdisp::TdispCommandResponseModifyMmioRange; use openhcl_tdisp::TdispCommandResponseStartTdi; -use openhcl_tdisp::TdispCommandResponseUnbind; use openhcl_tdisp::TdispDeviceInterfaceInfo; use openhcl_tdisp::TdispGuestOperationErrorCode; use openhcl_tdisp::TdispGuestProtocolType; @@ -117,6 +116,11 @@ impl VpciClientTdispMutableState { } } +struct SetupDeviceFailure { + reason: TdispGuestUnbindReason, + message: String, +} + /// TDISP state for a VPCI device. #[derive(Inspect)] pub struct VpciClientTdispState { @@ -194,11 +198,7 @@ impl VpciClientTdispState { /// * `expected` - The state the TDI must be in. /// * `device_id` - Identifies the TDI device (not a VPCI ID). Only valid if /// the platform supports reporting its own TDI state. - fn require_tdi_state( - &self, - expected: TdispTdiState, - device_id: TdispDeviceId, - ) -> anyhow::Result<()> { + 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, @@ -207,7 +207,9 @@ impl VpciClientTdispState { Some(device_id) => self .resource_validator .get_tsm_tdi_state(self.target_vtl, device_id) - .context("require_tdi_state: failed to read the TDI state from the firmware")?, + .unwrap_or_else(|e| { + panic!("require_tdi_state: failed to read the TDI state from the firmware: {e}") + }), None => None, }; @@ -239,8 +241,6 @@ impl VpciClientTdispState { checking the host's answer alone" ), } - - Ok(()) } pub(super) async fn send_tdisp_command( @@ -612,7 +612,7 @@ impl VpciClientTdispState { /// the unbind request. If the guest asking the trusted firmware disagrees /// with the state the host advertised after unbind, the function will /// panic. - pub async fn tdisp_unbind(&mut self, reason: TdispGuestUnbindReason) -> anyhow::Result<()> { + pub async fn tdisp_unbind(&mut self, reason: TdispGuestUnbindReason) { let validator = self.resource_validator.clone(); let device_id = self.mutable_state.guest_device_id; @@ -704,18 +704,20 @@ impl VpciClientTdispState { self.vpci_device_id, reason, )) - .await?; + .await; - if let Err(err) = res.response::() { - std::panic!("tdisp_unbind: error response from host, cannot continue: {err}"); + 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)?; - - Ok(()) + self.require_tdi_state(TdispTdiState::Unlocked, device_id); } /// Detects TDISP capabilities for the device. If the device supports TDISP @@ -787,6 +789,39 @@ impl VpciClientTdispState { /// /// * `interface_info` - The negotiated capabilities for this device. pub async fn attest(&mut self, interface_info: TdispDeviceInterfaceInfo) -> anyhow::Result<()> { + 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.tdisp_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" @@ -802,9 +837,7 @@ impl VpciClientTdispState { current_state = %self.tdi_state(), "tdisp_attest_device: TDI not in Unlocked, unbinding before rebind" ); - self.tdisp_unbind(TdispGuestUnbindReason::Graceful) - .await - .context("tdisp_attest_device: failed to unbind device from running state")?; + self.tdisp_unbind(TdispGuestUnbindReason::Graceful).await; } // If there are *still* any attestation artifacts after unbind, @@ -813,9 +846,10 @@ impl VpciClientTdispState { || self.mutable_state.dma_unblocked || !self.mutable_state.validated_mmio_bars.is_empty() { - anyhow::bail!( - "tdisp_attest_device: failed to clear existing attestation state, cannot proceed with new attestation" - ); + return Err(SetupDeviceFailure { + reason: TdispGuestUnbindReason::StartupFailure, + message: "tdisp_attest_device: failed to clear existing attestation state, cannot proceed with new attestation".to_string(), + }); } // Request the guest device ID before binding so the pre-bind and @@ -823,51 +857,89 @@ impl VpciClientTdispState { let guest_device_id = self .tdisp_get_tdi_device_id() .await - .context("tdisp_attest_device: failed to get TDI device ID before binding device")?; + .context("tdisp_attest_device: failed to get TDI device ID before binding device") + .map_err(|e| SetupDeviceFailure { + reason: TdispGuestUnbindReason::StartupFailure, + message: format!( + "tdisp_attest_device: failed to get TDI device ID before binding device: {}", + e + ), + })?; // Platforms require a u16 device ID even though the report returns a // u64. Ensure the returned device ID 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")?; + .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.resource_validator .on_pre_bind(self.target_vtl, guest_device_id_u16) - .context("tdisp_attest_device: pre-bind validation failed")?; + .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.tdisp_bind_interface() .await - .context("tdisp_attest_device: failed to bind device interface")?; + .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), - ) - .context("tdisp_attest_device: failed to confirm the TDI is Locked after the bind")?; + ); self.resource_validator .on_pre_start(self.target_vtl, guest_device_id_u16) - .context("tdisp_attest_device: pre-start validation failed")?; + .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.tdisp_start_device() .await - .context("tdisp_attest_device: failed to start device")?; + .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), - ) - .context("tdisp_attest_device: failed to confirm the TDI is in Run after the start")?; + ); self.resource_validator .on_post_start(self.target_vtl, guest_device_id_u16) - .context("tdisp_attest_device: post-start validation failed")?; + .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.tdisp_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, @@ -1205,7 +1277,7 @@ impl TdispVirtualDeviceInterface for VpciDevice { guard.tdisp_get_tdi_device_id().await } - async fn tdisp_unbind(&self, reason: TdispGuestUnbindReason) -> anyhow::Result<()> { + async fn tdisp_unbind(&self, reason: TdispGuestUnbindReason) { let mut guard = self.tdisp.0.lock().await; guard.tdisp_unbind(reason).await } @@ -1329,6 +1401,9 @@ impl VpciDevice { /// Return a classification of BAR and DMA isolation for this device, /// suitable for answering `VPCI_QUERY_ISOLATED_RESOURCES` on the /// guest-facing VPCI channel. + /// + /// If the device is not yet attested, this forces an attestation in order + /// to retrieve the validated report. pub async fn tdisp_isolation_snapshot(&self) -> TdispIsolationReport { let mut guard = self.tdisp.0.lock().await; diff --git a/vm/devices/pci/vpci_relay/src/lib.rs b/vm/devices/pci/vpci_relay/src/lib.rs index e8ce3fe310d..c50ef7aff17 100644 --- a/vm/devices/pci/vpci_relay/src/lib.rs +++ b/vm/devices/pci/vpci_relay/src/lib.rs @@ -139,17 +139,9 @@ impl RelayedDevice { // Unbind any TDI state if the device is a TDISP device. if self.vpci_device.tdisp_tdi_state().await != TdispTdiState::Unlocked { - if let Err(err) = self - .vpci_device + self.vpci_device .tdisp_unbind(tdisp::TdispGuestUnbindReason::DeviceTeardown) - .await - { - tracing::warn!( - bus_instance_id = %self.bus_instance_id, - error = &*err as &dyn std::error::Error, - "tdisp_unbind during relay teardown failed" - ); - } + .await; } self.bus_client.shutdown().await; @@ -469,7 +461,7 @@ impl VpciRelay { "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::Uninitialized); + assert_eq!(device.tdisp_tdi_state().await, TdispTdiState::Unlocked); let device_interface_info = device .tdisp_get_device_interface_info(TDISP_MOCK_GUEST_PROTOCOL) diff --git a/vm/devices/tdisp_proto/src/tdisp.proto b/vm/devices/tdisp_proto/src/tdisp.proto index 654db625727..f7f5cbaf02a 100644 --- a/vm/devices/tdisp_proto/src/tdisp.proto +++ b/vm/devices/tdisp_proto/src/tdisp.proto @@ -87,6 +87,13 @@ enum TdispGuestUnbindReason { // 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). From 188748301abe94d1ac83d3b5a757c320a8192307 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Mon, 14 Sep 2026 15:14:36 -0700 Subject: [PATCH 17/31] allow fast path device attesation without a full bind/unbind cycle if the device is already functional --- vm/devices/pci/vpci_client/src/lib.rs | 1 - vm/devices/pci/vpci_client/src/tdisp.rs | 27 ++++++++++++++++- vm/devices/pci/vpci_relay/src/lib.rs | 39 +++++++++++++++---------- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/vm/devices/pci/vpci_client/src/lib.rs b/vm/devices/pci/vpci_client/src/lib.rs index a9a51eb9a61..7cd7401d4bc 100644 --- a/vm/devices/pci/vpci_client/src/lib.rs +++ b/vm/devices/pci/vpci_client/src/lib.rs @@ -598,7 +598,6 @@ impl VpciDevice { 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 diff --git a/vm/devices/pci/vpci_client/src/tdisp.rs b/vm/devices/pci/vpci_client/src/tdisp.rs index 15e6970c52a..5059582b42f 100644 --- a/vm/devices/pci/vpci_client/src/tdisp.rs +++ b/vm/devices/pci/vpci_client/src/tdisp.rs @@ -210,7 +210,9 @@ impl VpciClientTdispState { .unwrap_or_else(|e| { panic!("require_tdi_state: failed to read the TDI state from the firmware: {e}") }), - None => None, + None => std::panic!( + "require_tdi_state: device ID wasn't assigned when calling require_tdi_state" + ), }; if cached != expected { @@ -789,6 +791,29 @@ impl VpciClientTdispState { /// /// * `interface_info` - The negotiated capabilities for this device. pub 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 and we've + // ensured our internal state reflects that the device is indeed in an operational 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" + ); + + assert!( + self.mutable_state.dma_unblocked, + "tdisp::attest: fast path: DMA should be unblocked when device is already in `Run` state" + ); + + assert!( + !self.mutable_state.validated_mmio_bars.is_empty(), + "tdisp::attest: fast path: At least one MMIO BAR should be validated when device is already in `Run` state" + ); + + return Ok(()); + } + let attestation_result = self.setup_and_attest(interface_info).await; match attestation_result { diff --git a/vm/devices/pci/vpci_relay/src/lib.rs b/vm/devices/pci/vpci_relay/src/lib.rs index c50ef7aff17..d649f8f57b6 100644 --- a/vm/devices/pci/vpci_relay/src/lib.rs +++ b/vm/devices/pci/vpci_relay/src/lib.rs @@ -376,6 +376,8 @@ impl VpciRelay { Self::tdisp_test_mock_flow(vpci_device.clone()) .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 { @@ -625,26 +627,33 @@ impl PciConfigSpace for RelayedVpciDevice { // command register untouched yields `next` equal to `prev`. let next = Command::from((value.merge(current) & 0xffff) as u16).mmio_enabled(); - // No change was detected, complete the request anyways. - if prev == next { - self.device.write_cfg(offset, value); - return IoResult::Ok; - } - let device = self.device.clone(); - let fut = Box::pin(async move { - // Attest while the command register is still off, then turn it on - // after it succeeds. - if !device.tdisp_on_device_activate(value).await { - // The command register is left off if attestation failed. - // Otherwise, command register is enabled. - tracing::warn!("TDISP attestation failed. Not enabling STATUS_COMMAND."); + + let fut: Pin + Send + Sync>> = 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. + (false, true) => 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 itself. + (true, false) => 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); + return IoResult::Ok; } - }); + }; // Every caller waits for its own deferred write to complete, so this // should not happen. - debug_assert!( + assert!( self.pending.is_none(), "config space write deferred while another deferred write is in flight" ); From 0f0c3aee2ce3085ec070c9a7ede9006ca2ad9428 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Mon, 14 Sep 2026 15:57:49 -0700 Subject: [PATCH 18/31] vpci_relay: serialize config space writes around TDISP operations --- Cargo.lock | 8 + vm/devices/pci/vpci_client/src/tdisp.rs | 14 +- vm/devices/pci/vpci_relay/Cargo.toml | 10 +- vm/devices/pci/vpci_relay/src/lib.rs | 256 +++++++++---- vm/devices/pci/vpci_relay/src/tests.rs | 487 ++++++++++++++++++++++++ 5 files changed, 694 insertions(+), 81 deletions(-) create mode 100644 vm/devices/pci/vpci_relay/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index ff930441153..3bc0061e294 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11067,23 +11067,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/vm/devices/pci/vpci_client/src/tdisp.rs b/vm/devices/pci/vpci_client/src/tdisp.rs index 5059582b42f..6d0a700e3ec 100644 --- a/vm/devices/pci/vpci_client/src/tdisp.rs +++ b/vm/devices/pci/vpci_client/src/tdisp.rs @@ -792,8 +792,8 @@ impl VpciClientTdispState { /// * `interface_info` - The negotiated capabilities for this device. pub 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 and we've - // ensured our internal state reflects that the device is indeed in an operational state. + // 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); @@ -801,16 +801,6 @@ impl VpciClientTdispState { "tdisp::attest: fast path: device already in `Run` state, skipping initial bind/attest cycle" ); - assert!( - self.mutable_state.dma_unblocked, - "tdisp::attest: fast path: DMA should be unblocked when device is already in `Run` state" - ); - - assert!( - !self.mutable_state.validated_mmio_bars.is_empty(), - "tdisp::attest: fast path: At least one MMIO BAR should be validated when device is already in `Run` state" - ); - return Ok(()); } diff --git a/vm/devices/pci/vpci_relay/Cargo.toml b/vm/devices/pci/vpci_relay/Cargo.toml index 734da3a2e2f..e17a89af825 100644 --- a/vm/devices/pci/vpci_relay/Cargo.toml +++ b/vm/devices/pci/vpci_relay/Cargo.toml @@ -38,8 +38,16 @@ 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 d649f8f57b6..04c27aa130c 100644 --- a/vm/devices/pci/vpci_relay/src/lib.rs +++ b/vm/devices/pci/vpci_relay/src/lib.rs @@ -12,6 +12,8 @@ #[cfg(target_os = "linux")] pub mod linux_mmio; +mod tests; + // Exported to make it easier to define filters without explicitly pulling in // `pci_core`. pub use pci_core::spec::hwid::ClassCode; @@ -22,6 +24,7 @@ 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; @@ -35,6 +38,7 @@ use openhcl_tdisp::TdispVirtualDeviceInterface; 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; @@ -404,6 +408,7 @@ impl VpciRelay { Ok(RelayedVpciDevice { device: vpci_device.clone(), pending: None, + queued: VecDeque::new(), waker: Waker::noop().clone(), tdisp_capable, }) @@ -528,16 +533,23 @@ struct RelayedVpciDevice { #[inspect(flatten)] device: Arc, - /// In-flight deferred config space write. Driven by [`PollDevice`]. + /// 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>>, )>, - /// Waker captured from the most recent `PollDevice::poll_device` call. - /// We wake it from `pci_cfg_write` when we install a new pending future - /// so the chipset device unit re-polls us. + /// Config space writes that arrived while a TDISP 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 a TDISP operation has been started. #[inspect(skip)] waker: Waker, @@ -545,6 +557,136 @@ struct RelayedVpciDevice { tdisp_capable: bool, } +/// 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 crossed an MMIO-enable edge. The future carries out the TDISP + /// work the edge requires, including writing the command register itself, + /// and must run to completion before any further config space write reaches + /// the device. + Started(Pin + Send + Sync>>), +} + +impl RelayedVpciDevice { + /// Applies a config space write that has no TDISP operation ahead of it, + /// either passing it through to the device or producing the TDISP work the + /// write requires. + /// + /// `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 BEFORE issuing the write so we can + // dispatch the correct TDISP notification. + // + // The write contains both the Command and Status registers packed into + // a single 32-bit value. Only the Command register is relevant for + // detecting the MMIO-enable edge. + use pci_core::spec::cfg_space::Command; + let mut current = 0; + self.device.read_cfg( + offset, + ByteEnabledDwordRead::with_all_bytes_enabled(&mut current), + ); + + 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. + (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 itself. + (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(); + } + + /// 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; + } + } + } + } +} + impl ChipsetDevice for RelayedVpciDevice { fn supports_pci(&mut self) -> Option<&mut dyn PciConfigSpace> { Some(self) @@ -562,13 +704,16 @@ impl ChipsetDevice for RelayedVpciDevice { impl PollDevice for RelayedVpciDevice { fn poll_device(&mut self, cx: &mut std::task::Context<'_>) { self.waker = cx.waker().clone(); - if let Some((_, fut)) = self.pending.as_mut() { - if fut.as_mut().poll(cx).is_ready() { - // Future done; complete the deferred write so the bus can - // continue draining any queued config writes. - let (deferred, _) = self.pending.take().expect("just checked"); - deferred.complete(); + while let Some((_, fut)) = self.pending.as_mut() { + if fut.as_mut().poll(cx).is_pending() { + break; } + // The operation is done. Release the write that started it, then + // let the writes that queued up behind it through. If one of those + // starts another operation, the loop picks it up here. + let (deferred, _) = self.pending.take().expect("just checked"); + deferred.complete(); + self.drain_queued(); } } } @@ -602,73 +747,48 @@ impl PciConfigSpace for RelayedVpciDevice { } fn pci_cfg_write(&mut self, offset: u16, value: ByteEnabledDwordWrite) -> IoResult { - // 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 IoResult::Ok; + // A TDISP operation has to run to completion with nothing else touching + // the device's config space, so every write that arrives while one is in + // flight waits, whatever register it targets. Writes arriving behind an + // already queued write wait too, so that they are 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); } - // Detect the MMIO-enable edge BEFORE issuing the write so we can - // dispatch the correct TDISP notification. - // - // The write contains both the Command and Status registers packed into - // a single 32-bit value. Only the Command register is relevant for - // detecting the MMIO-enable edge. - use pci_core::spec::cfg_space::Command; - let mut current = 0; - self.device.read_cfg( - offset, - ByteEnabledDwordRead::with_all_bytes_enabled(&mut current), - ); - - 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(); - - let device = self.device.clone(); - - let fut: Pin + Send + Sync>> = 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. - (false, true) => 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 itself. - (true, false) => 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); - return IoResult::Ok; + 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) } - }; - - // Every caller waits for its own deferred write to complete, so this - // should not happen. - assert!( - self.pending.is_none(), - "config space write deferred while another deferred write is in flight" - ); - - let (write, token) = chipset_device::io::deferred::defer_write(); - self.pending = Some((write, fut)); - self.waker.wake_by_ref(); - 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 the TDISP + // operation and everything queued behind it 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/tests.rs b/vm/devices/pci/vpci_relay/src/tests.rs new file mode 100644 index 00000000000..a8d737d652d --- /dev/null +++ b/vm/devices/pci/vpci_relay/src/tests.rs @@ -0,0 +1,487 @@ +// 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::TdispDeviceInterfaceInfo; +use tdisp::TdispGuestProtocolType; +use tdisp::TdispHostDeviceInterface; +use tdisp::TdispHostDeviceTargetEmulator; +use tdisp::TdispMmioRangeAction; +use tdisp::TdispReportType; +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 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; +use vpci_client::tdisp::TdispVpciAttestationInterface; + +/// 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 host side of the TDISP interface for the emulated device. It accepts +/// every lifecycle transition and hands back the reports an attestation needs, +/// so that a TDISP operation started from config space runs to completion. +struct AttestingHostInterface; + +impl TdispHostDeviceInterface for AttestingHostInterface { + 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<()> { + 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()), + // A well-formed report header describing a TDI with no MMIO + // ranges, which is all the device implements. + TdispReportType::InterfaceReport => Ok(vec![0; 16]), + other => anyhow::bail!("unexpected report type requested: {other:?}"), + } + } + + fn tdisp_modify_mmio_range( + &mut self, + _action: TdispMmioRangeAction, + _range_id: u16, + _gpa_base: u64, + _range_len_bytes: u64, + ) -> anyhow::Result<()> { + Ok(()) + } +} + +/// 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: TdispHostDeviceTargetEmulator::new( + Arc::new(Mutex::new(AttestingHostInterface)), + "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, + TEST_VTOM, + 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(); +} From c5fde76dbe89fd1eb3e6fda550c613d486e00a21 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Tue, 15 Sep 2026 10:25:50 -0700 Subject: [PATCH 19/31] tdisp: accept attestation and startup failure as guest unbind reasons --- vm/devices/tdisp/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vm/devices/tdisp/src/lib.rs b/vm/devices/tdisp/src/lib.rs index f619afe871b..b43663122c7 100644 --- a/vm/devices/tdisp/src/lib.rs +++ b/vm/devices/tdisp/src/lib.rs @@ -911,9 +911,9 @@ impl TdispGuestRequestInterface for TdispHostStateMachine { let reason = match reason { TdispGuestUnbindReason::Graceful | TdispGuestUnbindReason::DeviceTeardown - | TdispGuestUnbindReason::ResourceSetupFailure => { - TdispUnbindReason::GuestInitiated(reason) - } + | TdispGuestUnbindReason::ResourceSetupFailure + | TdispGuestUnbindReason::AttestationFailure + | TdispGuestUnbindReason::StartupFailure => TdispUnbindReason::GuestInitiated(reason), _ => { tracing::error!( "Invalid guest unbind reason {} requested", From e2faed23d7d0359900b10c639995719ffcc380f5 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Tue, 15 Sep 2026 10:29:01 -0700 Subject: [PATCH 20/31] vpci: reject TDISP commands from guests that negotiated below RB --- vm/devices/pci/vpci/src/device.rs | 86 ++++++++++++++++++++++++++++--- vm/devices/tdisp/src/lib.rs | 6 +-- 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/vm/devices/pci/vpci/src/device.rs b/vm/devices/pci/vpci/src/device.rs index 6d1b064b7d4..7703036a67a 100644 --- a/vm/devices/pci/vpci/src/device.rs +++ b/vm/devices/pci/vpci/src/device.rs @@ -1063,6 +1063,23 @@ impl ReadyState { 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) => { @@ -2013,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, @@ -2031,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(); @@ -2778,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; @@ -2815,6 +2859,32 @@ mod tests { } } + /// 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`. /// diff --git a/vm/devices/tdisp/src/lib.rs b/vm/devices/tdisp/src/lib.rs index b43663122c7..eb6755f62b5 100644 --- a/vm/devices/tdisp/src/lib.rs +++ b/vm/devices/tdisp/src/lib.rs @@ -155,9 +155,9 @@ pub enum TdispIsolationReport { /// The TDI is not in a state that it can respond to the isolation report /// request. NotReady, - /// The TDI is in Run and resources have been unblocked. The inner - /// arrays give the six per-BAR classifications and the DMA - /// classification. Guaranteed to contain only `Shared` / `Private`. + /// 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], From 1cd42b028116c48411330c7f8278e35093d2ada3 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Tue, 15 Sep 2026 10:32:17 -0700 Subject: [PATCH 21/31] fix build errors, fix device id checking --- vm/devices/pci/vpci_client/src/tdisp.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/vm/devices/pci/vpci_client/src/tdisp.rs b/vm/devices/pci/vpci_client/src/tdisp.rs index 6d0a700e3ec..d87f93c46bc 100644 --- a/vm/devices/pci/vpci_client/src/tdisp.rs +++ b/vm/devices/pci/vpci_client/src/tdisp.rs @@ -210,9 +210,17 @@ impl VpciClientTdispState { .unwrap_or_else(|e| { panic!("require_tdi_state: failed to read the TDI state from the firmware: {e}") }), - None => std::panic!( - "require_tdi_state: device ID wasn't assigned when calling require_tdi_state" - ), + 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 { @@ -894,6 +902,9 @@ impl VpciClientTdispState { ), })?; + 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") @@ -962,9 +973,6 @@ impl VpciClientTdispState { "tdisp_attest_device: device attestation flow completed successfully, waiting on resources to be assigned" ); - self.mutable_state - .update_guest_device_id(TdispDeviceId::Valid(guest_device_id_u16)); - // Hand the report to the validator before any resource is unblocked. self.resource_validator .tdisp_set_tdi_report(guest_device_id_u16, &tdi_report); From 605432b5fea4fcfda0b7afb860d354be2b8c2206 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Tue, 15 Sep 2026 12:47:25 -0700 Subject: [PATCH 22/31] vpci_relay: create a TDISP resource validator per relayed device --- vm/devices/pci/vpci_relay/src/lib.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/vm/devices/pci/vpci_relay/src/lib.rs b/vm/devices/pci/vpci_relay/src/lib.rs index 04c27aa130c..d825e626a34 100644 --- a/vm/devices/pci/vpci_relay/src/lib.rs +++ b/vm/devices/pci/vpci_relay/src/lib.rs @@ -33,8 +33,8 @@ use futures::StreamExt as _; use inspect::Inspect; use inspect::InspectMut; use memory_range::MemoryRange; -use openhcl_tdisp::TdispResourceValidationInterface; 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; @@ -114,8 +114,6 @@ pub struct VpciRelay { vtom: Option, isolation_type: IsolationType, options: VpciRelayOptions, - #[inspect(skip)] - resource_validator: Arc, } #[derive(Inspect)] @@ -212,7 +210,6 @@ impl VpciRelay { dma_client: Arc, mmio_range: MemoryRange, mmio_access: Box, - resource_validator: Arc, isolation_type: IsolationType, vtom: Option, options: VpciRelayOptions, @@ -242,7 +239,6 @@ impl VpciRelay { allowed_devices: Vec::new(), vtom: target_vtom, isolation_type: target_isolation_type, - resource_validator, options, } } @@ -361,9 +357,19 @@ impl VpciRelay { tracing::info!(%instance_id, vendor_id = hw_ids.vendor_id, device_id = hw_ids.device_id, "vpci relay device arrived"); + // Each device gets a validator of its own. The validators keep + // per-device state that is not keyed by device ID, and on some + // platforms they hold a firmware handle, so sharing one across devices + // would let them overwrite each other's resource state. Built after the + // allowed-device filter so a device the relay is about to reject never + // takes a handle. + 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( - self.resource_validator.clone(), + resource_validator, self.isolation_type, self.vtom.unwrap_or(0), hvdef::Vtl::Vtl0, From 696d5ef6e8c6ef459dad484f1d47675896bf0d37 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Tue, 15 Sep 2026 12:49:53 -0700 Subject: [PATCH 23/31] underhill_core: stop building a shared TDISP resource validator --- Cargo.lock | 1 - openhcl/underhill_core/Cargo.toml | 1 - openhcl/underhill_core/src/worker.rs | 7 ------- 3 files changed, 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3bc0061e294..675b39c72b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9072,7 +9072,6 @@ dependencies = [ "nvme_spec", "openhcl_attestation_protocol", "openhcl_dma_manager", - "openhcl_tdisp", "pal", "pal_async", "pal_uring", diff --git a/openhcl/underhill_core/Cargo.toml b/openhcl/underhill_core/Cargo.toml index ded6d3dedf9..42132aa7819 100644 --- a/openhcl/underhill_core/Cargo.toml +++ b/openhcl/underhill_core/Cargo.toml @@ -82,7 +82,6 @@ netvsp.workspace = true nvme_driver.workspace = true nvme_resources.workspace = true openhcl_dma_manager.workspace = true -openhcl_tdisp.workspace = true scsi_core.workspace = true scsidisk.workspace = true scsidisk_resources.workspace = true diff --git a/openhcl/underhill_core/src/worker.rs b/openhcl/underhill_core/src/worker.rs index 30c8d7e467f..bb23bc6d141 100644 --- a/openhcl/underhill_core/src/worker.rs +++ b/openhcl/underhill_core/src/worker.rs @@ -3384,12 +3384,6 @@ async fn new_underhill_vm( use vpci_relay::*; - // Choose the appropriate TDISP resource validator based on the - // isolation type, VTOM, and whether we're in a test - // environment. - let resource_validator = - openhcl_tdisp::new_resource_validator(isolation, vtom, test_tdisp_flow)?; - let mut relay = VpciRelay::new( driver_source.clone(), vpci_filter.take(), @@ -3416,7 +3410,6 @@ async fn new_underhill_vm( .context("failed to create direct mmio accessor")?, ) }, - resource_validator, isolation, vtom, VpciRelayOptions { From 0c6f1289a1b3af40ced89c62413dfaf6678512ff Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Tue, 15 Sep 2026 13:19:11 -0700 Subject: [PATCH 24/31] vpci_client: take the TDI device id from the negotiated interface info --- vm/devices/pci/vpci_client/src/tdisp.rs | 45 +++++-------------------- 1 file changed, 8 insertions(+), 37 deletions(-) diff --git a/vm/devices/pci/vpci_client/src/tdisp.rs b/vm/devices/pci/vpci_client/src/tdisp.rs index d87f93c46bc..114a9ba9a41 100644 --- a/vm/devices/pci/vpci_client/src/tdisp.rs +++ b/vm/devices/pci/vpci_client/src/tdisp.rs @@ -485,22 +485,6 @@ impl VpciClientTdispState { Ok(report) } - /// Fetch the device's TDI device id, which identifies the TDI in platform - /// calls. Available in any TDI state, unlike the other reports. - pub async fn tdisp_get_tdi_device_id(&mut 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())) - } - /// Tell the host to block an MMIO range, reversing a previous unblock. /// This only notifies the host; the platform-side block is separate. /// @@ -875,23 +859,15 @@ impl VpciClientTdispState { }); } - // Request the guest device ID before binding so the pre-bind and - // pre-start validator hooks can identify the TDI they are gating. - let guest_device_id = self - .tdisp_get_tdi_device_id() - .await - .context("tdisp_attest_device: failed to get TDI device ID before binding device") - .map_err(|e| SetupDeviceFailure { - reason: TdispGuestUnbindReason::StartupFailure, - message: format!( - "tdisp_attest_device: failed to get TDI device ID before binding device: {}", - e - ), - })?; + // 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 report returns a - // u64. Ensure the returned device ID fits within that constraint before - // proceeding. + // 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 { @@ -1295,11 +1271,6 @@ impl TdispVirtualDeviceInterface for VpciDevice { guard.tdisp_get_tdi_report().await } - async fn tdisp_get_tdi_device_id(&self) -> anyhow::Result { - let mut guard = self.tdisp.0.lock().await; - guard.tdisp_get_tdi_device_id().await - } - async fn tdisp_unbind(&self, reason: TdispGuestUnbindReason) { let mut guard = self.tdisp.0.lock().await; guard.tdisp_unbind(reason).await From b73d6fa85fdb0cbfa9823fe6df4480c814e1982f Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Tue, 15 Sep 2026 13:19:49 -0700 Subject: [PATCH 25/31] openhcl_tdisp: drop the TDI device id accessor from the virtual device interface --- openhcl/openhcl_tdisp/src/lib.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/openhcl/openhcl_tdisp/src/lib.rs b/openhcl/openhcl_tdisp/src/lib.rs index 102979c2566..15db5ccafac 100644 --- a/openhcl/openhcl_tdisp/src/lib.rs +++ b/openhcl/openhcl_tdisp/src/lib.rs @@ -86,9 +86,6 @@ pub trait TdispVirtualDeviceInterface: Send + Sync { /// Request a TDI report from the TDI or physical device. fn tdisp_get_tdi_report(&self) -> impl Future> + Send; - /// Request the TDI device id from the vpci channel. - fn tdisp_get_tdi_device_id(&self) -> impl Future> + Send; - /// Request to unbind the device and return to the Unlocked state. fn tdisp_unbind(&self, reason: TdispGuestUnbindReason) -> impl Future + Send; From c7ec9d655c014342523442891be2f0c0df909c6c Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Wed, 16 Sep 2026 12:47:49 -0700 Subject: [PATCH 26/31] tdisp: return well-formed reports from the mock host interface --- vm/devices/tdisp/src/devicereport.rs | 4 ++++ vm/devices/tdisp/src/test_helpers.rs | 15 ++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/vm/devices/tdisp/src/devicereport.rs b/vm/devices/tdisp/src/devicereport.rs index 09f58c8c2fe..5d4c16d3e9d 100644 --- a/vm/devices/tdisp/src/devicereport.rs +++ b/vm/devices/tdisp/src/devicereport.rs @@ -85,6 +85,10 @@ 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 { diff --git a/vm/devices/tdisp/src/test_helpers.rs b/vm/devices/tdisp/src/test_helpers.rs index 2ad8ecba26f..9c576e07603 100644 --- a/vm/devices/tdisp/src/test_helpers.rs +++ b/vm/devices/tdisp/src/test_helpers.rs @@ -3,6 +3,7 @@ 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; @@ -45,11 +46,15 @@ impl TdispHostDeviceInterface for NullTdispHostInterface { Ok(()) } - fn tdisp_get_device_report( - &mut self, - _report_type: TdispReportType, - ) -> anyhow::Result> { - Ok(vec![]) + 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( From b106cbb0c2b829d2e2264630d17bda9c71f32c31 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Wed, 16 Sep 2026 12:48:11 -0700 Subject: [PATCH 27/31] vpci_relay: use the shared mock TDISP host interface in tests --- vm/devices/pci/vpci_relay/src/lib.rs | 70 ++++++++++++-------------- vm/devices/pci/vpci_relay/src/tests.rs | 65 +----------------------- vm/devices/tdisp/src/lib.rs | 4 +- 3 files changed, 35 insertions(+), 104 deletions(-) diff --git a/vm/devices/pci/vpci_relay/src/lib.rs b/vm/devices/pci/vpci_relay/src/lib.rs index d825e626a34..aacab78b6dd 100644 --- a/vm/devices/pci/vpci_relay/src/lib.rs +++ b/vm/devices/pci/vpci_relay/src/lib.rs @@ -45,6 +45,12 @@ 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 tdisp::test_helpers::TDISP_MOCK_DEVICE_ID; +use tdisp::test_helpers::TDISP_MOCK_GUEST_PROTOCOL; +use tdisp::test_helpers::TDISP_MOCK_SUPPORTED_FEATURES; use user_driver::DmaClient; use virt::IsolationType; use vmbus_client::driver::OpenParams; @@ -64,16 +70,6 @@ use vpci_client::VpciDevice; use vpci_client::VpciDeviceEject; use vpci_client::tdisp::TdispVpciAttestationInterface; -/// TODO TDISP: Required for the tdisp crate to be built in the meantime. -#[expect(unused_imports)] -use tdisp::TdispHostDeviceInterface; -use tdisp::TdispIsolationReport; -use tdisp::TdispRelayedDeviceTarget; -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; - /// Trait for creating memory access instances. pub trait CreateMemoryAccess: 'static + Send + Sync { /// Creates a new memory access instance for the given guest physical address. @@ -548,14 +544,14 @@ struct RelayedVpciDevice { Pin + Send + Sync>>, )>, - /// Config space writes that arrived while a TDISP operation was in flight, + /// 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 a TDISP operation has been started. + /// poll this device again once an async operation has been started. #[inspect(skip)] waker: Waker, @@ -579,17 +575,16 @@ struct QueuedWrite { enum CfgWriteOutcome { /// The write reached the device and needs nothing further. Complete, - /// The write crossed an MMIO-enable edge. The future carries out the TDISP - /// work the edge requires, including writing the command register itself, - /// and must run to completion before any further config space write reaches - /// the device. + /// 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>>), } impl RelayedVpciDevice { - /// Applies a config space write that has no TDISP operation ahead of it, - /// either passing it through to the device or producing the TDISP work the - /// write requires. + /// 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. @@ -601,12 +596,8 @@ impl RelayedVpciDevice { return CfgWriteOutcome::Complete; } - // Detect the MMIO-enable edge BEFORE issuing the write so we can - // dispatch the correct TDISP notification. - // - // The write contains both the Command and Status registers packed into - // a single 32-bit value. Only the Command register is relevant for - // detecting the MMIO-enable edge. + // 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( @@ -624,6 +615,11 @@ impl RelayedVpciDevice { // 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 { @@ -635,7 +631,7 @@ impl RelayedVpciDevice { })) } // MMIO turning off. Tear the TDI back down. Deactivation leaves the - // command register in its off state itself. + // command register in its off state and unmaps all private BARs. (true, false) => { let device = self.device.clone(); CfgWriteOutcome::Started(Box::pin(async move { @@ -733,14 +729,12 @@ impl TdispRelayedDeviceTarget for RelayedVpciDevice { let tdisp_capable = self.tdisp_capable; Box::pin(async move { - // Whether the device is TDISP capable at all is decided once, when - // the host offers the device, so answer that here rather than - // asking the client. Everything else, including attesting when the - // TDI is `Unlocked`, is the client's job. + // 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. device.tdisp_isolation_snapshot().await }) } @@ -753,13 +747,11 @@ impl PciConfigSpace for RelayedVpciDevice { } fn pci_cfg_write(&mut self, offset: u16, value: ByteEnabledDwordWrite) -> IoResult { - // A TDISP operation has to run to completion with nothing else touching - // the device's config space, so every write that arrives while one is in - // flight waits, whatever register it targets. Writes arriving behind an - // already queued write wait too, so that they are 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. + // 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 { @@ -785,8 +777,8 @@ impl ChangeDeviceState for RelayedVpciDevice { fn start(&mut self) {} async fn stop(&mut self) { - // Nothing polls this device while it is stopped, so finish the TDISP - // operation and everything queued behind it here. Otherwise the callers + // 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() { diff --git a/vm/devices/pci/vpci_relay/src/tests.rs b/vm/devices/pci/vpci_relay/src/tests.rs index a8d737d652d..fcf2dbae8c6 100644 --- a/vm/devices/pci/vpci_relay/src/tests.rs +++ b/vm/devices/pci/vpci_relay/src/tests.rs @@ -37,16 +37,9 @@ use std::pin::pin; use std::sync::Arc; use std::task::Waker; use task_control::StopTask; -use tdisp::TdispDeviceInterfaceInfo; -use tdisp::TdispGuestProtocolType; -use tdisp::TdispHostDeviceInterface; use tdisp::TdispHostDeviceTargetEmulator; -use tdisp::TdispMmioRangeAction; -use tdisp::TdispReportType; 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 tdisp::test_helpers::new_null_tdisp_interface; use test_with_tracing::test; use virt::IsolationType; use vmbus_channel::simple::SimpleVmbusDevice; @@ -66,57 +59,6 @@ const SCRATCH_OFFSET: u16 = 0x40; /// The VTOM most SNP platforms report, which TDISP setup expects to be present. const TEST_VTOM: u64 = 0x400000000000; -/// The host side of the TDISP interface for the emulated device. It accepts -/// every lifecycle transition and hands back the reports an attestation needs, -/// so that a TDISP operation started from config space runs to completion. -struct AttestingHostInterface; - -impl TdispHostDeviceInterface for AttestingHostInterface { - 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<()> { - 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()), - // A well-formed report header describing a TDI with no MMIO - // ranges, which is all the device implements. - TdispReportType::InterfaceReport => Ok(vec![0; 16]), - other => anyhow::bail!("unexpected report type requested: {other:?}"), - } - } - - fn tdisp_modify_mmio_range( - &mut self, - _action: TdispMmioRangeAction, - _range_id: u16, - _gpa_base: u64, - _range_len_bytes: u64, - ) -> anyhow::Result<()> { - Ok(()) - } -} - /// The config space the emulated host device presents, plus a log of everything /// the relay has pushed through to it. #[derive(Default)] @@ -215,10 +157,7 @@ struct TestRelay { 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: TdispHostDeviceTargetEmulator::new( - Arc::new(Mutex::new(AttestingHostInterface)), - "vpci-relay-unit-test", - ), + tdisp_interface: new_null_tdisp_interface("vpci-relay-unit-test"), cfg: cfg.clone(), })); diff --git a/vm/devices/tdisp/src/lib.rs b/vm/devices/tdisp/src/lib.rs index eb6755f62b5..2419358473b 100644 --- a/vm/devices/tdisp/src/lib.rs +++ b/vm/devices/tdisp/src/lib.rs @@ -305,8 +305,8 @@ impl TdispHostDeviceTarget for TdispHostDeviceTargetEmulator { Some(Command::ModifyMmioRange(cmd)) => { let action = TdispMmioRangeAction::from_i32(cmd.action); - // `range_id` is a BAR index; the wire widens it to u32 because - // protobuf has no 16-bit type. + // `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( From d6b9123432dd5492e14da613cc771a8d6982ddcd Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Wed, 16 Sep 2026 13:19:28 -0700 Subject: [PATCH 28/31] vpci_relay: move the mocked TDISP test flow into its own module --- vm/devices/pci/vpci_relay/src/lib.rs | 84 ++------------------ vm/devices/pci/vpci_relay/src/tdispmock.rs | 92 ++++++++++++++++++++++ 2 files changed, 98 insertions(+), 78 deletions(-) create mode 100644 vm/devices/pci/vpci_relay/src/tdispmock.rs diff --git a/vm/devices/pci/vpci_relay/src/lib.rs b/vm/devices/pci/vpci_relay/src/lib.rs index aacab78b6dd..cd2fc881e35 100644 --- a/vm/devices/pci/vpci_relay/src/lib.rs +++ b/vm/devices/pci/vpci_relay/src/lib.rs @@ -12,6 +12,7 @@ #[cfg(target_os = "linux")] pub mod linux_mmio; +mod tdispmock; mod tests; // Exported to make it easier to define filters without explicitly pulling in @@ -48,9 +49,6 @@ use std::task::Waker; use tdisp::TdispIsolationReport; use tdisp::TdispRelayedDeviceTarget; 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 user_driver::DmaClient; use virt::IsolationType; use vmbus_client::driver::OpenParams; @@ -353,12 +351,10 @@ impl VpciRelay { tracing::info!(%instance_id, vendor_id = hw_ids.vendor_id, device_id = hw_ids.device_id, "vpci relay device arrived"); - // Each device gets a validator of its own. The validators keep - // per-device state that is not keyed by device ID, and on some - // platforms they hold a firmware handle, so sharing one across devices - // would let them overwrite each other's resource state. Built after the - // allowed-device filter so a device the relay is about to reject never - // takes a handle. + // 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")?; @@ -379,7 +375,7 @@ impl VpciRelay { // If testing the mock TDISP flow... if self.options.test_tdisp_flow { - Self::tdisp_test_mock_flow(vpci_device.clone()) + tdispmock::run_test_flow(vpci_device.clone()) .await .expect("failed to exercise TDISP flow test"); @@ -460,74 +456,6 @@ 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. - - 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); - - Self::tdisp_test_mock_attest_flow(device.clone()) - .await - .context("tdisp_test_mock_flow: failed to exercise TDISP attestation flow")?; - - Ok(()) - } - - async fn tdisp_test_mock_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_device(tdisp_capabilities) - .await - .context("tdisp_test_mock_flow: failed to attest device over vpci")?; - - assert_eq!(device.tdisp_tdi_state().await, TdispTdiState::Run); - - Ok(()) - } } #[derive(InspectMut)] 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..21db373d354 --- /dev/null +++ b/vm/devices/pci/vpci_relay/src/tdispmock.rs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! A mocked TDISP flow for the emulated TDISP devices OpenVMM produces for +//! tests. +//! +//! Runs when the relay is started with `OPENHCL_TEST_CONFIG=TDISP_VPCI_FLOW_TEST`, +//! in place of the capability probe a real device gets, and asserts that the +//! device answers with the mocked values the emulated device is expected to +//! report. A failure here means the paravisor and the emulated device disagree +//! about the TDISP protocol. + +use anyhow::Context as _; +use openhcl_tdisp::TdispVirtualDeviceInterface; +use std::sync::Arc; +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::VpciDevice; +use vpci_client::tdisp::TdispVpciAttestationInterface; + +/// Exercises the mocked TDISP flow against `device`, leaving its TDI in +/// `TdispTdiState::Run`. +pub(crate) async fn run_test_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. + + 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_attest_flow(device.clone()) + .await + .context("tdisp_test_mock_flow: failed to exercise TDISP attestation flow")?; + + 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_device(tdisp_capabilities) + .await + .context("tdisp_test_mock_flow: failed to attest device over vpci")?; + + assert_eq!(device.tdisp_tdi_state().await, TdispTdiState::Run); + + Ok(()) +} From 2573f88305479b84039c1d20ba5c45cf283a6460 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Wed, 16 Sep 2026 13:34:09 -0700 Subject: [PATCH 29/31] tdisp: serialize TDI interface reports --- vm/devices/tdisp/src/devicereport.rs | 33 ++++++- .../tdisp/src/tests/devicereport_tests.rs | 96 +++++++++++++++++++ vm/devices/tdisp/src/tests/mod.rs | 3 + 3 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 vm/devices/tdisp/src/tests/devicereport_tests.rs diff --git a/vm/devices/tdisp/src/devicereport.rs b/vm/devices/tdisp/src/devicereport.rs index 5d4c16d3e9d..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, @@ -138,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/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/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; From e390d480d84e9142f99345955071a39fc9878de0 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Wed, 16 Sep 2026 13:38:43 -0700 Subject: [PATCH 30/31] nvme_test: block the register BAR until TDISP unblocks its range --- Cargo.lock | 2 + .../disk_nvme/nvme_driver/src/tests.rs | 2 +- vm/devices/storage/nvme_test/Cargo.toml | 2 + vm/devices/storage/nvme_test/src/lib.rs | 1 + vm/devices/storage/nvme_test/src/pci.rs | 53 +++- vm/devices/storage/nvme_test/src/resolver.rs | 12 +- vm/devices/storage/nvme_test/src/tdisp.rs | 195 +++++++++++++++ vm/devices/storage/nvme_test/src/tests.rs | 1 + .../nvme_test/src/tests/controller_tests.rs | 2 +- .../nvme_test/src/tests/tdisp_tests.rs | 228 ++++++++++++++++++ 10 files changed, 484 insertions(+), 14 deletions(-) create mode 100644 vm/devices/storage/nvme_test/src/tdisp.rs create mode 100644 vm/devices/storage/nvme_test/src/tests/tdisp_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 675b39c72b9..70ca7753d13 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", 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 7fefe6e1cc5..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(); @@ -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); +} From f626c6d7e9798803d411daa7cc0dfbb543a7caf6 Mon Sep 17 00:00:00 2001 From: mfrohlich Date: Wed, 16 Sep 2026 13:32:40 -0700 Subject: [PATCH 31/31] vpci_relay: smoke test BAR access around attestation in the mock flow --- vm/devices/pci/vpci_relay/src/lib.rs | 60 +++++- vm/devices/pci/vpci_relay/src/tdispmock.rs | 205 +++++++++++++++++++-- 2 files changed, 248 insertions(+), 17 deletions(-) diff --git a/vm/devices/pci/vpci_relay/src/lib.rs b/vm/devices/pci/vpci_relay/src/lib.rs index cd2fc881e35..9401cec803c 100644 --- a/vm/devices/pci/vpci_relay/src/lib.rs +++ b/vm/devices/pci/vpci_relay/src/lib.rs @@ -77,6 +77,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 { @@ -108,6 +114,11 @@ pub struct VpciRelay { 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)] @@ -221,6 +232,25 @@ impl VpciRelay { 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, @@ -234,9 +264,29 @@ impl VpciRelay { 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. /// @@ -375,7 +425,10 @@ impl VpciRelay { // If testing the mock TDISP flow... if self.options.test_tdisp_flow { - tdispmock::run_test_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"); @@ -638,9 +691,8 @@ impl PollDevice for RelayedVpciDevice { if fut.as_mut().poll(cx).is_pending() { break; } - // The operation is done. Release the write that started it, then - // let the writes that queued up behind it through. If one of those - // starts another operation, the loop picks it up here. + + // 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(); diff --git a/vm/devices/pci/vpci_relay/src/tdispmock.rs b/vm/devices/pci/vpci_relay/src/tdispmock.rs index 21db373d354..169f1178dfe 100644 --- a/vm/devices/pci/vpci_relay/src/tdispmock.rs +++ b/vm/devices/pci/vpci_relay/src/tdispmock.rs @@ -1,31 +1,66 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! A mocked TDISP flow for the emulated TDISP devices OpenVMM produces for -//! tests. +//! 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`, -//! in place of the capability probe a real device gets, and asserts that the -//! device answers with the mocked values the emulated device is expected to -//! report. A failure here means the paravisor and the emulated device disagree -//! about the TDISP protocol. +//! 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 openhcl_tdisp::TdispVirtualDeviceInterface; +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; use vpci_client::tdisp::TdispVpciAttestationInterface; -/// Exercises the mocked TDISP flow against `device`, leaving its TDI in -/// `TdispTdiState::Run`. -pub(crate) async fn run_test_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. +/// 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" ); @@ -53,13 +88,157 @@ pub(crate) async fn run_test_flow(device: Arc) -> anyhow::Result<()> ); assert_eq!(device.tdisp_tdi_state().await, TdispTdiState::Unlocked); - run_attest_flow(device.clone()) + 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<()> {