tdisp_openhcl: Implement necessary infrastructure for TDISP under VPCI Relay - #4416
Michael Frohlich (mfrohlich-msft) wants to merge 31 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Critical TDISP validation, cleanup, state-handling, and relay issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds TDISP MMIO-range management and VPCI isolated-resource querying for TDX Connect and newer VPCI infrastructure.
Changes:
- Adds
ModifyMmioRangesupport and related serialization/state-machine coverage. - Adds VPCI RB protocol support and BAR/DMA isolation reporting.
- Integrates TDISP lifecycle handling, validation, relay support, and tests.
File summaries
| File | Description |
|---|---|
vm/devices/tdisp/src/tests/statemachine_tests.rs |
Adds device-ID and state-machine tests. |
vm/devices/tdisp/src/tests/serialize_tests.rs |
Tests MMIO command serialization. |
vm/devices/tdisp/src/tests/mocks.rs |
Extends host mocks. |
vm/devices/tdisp/src/tests/endtoend_tests.rs |
Adds MMIO lifecycle tests. |
vm/devices/tdisp/src/test_helpers.rs |
Updates test interfaces. |
vm/devices/tdisp/src/serialize_proto.rs |
Validates MMIO actions. |
vm/devices/tdisp/src/lib.rs |
Adds MMIO and isolation APIs. |
vm/devices/tdisp/src/devicereport.rs |
Updates device-report documentation. |
vm/devices/tdisp_proto/src/tdisp.proto |
Defines new TDISP protocol messages. |
vm/devices/tdisp_proto/src/lib.rs |
Adds protocol helpers and state formatting. |
vm/devices/pci/vpci/src/device.rs |
Handles isolated-resource queries. |
vm/devices/pci/vpci_relay/src/lib.rs |
Integrates relay and asynchronous config handling. |
vm/devices/pci/vpci_relay/Cargo.toml |
Updates dependencies. |
vm/devices/pci/vpci_protocol/src/lib.rs |
Adds RB protocol and isolation structures. |
vm/devices/pci/vpci_client/src/tests.rs |
Adds BAR-decoding tests. |
vm/devices/pci/vpci_client/src/tdisp.rs |
Implements TDISP lifecycle and resource classification. |
vm/devices/pci/vpci_client/src/lib.rs |
Integrates client lifecycle and negotiation. |
vm/devices/pci/vpci_client/Cargo.toml |
Updates dependencies. |
vm/chipset_device/src/lib.rs |
Adds isolation-report support. |
vm/chipset_device_resources/src/lib.rs |
Forwards the isolation-report capability. |
openhcl/underhill_core/src/worker.rs |
Wires resource validation. |
openhcl/underhill_core/Cargo.toml |
Updates dependencies. |
openhcl/openhcl_tdisp/src/noop.rs |
Implements no-op validation hooks. |
openhcl/openhcl_tdisp/src/lib.rs |
Adds TDISP interfaces and command builders. |
openhcl/openhcl_tdisp/Cargo.toml |
Updates dependencies. |
Cargo.lock |
Updates locked dependencies. |
Review details
Suppressed comments (10)
vm/devices/pci/vpci_client/src/lib.rs:734
- The method documents this path as a no-op unless the TDI is in
Run, but it unconditionally sendstdisp_unbindforUnlockedandLockedas well. That can issue an invalid/unnecessary host unbind and clears per-attest state on a path that should do nothing; guard the unbind (and its warning) with the observedRunstate.
if let Err(err) = self.tdisp_unbind(TdispGuestUnbindReason::Graceful).await {
vm/devices/pci/vpci_client/src/tdisp.rs:1012
- This comment has a duplicated word: “the the device”.
// If any BAR is classified as PRIVATE, the the device should also have PRIVATE DMA.
vm/devices/pci/vpci_client/src/tdisp.rs:1369
- These failures are directly retriggerable by the guest’s isolation query, so the bare error event can be used to flood logs. Use the repository’s rate-limited error macro for this guest-triggered path.
tracing::error!(
vm/devices/pci/vpci_protocol/src/lib.rs:112
- The protocol definition marks
VPCI_TDISP_COMMANDas valid only forProtocolVersion::RBand newer, but the server'sDeviceRequest::TdispCommandhandler does not checkself.vpci_versionbefore dispatching it. A guest that negotiated VB can therefore use a packet the negotiated protocol does not advertise; reject it withNOT_SUPPORTEDon downlevel protocols, consistently withQueryIsolatedResources.
/// TDISP command from guest to host.
///
/// Only valid on protocol version >= `ProtocolVersion::RB`.
VPCI_TDISP_COMMAND = 0x4249001D,
vm/devices/pci/vpci_relay/src/lib.rs:681
PciBusCfgAccessHandlercan queue multiple deferred config writes, but thisdebug_assert!is removed in release builds. If a second MMIO-enable write arrives whilependingis set, the assignment below drops the firstDeferredWrite, so its caller receivesIoError::NoResponse. Queue or reject overlapping activations in release rather than relying on a debug-only invariant.
debug_assert!(
self.pending.is_none(),
"config space write deferred while another deferred write is in flight"
vm/devices/pci/vpci_relay/src/lib.rs:389
- The mock branch successfully runs the TDISP flow but leaves
tdisp_capableset tofalse. The resulting relay bypasses TDISP config interception and returnsNotTdispCapableforQueryIsolatedResources, so the dedicated TDISP test path does not exercise the new behavior. Set the flag after the mock flow succeeds.
if self.options.test_tdisp_flow {
Self::tdisp_test_mock_flow(vpci_device.clone())
.await
.expect("failed to exercise TDISP flow test");
vm/devices/pci/vpci_relay/src/lib.rs:475
- The new
VpciClientTdispState::newinitializestdi_statetoUnlocked, so this assertion fails before the mock flow runs. AssertUnlockedhere, or make the client useUninitializedconsistently and adjust the attestation and teardown paths that currently treatUnlockedas the entry state.
assert_eq!(device.tdisp_tdi_state().await, TdispTdiState::Uninitialized);
vm/devices/tdisp/src/lib.rs:166
Readyis constructed withInvalidfor unimplemented BAR slots, and the VPCI wire reply intentionally preserves those INVALID entries on SUCCESS. This public guarantee is therefore false and can cause consumers to reject a valid successful report; document INVALID BAR slots as allowed while keeping DMA limited to SHARED/PRIVATE.
/// 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`.
vm/devices/tdisp/src/lib.rs:186
- This contract says the fresh attestation cycle ends back in
Unlocked, butVpciDevice::tdisp_isolation_snapshotcallsattestand returns with the TDI inRun. That state is significant because later MMIO activation expectsRun; either the implementation or this public lifecycle description must be corrected so callers do not rely on a false postcondition.
/// 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
vm/devices/tdisp/src/tests/endtoend_tests.rs:554
dispatch_roundtripserializes and then callsdeserialize_command(...).unwrap(), whilevalidate_commandrejectsTdispMmioRangeAction::Invalidbefore the emulator is invoked. This test therefore panics in its helper instead of producing the expected response. Assert the wire-level deserialization error here, or call the emulator directly if the state-machine branch is what this test intends to cover.
let resp = dispatch_roundtrip(
&mut mock.emulator,
modify_mmio_range_cmd(
DEVICE_ID,
TdispMmioRangeAction::Invalid,
1,
0xd000_0000,
0x1000,
),
- Files reviewed: 25/26 changed files
- Comments generated: 15
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
18a93ad to
0e3c59b
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Critical and moderate protocol, lifecycle, cleanup, and validation issues remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (19)
vm/devices/pci/vpci/src/device.rs:1085
- The protocol comments now say
VPCI_TDISP_COMMANDis valid only forProtocolVersion::RBor newer, but this handler never checksself.vpci_version; a VB fallback channel still deserializes and executes TDISP commands. Gate this arm before handing the command to the chipset device, as the adjacentQueryIsolatedResourcesarm does.
let mut locked_dev = dev.device.lock();
if let Some(tdisp) = locked_dev.supports_tdisp_host() {
vm/devices/pci/vpci_client/src/tdisp.rs:168
- This state is the sentinel used by
RelayedDevice::removeto distinguish devices that never accepted TDISP; initializing it toUnlockedmakes every freshly initialized non-TDISP device look like it needs an Unbind, and the mock flow's first assertion fails. Start inUninitialized; the first successful interface-info response will move it toUnlocked.
tdi_state: TdispTdiState::Unlocked,
vm/devices/pci/vpci_client/src/tdisp.rs:677
- The DMA cleanup has the same VTL mismatch as the MMIO cleanup: activation unblocks with
self.target_vtl, but this path re-blocks with VTL2. That can leave the actual target VTL's DMA access enabled after unbind. Use the configured target VTL.
if let Err(e) = validator.tdisp_block_dma(Vtl::Vtl2, raw_device_id) {
vm/devices/pci/vpci_client/src/tdisp.rs:672
- If the platform re-block succeeds but
tdisp_host_block_mmio_rangefails, this unconditional removal forgets the range anyway. A later unbind or re-attestation then has no record with which to retry the host-side block, so the host can remain unblocked while the TDI state is cleared. Retain failed host cleanup (or track it separately) until both sides have succeeded.
self.mutable_state.validated_mmio_bars.remove(&bar_id);
vm/devices/pci/vpci_client/src/tdisp.rs:1149
- The host unblock completes before the platform validator call, but a validator error returns before this range is inserted into
validated_mmio_bars. The subsequent cleanup therefore cannot send the compensating host block, leaving the host's range state ahead of the failed platform operation. Roll back the host unblock on validator failure or record the range before the call with explicit partial-cleanup handling.
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")?;
vm/devices/pci/vpci_client/src/tdisp.rs:967
- A TDISP report may contain multiple MMIO entries sharing one
range_id, but.find()classifies only the first entry. A later subrange can therefore be omitted from the BAR's isolation decision (and from the corresponding unblock work), making the reported classification disagree with the report. Handle all entries in the group and preserve their offsets/subranges when performing validation.
let Some(range) = report
.mmio_interface_info
.iter()
.find(|r| r.range_id == bar_id)
vm/devices/pci/vpci_client/src/tdisp.rs:1372
- The relay has already marked this device TDISP-capable after a successful probe, so a later capability-query failure is an internal/host error, not evidence that the device is non-TDISP. Returning
NotTdispCapablemaps to a successful all-SHAREDVPCI reply, allowing a failed isolation query to be treated as a safe shared device. Return the error classification instead.
return TdispIsolationReport::NotTdispCapable;
vm/devices/pci/vpci_client/src/tdisp.rs:1012
- This comment repeats “the”; remove the duplicate.
// If any BAR is classified as PRIVATE, the the device should also have PRIVATE DMA.
vm/devices/pci/vpci_client/src/tdisp.rs:697
- These per-attestation fields are cleared before the host Unbind command and the final state check complete. If Unbind fails, a retry no longer has the guest device ID, report, or resource records needed to clean up platform state, leaving the TDI irrecoverable. Defer clearing them until Unbind succeeds and the state is confirmed.
// 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();
vm/devices/pci/vpci_client/src/tdisp.rs:227
cachedis derived from the host's TDISP response, so an unexpected root value can reach this branch. Panicking here lets untrusted host input terminate OpenHCL; convert a state mismatch into an error and perform teardown instead of asserting on the protocol response.
if cached != expected {
panic!(
"TDI {device_id:?} must be in state {expected}, but the host reports \
{cached} (firmware reports {firmware:?})"
);
vm/devices/pci/vpci_relay/src/lib.rs:622
- BAR writes take this synchronous path even after TDISP activation.
VpciDevice::write_cfgonly updates the BAR shadow, and this branch never invokestdisp_on_mmio_reconfigured, so a guest BAR change can leave the host mapping and platform validation at the old GPA while config reads expose the new one. Reject reprogramming while the TDI is active or perform old-range teardown and new-range validation.
if !self.tdisp_capable || HeaderType00(offset) != HeaderType00::STATUS_COMMAND {
self.device.write_cfg(offset, value);
return IoResult::Ok;
vm/devices/pci/vpci_relay/src/lib.rs:392
VPCI_TDISP_COMMANDis documented as valid only from protocolRB, but the relay probes every host VPCI device without knowing the negotiated host version. On a host that negotiatedVB, this sends an invalid message and relies on the resulting failure being treated as ordinary non-TDISP. Propagate the negotiated version and skip the probe when it is belowRB.
// Probe TDISP capability without attesting.
match vpci_device.tdisp_query_capabilities().await {
vm/devices/pci/vpci_relay/src/lib.rs:648
- This branch silently discards the guest's MMIO-disable edge, but
tdisp_on_device_deactivateis never invoked by this path. A guest can therefore leave the TDI in Run with its resources unbound while the command shadow remains enabled. Defer the write until deactivation/unbind completes (or otherwise invoke the deactivation path) instead of dropping the configuration update.
(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.
vm/devices/pci/vpci_relay/src/lib.rs:405
- All capability-probe errors are treated as if the device simply lacks TDISP, including host or transport failures. That sets
tdisp_capablefalse and later causesQueryIsolatedResourcesto return SUCCESS with all resources SHARED, hiding an operational failure. Distinguish an explicit unsupported response from a failed probe and surface the latter as an error.
tracing::info!(
%instance_id,
failure_reason = ?e,
"TDISP not supported or failed to query capabilities"
);
vm/devices/tdisp/src/lib.rs:846
- This context string repeats “to”, producing an awkward error message. Use “failed to modify MMIO range” so callers get a clear failure description.
.context("failed to call to modify MMIO range");
vm/devices/tdisp/src/lib.rs:134
- The referenced wire type is named
VpciQueryIsolatedResourcesinvpci_protocol, notVpciMsgQueryIsolatedResources; this makes the documentation point at a nonexistent API name.
/// `VpciMsgQueryIsolatedResources`, but is defined here so that
vm/devices/tdisp/src/lib.rs:162
- This says
NotReadyincludes the Run state before resources are unblocked, butisolation_snapshotreturnsReadyas soon as an interface report is cached, and the existing empty-report test exercises that behavior before any resource is unblocked. Align this public contract with the implementation, or make the snapshot track resource activation.
/// 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.
vm/devices/tdisp/src/lib.rs:153
- This PR adds a guest-visible
VPCI_QUERY_ISOLATED_RESOURCEScontract and TDISP BAR/DMA isolation semantics, but the existing OpenHCL VPCI relay Guide page has no section describing the RB protocol gate, reply statuses, or classification rules. Update the Guide (and its code-to-Guide mapping if needed) so this structural guest-facing behavior is documented.
/// Classification of a device's BAR and DMA isolation for the VPCI
/// `QueryIsolatedResources` message, reported by the guest-facing VPCI
/// server.
workers/chipset_device_worker/src/worker.rs:149
- The remote worker/proxy has no capability or request forwarding for
supports_tdisp_relay—DeviceInitonly carries MMIO/PIO/PCI, and the proxy's relay support is absent. Checking onlysupports_tdisp_hostnow lets a remote device with the new relay trait cross this boundary while silently losing its TDISP relay behavior. Reject the relay capability here as unsupported too, or add an explicit forwarding path.
|| device.supports_tdisp_host().is_some()
- Files reviewed: 27/28 changed files
- Comments generated: 6
- Review effort level: Lite
0e3c59b to
dd3042a
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings affect TDISP isolation, lifecycle cleanup, and protocol behavior.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (12)
openhcl/openhcl_tdisp/src/lib.rs:304
new_resource_validatoris wired into every production VPCI relay, but every non-test isolation type still receivesTdispNoopResourceValidator, whose MMIO/DMA methods only record and return success. A TDISP device can therefore be reportedPRIVATEand activated without any platform memory/IOMMU transition, breaking the isolation guarantee. Do not enable TDISP for unsupported production isolation types until real validators are selected, or wire those validators here.
// TODO: Add platform-specific resource validators based on the isolation type.
// This will follow in subsequent PRs.
Ok(Arc::new(noop::TdispNoopResourceValidator::new()))
vm/devices/pci/vpci_client/src/tdisp.rs:158
VpciClientTdispStateis the client-side pre-command state, and the relay's mock flow explicitly expects it to beUninitializedbefore the first VPCI TDISP command. Starting atUnlockedmakes that assertion fail and loses the sentinel needed to distinguish an untouched channel from one that has negotiated successfully. Initialize this field toUninitialized; the first host response will transition it toUnlocked.
tdi_state: TdispTdiState::Unlocked,
vm/devices/pci/vpci_client/src/tdisp.rs:668
- The DMA teardown path also hard-codes
Vtl2, while the corresponding activation call usesself.target_vtl. If the configured target differs, DMA is re-blocked for the wrong VTL and the attested device's actual DMA mapping is not restored. Passself.target_vtlhere.
if let Err(e) = validator.tdisp_block_dma(Vtl::Vtl2, raw_device_id) {
vm/devices/pci/vpci_client/src/tdisp.rs:1107
- This host-side unblock is sent before the platform call, but if
tdisp_unblock_mmiofails afterward, the function returns before recordingvalidated_mmio_bars.tdisp_unbind_resourcesthen has no entry to send a compensating host block, leaving the host and platform views inconsistent during failed activation. Roll back the host notification on validator failure or track the range as pending until both operations succeed.
self.tdisp_host_unblock_mmio_range(bar_id, base_address, length)
.await
.context("tdisp_on_mmio_reconfigured: failed to unblock MMIO on the host")?;
vm/devices/pci/vpci_client/src/tdisp.rs:663
- The entry is removed whenever
tdisp_block_mmiosucceeds, even iftdisp_host_block_mmio_rangejust failed. That discards the only local record needed for a retry and lets subsequent teardown checks treat the host-side range as restored when it was not. Remove the entry only after both platform and host block succeed, or abort teardown while retaining it.
// Successful re-block, remove the bar from the validated list.
self.mutable_state.validated_mmio_bars.remove(&bar_id);
vm/devices/pci/vpci_client/src/tdisp.rs:945
range_idin the TDI report is device-specific and may group multiple MMIO subranges; it is not guaranteed to equal a PCI BAR index. Matching it directly tobar_idcan misclassify valid BARs and skip their private-memory handling. Keep an explicit BAR-to-report-range mapping, including any report-relative subrange information needed by the platform validator.
let Some(range) = report
.mmio_interface_info
.iter()
.find(|r| r.range_id == bar_id)
vm/devices/pci/vpci_client/src/tdisp.rs:837
- The attestation contract says failures leave the device Unlocked with no retained state, but this and the later fallible steps return directly after a successful bind. For example, a failed
on_pre_startleaves the host in Locked and no caller ofattestis required to clean it up. Add rollback/unbind-on-error around the post-bind portion of the flow.
self.resource_validator
.on_pre_start(self.target_vtl, guest_device_id_u16)
.context("tdisp_attest_device: pre-start validation failed")?;
vm/devices/pci/vpci_client/src/tdisp.rs:1322
- Once the client uses
Uninitializedas its pre-command sentinel, this condition will skip auto-attestation for a brand-new device and returnNotReadyfromQueryIsolatedResources. Treat bothUninitializedandUnlockedas the bootstrap states that may need capability probing and attestation.
if guard.tdi_state() == TdispTdiState::Unlocked {
vm/devices/pci/vpci_client/src/tdisp.rs:980
- This comment contains a duplicated word:
the the device.
// If any BAR is classified as PRIVATE, the the device should also have PRIVATE DMA.
vm/devices/pci/vpci_protocol/src/lib.rs:112
- The protocol contract now declares
VPCI_TDISP_COMMANDRB-only, but the ready-state handler still dispatches it under VB, and the client still probes TDISP after the RB-to-VB fallback. A downlevel peer can receive an operation the negotiated protocol says is unsupported (or close the channel). Gate TDISP command probing/dispatch on RB, or keep the command documented as valid for VB as before.
/// TDISP command from guest to host.
///
/// Only valid on protocol version >= `ProtocolVersion::RB`.
VPCI_TDISP_COMMAND = 0x4249001D,
vm/devices/pci/vpci_relay/src/lib.rs:472
- This assertion cannot hold with the client state initialization:
VpciClientTdispState::newstarts atUnlocked, so the mock flow panics before sending its first command. Use the pre-commandUninitializedsentinel consistently (and handle that sentinel consistently during teardown) before relying on this flow.
assert_eq!(device.tdisp_tdi_state().await, TdispTdiState::Uninitialized);
vm/devices/tdisp/src/lib.rs:160
Readyis returned bytdisp_isolation_snapshotimmediately afterattest, butattestexplicitly leaves resources inaccessible until MMIO activation and has not unblocked DMA either. This variant therefore claims a readiness condition the implementation does not meet; either report the attested classification separately or delayReadyuntil resource unblocks complete.
/// 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`.
- Files reviewed: 27/28 changed files
- Comments generated: 7
- Review effort level: Lite
dd3042a to
b81720c
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved state, cleanup, VTL, BAR-reporting, protocol-gating, and resource-tracking issues remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (14)
openhcl/openhcl_tdisp/src/noop.rs:183
- The resource validator is shared by all relayed VPCI devices, but this removes every recorded range with the same BAR index, regardless of
device_idor VTL. Unbinding one device can therefore erase another device's still-unblocked range; retain records only when the target VTL, device ID, and range ID all differ from the block request.
self.unblocked_mmio_ranges
.lock()
.retain(|r| r.range_id != range_id);
openhcl/openhcl_tdisp/src/noop.rs:47
- This single shared boolean is not a per-device DMA record: the worker constructs one validator and passes it to every relayed device, so unbinding any one TDI sets the flag false even if another TDI remains unblocked. Track DMA state by at least
(target_vtl, device_id), or make the accessor explicitly aggregate all active devices.
dma_unblocked: Mutex<bool>,
vm/devices/pci/vpci/src/device.rs:1178
- For a non-TDISP relayed device, this
SUCCESSreply marks all six BAR slots asSHARED, including unimplemented slots. The protocol definesINVALIDfor a slot outside the device's BAR ID set, and the client already trackspresent_bars; carry that mask into the non-TDISP report or emitINVALIDfor absent BARs instead of claiming they are host-visible resources.
Some(TdispIsolationReport::NotTdispCapable) => protocol::VpciIsolatedResourcesReply {
status: protocol::Status::SUCCESS,
bar_isolation: [ResourceIsolation::SHARED; 6],
dma_isolation: ResourceIsolation::SHARED,
vm/devices/pci/vpci_client/src/tdisp.rs:158
tdi_stateis the cache of the last host response, so before the first command it must beUninitialized. The relay's mock flow explicitly asserts that state atvpci_relay/src/lib.rs:472-475; initializing it toUnlockedmakes that test fail and loses the pre-command distinction needed by teardown. Initialize this field toTdispTdiState::Uninitialized.
tdi_state: TdispTdiState::Unlocked,
vm/devices/pci/vpci_client/src/tdisp.rs:668
- DMA is unblocked with
self.target_vtlon the activation path, but cleanup re-blocks it with hard-codedVtl2. For the relay'sVtl0target this leaves DMA enabled for the actual target after unbind. Passself.target_vtlhere as well.
if let Err(e) = validator.tdisp_block_dma(Vtl::Vtl2, raw_device_id) {
vm/devices/pci/vpci_client/src/tdisp.rs:1329
- Once the relay has marked a device
tdisp_capable, a later capability-query failure means the isolation state could not be read, not that the device is non-TDISP. ReturningNotTdispCapablemaps to a successful all-SHAREDreply inbuild_isolation_reply, allowing the guest to proceed after a host error. ReturnError(or otherwise preserve an error state) here.
Err(err) => {
tracing::error!(
"tdisp_isolation_snapshot: query_capabilities failed (tdisp not supported or host errored out): {err}"
);
return TdispIsolationReport::NotTdispCapable;
vm/devices/pci/vpci_client/src/tdisp.rs:827
- After
tdisp_bind_interfacesucceeds, each later fallible step inattestreturns directly, leaving the host TDI inLockedorRunand retaining platform state. This violates the publictdisp_attest_devicecontract that an error leaves the deviceUnlockedwith no attestation state; the outer activation wrapper happens to clean up, but direct trait callers do not. Add rollback inattestor revise the contract.
self.tdisp_bind_interface()
.await
.context("tdisp_attest_device: failed to bind device interface")?;
vm/devices/pci/vpci_client/src/tdisp.rs:945
- The interface-report contract permits multiple MMIO entries to share one
range_id(vm/devices/tdisp/src/devicereport.rs:65-67), butfindinspects only the first entry. A grouped range with differing subrange attributes is therefore classified according to report order, which can make the whole BAR appearPRIVATEorSHAREDincorrectly and skip or perform the wrong unblock operation. Aggregate or otherwise explicitly handle all entries for the BAR.
let Some(range) = report
.mmio_interface_info
.iter()
.find(|r| r.range_id == bar_id)
vm/devices/pci/vpci_protocol/src/lib.rs:112
VPCI_TDISP_COMMANDis now documented as valid only forRB, but the VPCIReadyStatestill accepts and forwards this message without checkingself.vpci_version(unlike the newQueryIsolatedResourcespath). AVBguest can therefore use an interface the negotiated protocol says is unavailable; enforce the version gate in the command/probe path or remove the RB-only contract.
/// TDISP command from guest to host.
///
/// Only valid on protocol version >= `ProtocolVersion::RB`.
VPCI_TDISP_COMMAND = 0x4249001D,
vm/devices/pci/vpci_relay/src/lib.rs:141
- The pre-command sentinel is
Uninitialized, notUnlocked: this condition sendsUnbindfor a channel that has never received a TDISP response. Becauserequest_unbindrequires a negotiated protocol, removal can fail before negotiation; skip unbind only when the state isUninitialized.
if self.vpci_device.tdisp_tdi_state().await != TdispTdiState::Unlocked {
vm/devices/pci/vpci_relay/src/lib.rs:401
- This error branch leaves
tdisp_capablefalse for both 'not supported' and a capability-probe failure. The reporter then returnsNotTdispCapable, so VPCI answersSUCCESSwith all resourcesSHAREDeven when the host query failed. Preserve a separate error state and fail the isolation query unless unsupported was positively established.
Err(e) => {
tracing::info!(
%instance_id,
failure_reason = ?e,
"TDISP not supported or failed to query capabilities"
vm/devices/pci/vpci_relay/src/lib.rs:658
- If this invariant is violated, assigning a new future below abandons the first
DeferredWrite, so its caller can wait forever.debug_assert!removes that protection in release builds; use a release assertion or handle the second request explicitly.
debug_assert!(
self.pending.is_none(),
"config space write deferred while another deferred write is in flight"
);
vm/devices/pci/vpci_relay/src/lib.rs:386
- After the mock flow succeeds,
tdisp_capableis never set totrue. The resulting relay therefore bypasses TDISP command-register activation and reportsNotTdispCapableforQueryIsolatedResources, so this test path does not expose the device as the TDISP-capable relay it just exercised.
Self::tdisp_test_mock_flow(vpci_device.clone())
.await
.expect("failed to exercise TDISP flow test");
vm/devices/tdisp/src/lib.rs:160
Readyis documented as requiring resources to have been unblocked and its arrays to contain onlyShared/Private, butisolation_snapshotreturnsReadyimmediately after attestation (before MMIO/DMA unblocking) and intentionally emitsInvalidfor unimplemented BARs; the tests exercise both. Update this contract so consumers do not treat the report as current accessibility or assumeInvalidcannot occur.
/// 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`.
- Files reviewed: 27/28 changed files
- Comments generated: 4
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved correctness and security-sensitive lifecycle issues remain, including a critical panic path.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (16)
openhcl/openhcl_tdisp/src/noop.rs:47
- This validator is constructed once and cloned into every relayed VPCI device, but the tracking is global: blocking range N removes every device's range N, and one device's DMA block clears the flag for all other devices. Key MMIO and DMA state by at least
(target_vtl, device_id, range_id)(and the corresponding device/VTL identity for DMA).
pub struct TdispNoopResourceValidator {
unblocked_mmio_ranges: Mutex<Vec<UnblockedMmioRange>>,
dma_unblocked: Mutex<bool>,
vm/devices/pci/vpci/src/device.rs:1066
VPCI_TDISP_COMMANDis documented as valid only for protocol version RB, and the query branch above enforces that check, but this branch dispatches the command regardless ofself.vpci_version. A VB/DT guest can therefore send a TDISP command and receive a successful response. ReturnNOT_SUPPORTEDbefore deserializing or dispatching when the negotiated version is below RB.
DeviceRequest::TdispCommand { data } => {
let command = match tdisp::serialize_proto::deserialize_command(&data) {
vm/devices/pci/vpci/src/device.rs:1178
NotTdispCapableis returned for ordinary devices, but this reply marks all six BAR slotsSHAREDwithSUCCESS. The wire contract saysINVALIDidentifies slots not in the device's BAR ID set, and most PCI devices do not implement all six BARs, so the authoritative reply advertises nonexistent resources. Preserve BAR presence in the report or return a response that does not claim all slots are shared.
Some(TdispIsolationReport::NotTdispCapable) => protocol::VpciIsolatedResourcesReply {
status: protocol::Status::SUCCESS,
bar_isolation: [ResourceIsolation::SHARED; 6],
dma_isolation: ResourceIsolation::SHARED,
vm/devices/pci/vpci_client/src/tdisp.rs:1069
isolation_snapshotmarks DMAPRIVATEas soon as the report contains any TEE MMIO, but attestation deliberately leaves resources inaccessible untiltdisp_on_mmio_reconfiguredperforms the platform unblock (anddma_unblockedis set).VPCI_QUERY_ISOLATED_RESOURCEScan therefore returnPRIVATEimmediately after auto-attestation while the host still sees the ranges as shared. Base this report on the actual validated/unblocked state or keep itNotReadyuntil resources are accepted.
// 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
vm/devices/pci/vpci_client/src/tdisp.rs:1417
- Once this method is called,
RelayedVpciDevicehas already classified the device as TDISP-capable. A later capability-query failure is therefore a host/error condition, not evidence that the device is non-TDISP; returningNotTdispCapablemaps it to a successful all-SHAREDreply and can let the guest proceed without attestation. ReturnErrorso VPCI reportsUNSUCCESSFUL.
return TdispIsolationReport::NotTdispCapable;
vm/devices/pci/vpci_client/src/tdisp.rs:850
setup_and_attestpassesStartupFailureintotdisp_unbind, butTdispHostStateMachine::request_unbindaccepts onlyGraceful,DeviceTeardown, andResourceSetupFailure; it records this path asInvalidGuestUnbindReasonand discards the actual paravisor failure reason. The new proto comments define startup/attestation failures as paravisor unbind reasons, so accept those reasons in the state machine or translate them before sending the command.
reason: TdispGuestUnbindReason::StartupFailure,
vm/devices/pci/vpci_client/src/tdisp.rs:1197
- If the platform-side unblock fails after the host notification succeeds, this returns before recording the BAR in
validated_mmio_bars. Activation then invokestdisp_unbind, which has no record to drivetdisp_host_block_mmio_range, leaving host bookkeeping unblocked while the platform cleanup failed. Record a pending range before the platform call or explicitly compensate with a host-side block on this error path.
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")?;
vm/devices/pci/vpci_protocol/src/lib.rs:112
- The protocol now declares
VPCI_TDISP_COMMANDvalid only for RB and newer, but the VPCI server dispatchesDeviceRequest::TdispCommandwithout checkingself.vpci_version(unlikeQueryIsolatedResources). A VB/downlevel channel can therefore still execute this RB-only command; add the same version gate before deserializing or forwarding it.
/// Only valid on protocol version >= `ProtocolVersion::RB`.
VPCI_TDISP_COMMAND = 0x4249001D,
vm/devices/pci/vpci_relay/src/lib.rs:643
prev != nextis true for both MMIO enable and MMIO disable, but this closure always callstdisp_on_device_activate. On a disable edge it can re-attest and leave the TDI in Run with stale attestation/resource state instead of unbinding, so later isolation queries can describe a device whose MMIO is disabled. Dispatchtdisp_on_device_deactivatewhennextis false.
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.");
}
});
vm/devices/pci/vpci_relay/src/lib.rs:378
- In the mock-flow branch, successful attestation is followed by leaving
tdisp_capablefalse.RelayedVpciDevice::pci_cfg_writethen bypasses all TDISP activation/deactivation, andtdisp_isolation_reportreturnsNotTdispCapableeven though this device is inRunwith an attestation report. Mark the device capable after the mock flow succeeds.
if self.options.test_tdisp_flow {
Self::tdisp_test_mock_flow(vpci_device.clone())
.await
.expect("failed to exercise TDISP flow test");
vm/devices/pci/vpci_relay/src/lib.rs:393
- Every capability-probe error is collapsed into
tdisp_capable = false, which later maps toNotTdispCapableand a successful all-SHARED isolation report. A host/protocol failure is not evidence that the device is non-TDISP; this can expose a device as unprotected and make the guest bypass TDISP. Preserve a failure state (or distinguish explicit unsupported from probe errors) and return an error report for the latter.
Err(e) => {
tracing::info!(
%instance_id,
failure_reason = ?e,
"TDISP not supported or failed to query capabilities"
vm/devices/pci/vpci_relay/src/lib.rs:650
- This is a correctness invariant, not a debug-only diagnostic: in a release build a second deferred write would overwrite
self.pending, leaving the first deferred token without completion and stalling config processing. Use an unconditional assertion (or reject the second write) so the invariant is enforced in production.
debug_assert!(
self.pending.is_none(),
"config space write deferred while another deferred write is in flight"
);
vm/devices/pci/vpci_relay/src/lib.rs:641
- This warning is directly triggerable by repeated guest command-register writes. The VPCI request handler rate-limits comparable guest-triggered events (
vm/devices/pci/vpci/src/device.rs:1023-1027and1069-1076), so use the rate-limited warning macro here to avoid log amplification during repeated failed activations.
tracing::warn!("TDISP attestation failed. Not enabling STATUS_COMMAND.");
vm/devices/tdisp/src/lib.rs:915
setup_and_attestmaps failures (including failures after bind/start) toStartupFailureandattestsends that reason throughtdisp_unbind, but this match treatsStartupFailureandAttestationFailureas invalid. The host therefore recordsInvalidGuestUnbindReasoninstead of the failure reason that these new enum values are intended to carry; accept both paravisor-generated reasons here.
TdispGuestUnbindReason::Graceful
| TdispGuestUnbindReason::DeviceTeardown
| TdispGuestUnbindReason::ResourceSetupFailure => {
TdispUnbindReason::GuestInitiated(reason)
vm/devices/tdisp/src/lib.rs:160
Readyis documented as containing onlyShared/Private, butisolation_snapshotand the new VPCI reply intentionally emitInvalidfor unimplemented or report-absent BAR slots. Consumers following this contract could reject or mis-handle valid replies; documentInvalidas allowed for those slots.
/// 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`.
vm/devices/tdisp_proto/src/tdisp.proto:96
- These values are documented as paravisor-generated unbind reasons, and
setup_and_attestusesStartupFailure, butTdispHostStateMachine::request_unbindaccepts only Graceful, DeviceTeardown, and ResourceSetupFailure. The cleanup command is consequently recorded asInvalidGuestUnbindReasoninstead of preserving the startup/attestation failure reason; separate internal unbind handling from guest validation or allow the paravisor path explicitly.
// The paravisor failed during initial setup/bind of the TDI.
TDISP_GUEST_UNBIND_REASON_STARTUP_FAILURE = 5;
- Files reviewed: 27/28 changed files
- Comments generated: 3
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved moderate issues affect BAR classification, error handling, cleanup, logging, VTL selection, and TDISP operation serialization.
Review details
Suppressed comments (9)
vm/devices/pci/vpci/src/device.rs:1195
- This maps a non-TDISP device to
SHAREDfor all six BAR slots, including BARs that do not exist or are the upper half of a 64-bit BAR. The wire contract explicitly usesINVALIDfor entries that are not applicable, and theReadypath already preserves that distinction. Pass the device's implemented-BAR information into this conversion (or include it in the report) so aSUCCESSreply does not advertise nonexistent resources.
Some(TdispIsolationReport::NotTdispCapable) => protocol::VpciIsolatedResourcesReply {
status: protocol::Status::SUCCESS,
bar_isolation: [ResourceIsolation::SHARED; 6],
dma_isolation: ResourceIsolation::SHARED,
vm/devices/pci/vpci_client/src/tdisp.rs:1409
- This path is reached only after the relay has already classified the device as TDISP-capable. If the re-query fails because of a transient, malformed, or otherwise unexpected host response, returning
NotTdispCapablemakes the VPCI replySUCCESSwith all resourcesSHARED, which fails open. ReturnError(or another non-success result) for this failure instead of downgrading a known TDISP device.
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}"
vm/devices/pci/vpci_client/src/tdisp.rs:1064
- The comment contains a duplicated word (
the the), which makes the explanation unclear.
// If any BAR is classified as PRIVATE, the the device should also have PRIVATE DMA.
vm/devices/pci/vpci_client/src/tdisp.rs:1196
- If the host accepts the unblock but
tdisp_unblock_mmiofails, this function returns before insertingvalidated_mmio_bars. The subsequent unbind therefore has no record with which to sendModifyMmioRange(BlockMmioRange), leaving the host's range state unrolled back while the platform reports failure. Keep a pending cleanup record or explicitly roll back the host notification on this error path.
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")?;
vm/devices/pci/vpci_client/src/tdisp.rs:454
- The full host-supplied report is emitted at
infoon every attestation, and a guest can repeatedly trigger attest/unbind cycles by toggling MMIO. Reports can be large, so this can flood production logs and expose report contents; keep raw bytes at debug/trace or emit only a bounded digest/length with rate limiting.
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"
vm/devices/pci/vpci_relay/src/lib.rs:405
- The initial capability probe conflates an unsupported device with every probe failure, including VPCI transport errors and invalid host responses. That leaves
tdisp_capablefalse, so the relay bypasses TDISP on config writes and later reportsNotTdispCapable(which maps to a successful all-SHAREDreply). In an isolated VM this fails open; only an explicit unsupported result should take this path, while other failures should fail attachment or be reported as an error.
Err(e) => {
tracing::info!(
%instance_id,
failure_reason = ?e,
"TDISP not supported or failed to query capabilities"
vm/devices/pci/vpci_relay/src/lib.rs:744
- This future can call
tdisp_isolation_snapshot, which performs a full bind/start/report attestation when the TDI is Unlocked, but it does not markRelayedVpciDevice::pending. If a guest sends this query before any config-triggered operation and another VP writes config space during the await,pci_cfg_writesees no pending operation and passes the write through instead of queueing it. Coordinate this attestation with the relay's pending/queue gate so config accesses remain serialized for the whole TDISP operation.
if !tdisp_capable {
return TdispIsolationReport::NotTdispCapable;
}
device.tdisp_isolation_snapshot().await
vm/devices/pci/vpci_relay/src/lib.rs:376
- The client is initialized with a hard-coded
Vtl::Vtl0, but all resource validation and cleanup later use the client's storedtarget_vtl. This bypasses the configured target VTL for any relay assignment that is not VTL0, so MMIO/DMA protections can be applied to the wrong guest VTL. Pass the relay's configured target VTL through instead of hard-coding it here.
let (vpci_device, removed) = vpci_device
.init(
resource_validator,
self.isolation_type,
self.vtom.unwrap_or(0),
hvdef::Vtl::Vtl0,
)
vm/devices/tdisp/src/lib.rs:160
TdispIsolationReport::Readycan currently containInvalidBAR entries:isolation_snapshotinitializes unimplemented/report-absent slots toInvalid, and the VPCI mapper forwards those values. TheShared/Privateguarantee here is therefore false and can cause consumers to treat every BAR slot as classified; documentInvalidas a possible BAR value.
/// 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`.
- Files reviewed: 27/28 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical VPCI protocol and teardown issues remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (10)
Previously missed (1) — in code that hasn't changed since the last review.
vm/devices/pci/vpci_client/src/tdisp.rs:594
- The documentation says cleanup is “best-effort” and that failures do not abort, but the implementation below explicitly panics when MMIO or DMA re-blocking fails. This contradiction matters to callers because
tdisp_unbindis used during teardown; document the failure as fatal (or change the implementation to match the best-effort contract).
openhcl/openhcl_tdisp/src/noop.rs:48
- The validator's state is global to the validator even though every resource API call is scoped by
target_vtlanddevice_id. If one validator is reused for two TDIs, unbinding one TDI'srange_idremoves the other's record, and the single DMA flag is cleared for the other device; key MMIO and DMA state by(target_vtl, device_id)(plus range ID for MMIO), or make the validator non-shareable.
pub struct TdispNoopResourceValidator {
unblocked_mmio_ranges: Mutex<Vec<UnblockedMmioRange>>,
dma_unblocked: Mutex<bool>,
}
vm/devices/pci/vpci/src/device.rs:2689
- The test implementation refers to
Futurewithout importingstd::future::Future; that trait is not in the prelude, so this new impl fails to compile. Use the fully qualified trait (or add the missing import).
) -> std::pin::Pin<Box<dyn Future<Output = tdisp::TdispIsolationReport> + Send + 'static>>
vm/devices/pci/vpci/src/device.rs:1195
NotTdispCapableis serialized asSHAREDfor all six BAR slots, including unimplemented slots. The wire contract documentsINVALIDas the authoritative value for a successful reply when a slot is not part of the device's BAR ID set, so this misclassifies non-existent BARs. Carry the implemented-BAR mask into this report or emitINVALIDfor those slots.
Some(TdispIsolationReport::NotTdispCapable) => protocol::VpciIsolatedResourcesReply {
status: protocol::Status::SUCCESS,
bar_isolation: [ResourceIsolation::SHARED; 6],
dma_isolation: ResourceIsolation::SHARED,
vm/devices/pci/vpci_client/src/tdisp.rs:1196
- The host-side unblock completes before the platform validator call, but the
validated_mmio_barsentry is inserted only after both calls succeed. If the platform unblock fails, teardown has no record of this range and will not send the compensating host block, leaving host and platform resource state out of sync. Track the pending range or compensate the host before returning the error.
// 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")?;
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")?;
vm/devices/pci/vpci_client/src/tdisp.rs:1064
- This comment contains a duplicated article (
the the), which makes the explanation harder to read. Remove the duplicate.
// If any BAR is classified as PRIVATE, the the device should also have PRIVATE DMA.
vm/devices/pci/vpci_client/src/tdisp.rs:1411
- A capability-query failure is being reported as
NotTdispCapable, whichbuild_isolation_replyconverts toSUCCESSwith every BAR and DMA markedSHARED. For a device already classified as TDISP-capable, this is an inability to determine isolation, not proof that the device is non-TDISP; returningSHAREDcan let the guest treat resources as host-visible after an error. ReturnTdispIsolationReport::Errorhere so the wire status is unsuccessful/invalid instead.
return TdispIsolationReport::NotTdispCapable;
vm/devices/pci/vpci_relay/src/lib.rs:376
- The relay now passes a
target_vtlintoVpciDeviceDescription::init, but this production path hardcodesVtl0for every device. That discards the target-VTL configuration and can apply TDISP firmware resource operations to the wrong VTL once the relay serves another target; thread the configured target VTL through the relay instead.
let (vpci_device, removed) = vpci_device
.init(
resource_validator,
self.isolation_type,
self.vtom.unwrap_or(0),
hvdef::Vtl::Vtl0,
)
vm/devices/pci/vpci_relay/src/lib.rs:405
- This
Errpath leavestdisp_capablefalse, and the relay later maps that state toNotTdispCapable/SUCCESSwith all resourcesSHARED. A transport or host failure during the probe is not evidence that the device is non-TDISP; preserving an error/unknown state is necessary soVPCI_QUERY_ISOLATED_RESOURCEScannot grant a shared classification after an inconclusive probe.
Err(e) => {
tracing::info!(
%instance_id,
failure_reason = ?e,
"TDISP not supported or failed to query capabilities"
vm/devices/tdisp/src/lib.rs:160
Readyis constructed withInvalidentries for unimplemented or report-absent BAR slots (and the tests assert that shape), so the public contract here is false. Document that the arrays may containInvalid; otherwise consumers may treat a valid response as guaranteed to classify every slot.
/// 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`.
- Files reviewed: 27/28 changed files
- Comments generated: 2
- Review effort level: Lite
5242c0e to
d6b9123
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved issues affect error handling, isolation reporting, and resource state consistency.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (6)
openhcl/openhcl_tdisp/src/lib.rs:298
- For non-test runs this factory returns
TdispNoopResourceValidatoreven for SNP and TDX, whilequery_capabilitiesaccepts those isolation types and the relay exposes the device as TDISP-capable. A real CVM can therefore classify host-reported TEE MMIO asPRIVATEand drive resource activation without any firmware/page-state or DMA validation. Fail closed until a platform validator is selected, rather than enabling this path with a no-op validator.
Ok(Arc::new(noop::TdispNoopResourceValidator::new()))
openhcl/openhcl_tdisp/src/noop.rs:183
- The no-op validator can be supplied through the shared
Arc<dyn TdispResourceValidationInterface>dependency, but this cleanup matches onlyrange_id. If one validator services multiple TDI IDs or VTLs, unbinding one device removes another device's MMIO record as well, making later cleanup/bookkeeping incorrect. Includetarget_vtlanddevice_idin the key.
self.unblocked_mmio_ranges
.lock()
.retain(|r| r.range_id != range_id);
vm/devices/pci/vpci_client/src/tdisp.rs:1196
- If the platform-side unblock fails, this returns before inserting the range into
validated_mmio_bars, even though the host-side unblock above has already succeeded. The subsequent unbind therefore has no record with which to send a compensating host block, leaving the host's TDI state out of sync. Roll back the host operation on platform failure or retain the range until both sides succeed.
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")?;
vm/devices/pci/vpci_client/src/tdisp.rs:1067
- DMA isolation is inferred solely from whether any BAR is private, but the TDI report exposes
generate_dma_without_pasidandgenerate_dma_with_pasidto indicate whether a DMA path exists. A device with private MMIO and neither flag set is reported as having PRIVATE DMA despite having no DMA resource; use the report's DMA capability flags before assigning this field.
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.
vm/devices/pci/vpci_relay/src/lib.rs:367
- Every relayed device is initialized with
Vtl0, andVpciClientTdispStatepasses this value to all platform resource-validation and cleanup calls.VpciRelay::newhas no target-VTL input, so a relay serving another configured VTL would unblock and re-block resources in the wrong context. Thread the configured target VTL through the relay and pass it here instead of hard-codingVtl0.
.init(
resource_validator,
self.isolation_type,
self.vtom.unwrap_or(0),
hvdef::Vtl::Vtl0,
vm/devices/tdisp/src/lib.rs:160
- This contract is inconsistent with the implementation:
isolation_snapshotreturnsReadywithInvalidentries for unimplemented or report-absent BAR slots, and the VPCI mapping preserves those entries. Document thatReadymay containInvalidBAR classifications so callers do not assume every entry isSharedorPrivate.
/// 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`.
- Files reviewed: 28/29 changed files
- Comments generated: 2
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings include a compile failure, unsafe relay error handling, and incorrect resource bookkeeping and logging behavior.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (7)
openhcl/openhcl_tdisp/src/noop.rs:47
- This validator is stored behind
Arcand receivestarget_vtl/device_id, but its state is global: MMIO cleanup later filters only byrange_id, and DMA is one shared boolean. If a validator is reused for two TDIs or VTLs, unbinding BAR 0 or DMA for one device removes/clears the bookkeeping for the other, so its resources may not be cleaned up. Key the tracked state by at least(target_vtl, device_id, range_id)and key DMA state the same way.
unblocked_mmio_ranges: Mutex<Vec<UnblockedMmioRange>>,
dma_unblocked: Mutex<bool>,
vm/devices/pci/vpci_relay/src/lib.rs:451
- This collapses every capability-probe error into
NotTdispCapable, but that variant is serialized asSUCCESSwith every BAR and DMA resource markedSHAREDbybuild_isolation_reply. A transient host/channel failure can therefore be reported as an authoritative shared classification; preserve unsupported versus probe-error state and return an error/UNSUCCESSFULresult for the latter.
tracing::info!(
%instance_id,
failure_reason = ?e,
"TDISP not supported or failed to query capabilities"
);
vm/devices/pci/vpci_relay/src/lib.rs:559
- There is a stray period before
The, which makes this new documentation read incorrectly.
/// The write was deferred by cfg handling .The future carries out async
vm/devices/tdisp/src/lib.rs:160
Readyis also produced when the report omits BARs or when a slot is the upper half of a 64-bit BAR; those entries are intentionallyTdispResourceIsolation::Invalid(seeisolation_snapshot). This guarantee contradicts the public API's actual output and can cause consumers to assume every slot is classified.
/// Guaranteed to contain only `Shared` / `Private`.
vm/devices/tdisp/src/lib.rs:329
- This error log is directly reachable from an untrusted
ModifyMmioRangecommand, so a guest can submit invalid actions repeatedly and flood the paravisor's logs. Use the repository's rate-limited error logging for this guest-triggered path.
tracing::error!(
action = cmd.action,
"ModifyMmioRange action is not a valid TdispMmioRangeAction"
vm/devices/tdisp/src/lib.rs:338
- This error log is also directly reachable from an untrusted
ModifyMmioRangecommand, so a guest can repeatedly send an oversizedrange_idto flood the paravisor's logs. Use the repository's rate-limited error logging for this guest-triggered path.
tracing::error!(
range_id = cmd.range_id,
"ModifyMmioRange range_id does not fit in a u16"
);
error = TdispGuestOperationError::InvalidGuestCommandId;
vm/devices/tdisp/src/lib.rs:816
- A guest can send the valid
Invalidenum value repeatedly, so this bare error event is guest-controlled and unbounded. Use a rate-limited error event here (and apply the same treatment to the adjacent invalid-state log) to prevent log flooding.
if action == TdispMmioRangeAction::Invalid {
tracing::error!("ModifyMmioRange requested with an invalid action.");
return Err(TdispGuestOperationError::InvalidGuestCommandId);
- Files reviewed: 38/39 changed files
- Comments generated: 3
- Review effort level: Lite
d762e42 to
f626c6d
Compare
There was a problem hiding this comment.
🟡 Changes recommended
One critical and seven moderate findings remain unresolved, affecting error propagation, per-device isolation, command handling, and test compilation.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (7)
openhcl/openhcl_tdisp/src/noop.rs:183
- The validator API includes
target_vtlanddevice_id, and this validator can be shared through itsArcinterface, but blocking matches onlyrange_id. Blocking one TDI can therefore remove another TDI's recorded range when IDs or VTLs overlap; include the target VTL and device ID in the predicate.
self.unblocked_mmio_ranges
.lock()
.retain(|r| r.range_id != range_id);
openhcl/openhcl_tdisp/src/noop.rs:47
- DMA tracking is a single global boolean even though every DMA operation is scoped by
target_vtland TDIdevice_id. With a shared validator, unblocking one device makesdma_unblocked()true for all devices, and blocking any device clears the others' state; track the active DMA identities instead.
dma_unblocked: Mutex<bool>,
vm/devices/pci/vpci_relay/src/lib.rs:450
- All failures from the initial capability probe are collapsed into
tdisp_capable = false.supports_tdisp_relaythen returnsNotTdispCapable, which the VPCI server maps toSUCCESSwith all resourcesSHARED; a transient host/RPC failure is therefore reported as a valid shared device instead of an error. Preserve unsupported and failed-probe states separately.
Err(e) => {
tracing::info!(
%instance_id,
failure_reason = ?e,
"TDISP not supported or failed to query capabilities"
vm/devices/pci/vpci_relay/src/lib.rs:430
reserve_mock_bar_mmiodeliberately returnsNonewhen the relay range is too small, but this path immediately callsexpecton that result. Enabling the test flow with a one-device-sized relay MMIO range therefore panics during device arrival instead of reporting the configuration error.
let bar_mmio = self
.mock_bar_mmio
.expect("the mocked TDISP flow needs a reserved MMIO window");
vm/devices/pci/vpci_relay/src/lib.rs:620
- On the MMIO-enabled-to-disabled edge, the original command-register write is never applied: this future receives no
value, andclear_command_registerstarts from the previous shadow before clearing only MMIO and bus-master. A write that disables MMIO while changing another command bit therefore silently loses that change; merge the incoming command value first, then force the safety bits off.
(true, false) => {
let device = self.device.clone();
CfgWriteOutcome::Started(Box::pin(async move {
device.tdisp_on_device_deactivate().await;
}))
vm/devices/tdisp/src/lib.rs:160
Readyis also used for classifications containingInvalid:isolation_snapshotinitializes all BARs asInvalidand returns it for unimplemented or report-absent slots, and the VPCI reply maps that value explicitly. The public contract should not claim thatReadycontains onlySharedandPrivate.
/// Guaranteed to contain only `Shared` / `Private`.
vm/devices/tdisp/src/tests/devicereport_tests.rs:74
- This test uses
size_ofwithout importing or qualifying it; imports in the parenttestsmodule are not visible as unqualified names here, so the crate fails to compile with an unresolved name. Qualify the call (or add a local import).
- Files reviewed: 38/39 changed files
- Comments generated: 1
- Review effort level: Lite
| Err(err) => { | ||
| tracing::error!( | ||
| "tdisp_isolation_snapshot: query_capabilities failed (tdisp not supported or host errored out): {err}" | ||
| ); | ||
| return TdispIsolationReport::NotTdispCapable; |
Chris Oo (chris-oo)
left a comment
There was a problem hiding this comment.
no where near done reviewing, but wanted to leave some initial thoughts after looking at vpci_relay
| vtom: Option<u64>, | ||
| isolation_type: IsolationType, | ||
| options: VpciRelayOptions, | ||
| /// Base of the window the mocked TDISP flow programs into a device BAR, |
There was a problem hiding this comment.
this is only for testing?
| 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 { |
There was a problem hiding this comment.
do we intend for this test code to be available in the prod code? i'm wondering if we should cfg gate this, or we need it for testing it end-to-end?
There was a problem hiding this comment.
At least, i'm trying to understand how we can make it more clear what's test / mock code vs what's not, because sprinkling different control flow thru main code seems pretty unreadable/unextendable...
| /// 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)] |
There was a problem hiding this comment.
are we supposed to infer if we have deffered writes by the lenth of queue below?
There was a problem hiding this comment.
do we ever keep track what vps are pending on the bar write?
| /// 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()")] |
There was a problem hiding this comment.
maybe this should be inspect by iter? and then implement inspect on QueuedWrite?
| impl PollDevice for RelayedVpciDevice { | ||
| fn poll_device(&mut self, cx: &mut std::task::Context<'_>) { | ||
| self.waker = cx.waker().clone(); | ||
| while let Some((_, fut)) = self.pending.as_mut() { |
There was a problem hiding this comment.
we talked about this offline, but do we think we're going to need to support more of these queued writes in the future? or just for relayed tdisp devices?
|
|
||
| Box::pin(async move { | ||
| // If the device is not TDISP capable, return early with an invalid report. | ||
| if !tdisp_capable { |
There was a problem hiding this comment.
how is it possible we get down this path? is it because we blanket implement this trait for all relayed devices?
| /// 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. |
There was a problem hiding this comment.
is there a way to enforce that we called this with no operations in flight?
This PR includes all feature work and changes to the VPCI stack to support relayed TDISP devices over VPCI.
This includes:
Command register accesses in TDISP under the VPCI Relay
This is especially tricky. For the first time, VPCI command register writes, specifically to the command register, drive attestation cycles that could take a significant amount of time on the VP. The VPCI relay handles this by making the TDISP operations async with the command register write. While these operations are happening, other VPs can execute cfg accesses. These accesses are queued in the order recieved and processed as soon as the device completes its TDISP work. This ensures that TDISP attestation flows are serialized and the guest cannot footgun itself with a config space access during the attestation flows.
Testing end-to-end flows with OpenVMM
This PR contains an extension to the OpenVMM end-to-end TDISP test. In a previous PR, the NVMe fault controller emulator was repurposed for basic TDISP testing. In this PR, it's extended to support a full end-to-end acceptance flow including emulation of device BAR behavior on block/unblock.
The test plan is now as follows:
requires attestation and the acceptance of its BAR0 range before it answers to any
BAR requests.
report to the guest.
register BAR range.
accessible.
become inaccessible again.
Note: This PR does not include the infrastructure to actually attest a device, it just drives the device to a functional state.