From 7125ea04d2b00a506cc78ce6e612ad9b2bc82a00 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:45:28 -0700 Subject: [PATCH 1/6] Enforce probe coverafe for every backend and isolation tier --- .../plans/backend-support-probe-api.md | 12 ++- .../src/core/mxc_engine/backend_registry.rs | 98 ++++++++++++++++++ src/mxc-sdk/src/core/mxc_engine/platform.rs | 35 +++---- src/mxc-sdk/src/core/mxc_engine/probe.rs | 99 ++++++++++++------- 4 files changed, 180 insertions(+), 64 deletions(-) diff --git a/docs/development/plans/backend-support-probe-api.md b/docs/development/plans/backend-support-probe-api.md index d0f3250f2e..dc47408c58 100644 --- a/docs/development/plans/backend-support-probe-api.md +++ b/docs/development/plans/backend-support-probe-api.md @@ -160,7 +160,9 @@ See §7.9 for why the canonical probe stays in Rust rather than moving into the (`{"backend":"…","tier":"…"}`) and verifies that `tier` is **omitted** when `None` (`#[serde(skip_serializing_if = "Option::is_none")]`), never serialized as `null`. - Every non-`None` `tier` is one of the canonical `IsolationTier::as_str()` strings, -guarding against drift between this API and the tier ladder. +guarding against drift between this API and the tier ladder. The canonical list is derived from +`IsolationTier::ALL`, and a further test asserts every tier is reachable from `select_tier`, so a +new tier cannot land without probe coverage. - On Linux, `bubblewrap` and `lxc` each appear when their check passes (`bwrap --version` / `lxc-ls --version`). - On Linux, `bubblewrap` carries the `proxyEnforcement` capability when the private-namespace dependencies are present, and a `warnings` entry naming the missing dependency when they are not; @@ -169,8 +171,9 @@ the backend itself still appears either way. Both directions are covered by exer test host happens to have `slirp4netns` installed. - A serde snapshot pins the camelCase capability name (`"proxyEnforcement"`) and verifies that `capabilities` and `warnings` are **omitted** when empty. -- `wslc` appears when `WslcSdk::load()` resolves `wslcsdk.dll`; the remaining VM group -(`windows_sandbox`, `isolation_session`, `microvm`, `hyperlight`) never appears until its detector lands. +- `wslc` appears when `WslcSdk::load()` resolves `wslcsdk.dll`. Each backend declares a +`ProbeCoverage` in `mxc_engine::backend_registry`, and a test asserts that one registered as +`NotDetected` never appears in the output. - The TypeScript `getPlatformSupport()` output matches the native probe (parity by projection, §4.2), guarding against the two layers drifting. @@ -179,7 +182,8 @@ test host happens to have `slirp4netns` installed. Writing the missing detectors and wiring the TypeScript projection, one issue each: 1. `windows_sandbox` - optional-feature (DISM/registry) detector. 2. `isolation_session` - probe whether the `Windows.AI.IsolationSession.Preview` `IsoSessionOps` API class is registered on the OS (activation-factory resolvable), replacing the old build-number gate (see #761), and expose it to Rust. -3. `microvm` / `hyperlight` - hypervisor-presence probe. +3. `microvm` - hypervisor-presence probe. `hyperlight` has one (`is_whp_available()`); `microvm` +is registered as `ProbeCoverage::NotDetected` until its detector lands. 4. `lxc` - port the `lxc-ls` presence check from TypeScript to Rust, so the probe (not just the SDK) can report it (§4.2, step 1). 5. TypeScript projection - make `getPlatformSupport()` read the native backend availability via a diff --git a/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs b/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs index ff57e9392a..ad279412ff 100644 --- a/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs +++ b/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs @@ -9,55 +9,118 @@ use crate::mxc_common::models::ContainmentBackend; +/// Whether `available_backends()` has host detection for a backend. +/// +/// Declaring this is mandatory, so a new backend cannot reach the enum without +/// someone deciding whether the host probe reports it. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ProbeCoverage { + /// Reported by `available_backends()` whenever its host detection succeeds. + Detected, + /// Never reported, with the reason it has no host detector. + NotDetected(&'static str), +} + #[derive(Debug)] pub(crate) struct BackendRegistration { pub(crate) backend: ContainmentBackend, pub(crate) experimental: bool, + /// Read by the probe-coverage invariant tests rather than at runtime. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) probe_coverage: ProbeCoverage, } static BACKENDS: [BackendRegistration; 10] = [ BackendRegistration { backend: ContainmentBackend::ProcessContainer, experimental: false, + probe_coverage: ProbeCoverage::Detected, }, BackendRegistration { backend: ContainmentBackend::Wslc, experimental: false, + probe_coverage: ProbeCoverage::Detected, }, BackendRegistration { backend: ContainmentBackend::Lxc, experimental: false, + probe_coverage: ProbeCoverage::Detected, }, BackendRegistration { backend: ContainmentBackend::Vm, experimental: false, + probe_coverage: ProbeCoverage::NotDetected( + "abstract VM intent; resolves to a concrete backend per host, and that backend is \ + detected in its own right", + ), }, BackendRegistration { backend: ContainmentBackend::MicroVm, experimental: true, + probe_coverage: ProbeCoverage::NotDetected( + "no hypervisor-presence detector yet; see the follow-up items in \ + docs/development/plans/backend-support-probe-api.md", + ), }, BackendRegistration { backend: ContainmentBackend::Hyperlight, experimental: true, + probe_coverage: ProbeCoverage::Detected, }, BackendRegistration { backend: ContainmentBackend::WindowsSandbox, experimental: true, + probe_coverage: ProbeCoverage::Detected, }, BackendRegistration { backend: ContainmentBackend::IsolationSession, experimental: false, + probe_coverage: ProbeCoverage::Detected, }, BackendRegistration { backend: ContainmentBackend::Seatbelt, experimental: false, + probe_coverage: ProbeCoverage::Detected, }, BackendRegistration { backend: ContainmentBackend::Bubblewrap, experimental: false, + probe_coverage: ProbeCoverage::Detected, }, ]; +/// Every registered backend, for callers that must stay exhaustive over them. +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn all() -> &'static [BackendRegistration] { + &BACKENDS +} + +/// Canonical wire names held as literals, so renaming one is a deliberate edit +/// rather than a silent consequence of changing `wire_name()`. Shared by the +/// probe and platform-support tests so the two cannot disagree. +#[cfg(test)] +pub(crate) const CANONICAL_BACKEND_NAMES: &[(ContainmentBackend, &str)] = &[ + (ContainmentBackend::ProcessContainer, "processcontainer"), + (ContainmentBackend::Wslc, "wslc"), + (ContainmentBackend::Lxc, "lxc"), + (ContainmentBackend::Vm, "vm"), + (ContainmentBackend::MicroVm, "microvm"), + (ContainmentBackend::Hyperlight, "hyperlight"), + (ContainmentBackend::WindowsSandbox, "windows_sandbox"), + (ContainmentBackend::IsolationSession, "isolation_session"), + (ContainmentBackend::Seatbelt, "seatbelt"), + (ContainmentBackend::Bubblewrap, "bubblewrap"), +]; + +/// Every canonical backend wire name. +#[cfg(test)] +pub(crate) fn all_containment_names() -> Vec { + all() + .iter() + .map(|entry| entry.backend.wire_name().to_string()) + .collect() +} + /// Every new backend must select a registration; there is no implicit default. pub(crate) fn registration(backend: &ContainmentBackend) -> &'static BackendRegistration { let index = match backend { @@ -102,4 +165,39 @@ mod tests { BTreeSet::from(["microvm", "hyperlight", "windows_sandbox"]) ); } + + #[test] + fn canonical_names_match_the_backend_wire_names() { + for (backend, expected) in CANONICAL_BACKEND_NAMES { + assert_eq!(backend.wire_name(), *expected, "{backend:?}"); + } + } + + /// Fails when a backend is added to the enum without a canonical name, + /// which would leave it invisible to every name-based probe assertion. + #[test] + fn the_canonical_name_table_covers_every_registered_backend() { + let registered: BTreeSet<_> = BACKENDS + .iter() + .map(|entry| entry.backend.wire_name()) + .collect(); + let canonical: BTreeSet<_> = CANONICAL_BACKEND_NAMES + .iter() + .map(|(_, name)| *name) + .collect(); + assert_eq!(registered, canonical); + } + + #[test] + fn undetected_backends_explain_why() { + for entry in all() { + if let ProbeCoverage::NotDetected(reason) = &entry.probe_coverage { + assert!( + !reason.trim().is_empty(), + "{:?} must say why it has no host detector", + entry.backend + ); + } + } + } } diff --git a/src/mxc-sdk/src/core/mxc_engine/platform.rs b/src/mxc-sdk/src/core/mxc_engine/platform.rs index 393079e382..0f208f8c82 100644 --- a/src/mxc-sdk/src/core/mxc_engine/platform.rs +++ b/src/mxc-sdk/src/core/mxc_engine/platform.rs @@ -14,6 +14,8 @@ use serde::Serialize; +use crate::mxc_common::models::ContainmentBackend; + /// Whether the host can enforce Bubblewrap proxy-only egress. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] @@ -89,10 +91,10 @@ where // `getPlatformSupport` reads the same answer. let mut available_methods = Vec::new(); if lxc_available { - available_methods.push("lxc".to_string()); + available_methods.push(ContainmentBackend::Lxc.wire_name().to_string()); } if bwrap_probe.is_ok() { - available_methods.push("bubblewrap".to_string()); + available_methods.push(ContainmentBackend::Bubblewrap.wire_name().to_string()); } match bwrap_probe { @@ -141,7 +143,7 @@ pub fn platform_support() -> PlatformSupport { if std::path::Path::new("/usr/bin/sandbox-exec").exists() { PlatformSupport { is_supported: true, - available_methods: vec!["seatbelt".to_string()], + available_methods: vec![ContainmentBackend::Seatbelt.wire_name().to_string()], ..Default::default() } } else { @@ -169,17 +171,18 @@ pub fn platform_support() -> PlatformSupport { #[cfg(target_os = "windows")] { - let mut available_methods = vec!["processcontainer".to_string()]; + let mut available_methods = + vec![ContainmentBackend::ProcessContainer.wire_name().to_string()]; // `windows_sandbox` is a host-capability backend the SDK can't launch, // so it is reported by `available_backends()` rather than here. // // WSLC and IsolationSession are additional explicit backends rather // than fallbacks: report each only when the host has it. if wslc_available() { - available_methods.push("wslc".to_string()); + available_methods.push(ContainmentBackend::Wslc.wire_name().to_string()); } if isolation_session_present() { - available_methods.push("isolation_session".to_string()); + available_methods.push(ContainmentBackend::IsolationSession.wire_name().to_string()); } PlatformSupport { is_supported: true, @@ -269,20 +272,7 @@ mod tests { }; #[cfg(target_os = "linux")] use crate::bwrap_common::bwrap_version::{BwrapUnavailable, BwrapVersion, MIN_BWRAP_VERSION}; - use crate::mxc_common::models::ContainmentBackend; - - const CANONICAL_BACKEND_NAMES: &[(ContainmentBackend, &str)] = &[ - (ContainmentBackend::ProcessContainer, "processcontainer"), - (ContainmentBackend::Wslc, "wslc"), - (ContainmentBackend::Lxc, "lxc"), - (ContainmentBackend::Vm, "vm"), - (ContainmentBackend::MicroVm, "microvm"), - (ContainmentBackend::Hyperlight, "hyperlight"), - (ContainmentBackend::WindowsSandbox, "windows_sandbox"), - (ContainmentBackend::IsolationSession, "isolation_session"), - (ContainmentBackend::Seatbelt, "seatbelt"), - (ContainmentBackend::Bubblewrap, "bubblewrap"), - ]; + use crate::mxc_engine::backend_registry::{self, CANONICAL_BACKEND_NAMES}; #[cfg(target_os = "linux")] #[test] @@ -321,10 +311,7 @@ mod tests { } fn all_containment_names() -> Vec { - CANONICAL_BACKEND_NAMES - .iter() - .map(|(_, name)| (*name).to_string()) - .collect() + backend_registry::all_containment_names() } /// Guards the reported string literals against drift from canonical runtime diff --git a/src/mxc-sdk/src/core/mxc_engine/probe.rs b/src/mxc-sdk/src/core/mxc_engine/probe.rs index 428aa62071..4edf9711f3 100644 --- a/src/mxc-sdk/src/core/mxc_engine/probe.rs +++ b/src/mxc-sdk/src/core/mxc_engine/probe.rs @@ -286,37 +286,16 @@ fn select_tier( #[cfg(test)] mod tests { use super::*; - use crate::mxc_common::models::ContainmentBackend; - - const CANONICAL_BACKEND_NAMES: &[(ContainmentBackend, &str)] = &[ - (ContainmentBackend::ProcessContainer, "processcontainer"), - (ContainmentBackend::Wslc, "wslc"), - (ContainmentBackend::Lxc, "lxc"), - (ContainmentBackend::Vm, "vm"), - (ContainmentBackend::MicroVm, "microvm"), - (ContainmentBackend::Hyperlight, "hyperlight"), - (ContainmentBackend::WindowsSandbox, "windows_sandbox"), - (ContainmentBackend::IsolationSession, "isolation_session"), - (ContainmentBackend::Seatbelt, "seatbelt"), - (ContainmentBackend::Bubblewrap, "bubblewrap"), - ]; - - fn all_containment_names() -> Vec { - CANONICAL_BACKEND_NAMES - .iter() - .map(|(_, name)| (*name).to_string()) - .collect() - } + use crate::mxc_engine::backend_registry::{self, ProbeCoverage}; - #[test] - fn backend_wire_names_match_the_canonical_table() { - for (backend, expected) in CANONICAL_BACKEND_NAMES { - assert_eq!(backend.wire_name(), *expected, "{backend:?}"); - } + /// Every tier the process-container ladder can name, derived from + /// [`IsolationTier`] so a new rung cannot be missed here. + #[cfg(target_os = "windows")] + fn canonical_tiers() -> Vec<&'static str> { + use crate::process_container_common::fallback_detector::IsolationTier; + IsolationTier::ALL.iter().map(|t| t.as_str()).collect() } - const CANONICAL_TIERS: [&str; 3] = ["base-container", "appcontainer-bfs", "appcontainer-dacl"]; - #[test] fn tier_is_omitted_from_json_when_none() { let backend = AvailableBackend::tierless("seatbelt"); @@ -420,7 +399,7 @@ mod tests { #[test] fn every_reported_backend_uses_a_canonical_name() { - let known = all_containment_names(); + let known = backend_registry::all_containment_names(); for entry in available_backends() { assert!( known.contains(&entry.backend), @@ -430,26 +409,74 @@ mod tests { } } + /// A backend registered as having no host detector must not surface here; + /// the registration and the probe are two halves of one decision. + #[test] + fn backends_without_a_host_detector_are_never_reported() { + let reported: Vec = available_backends() + .into_iter() + .map(|entry| entry.backend) + .collect(); + for entry in backend_registry::all() { + if let ProbeCoverage::NotDetected(reason) = &entry.probe_coverage { + let name = entry.backend.wire_name(); + assert!( + !reported.contains(&name.to_string()), + "{name} is reported by available_backends() but registered as undetected: \ + {reason}" + ); + } + } + } + + /// Only the Windows process container has a tier ladder, so a tier + /// appearing elsewhere means a new ladder landed without probe coverage. + #[cfg(not(target_os = "windows"))] + #[test] + fn no_backend_reports_a_tier_off_windows() { + for entry in available_backends() { + assert_eq!( + entry.tier, None, + "{} reported a tier, but no non-Windows backend has a tier ladder", + entry.backend + ); + } + } + + #[cfg(target_os = "windows")] #[test] fn every_reported_tier_is_a_canonical_tier_string() { + let canonical = canonical_tiers(); for entry in available_backends() { if let Some(tier) = entry.tier { assert!( - CANONICAL_TIERS.contains(&tier.as_str()), + canonical.contains(&tier.as_str()), "reported tier {tier:?} is not a canonical IsolationTier string" ); } } } - /// Guards `CANONICAL_TIERS` against drift from `IsolationTier::as_str()`. + /// Fails when a tier is added to [`IsolationTier`] without a rung in + /// [`select_tier`], which would leave the probe unable to ever report it. #[cfg(target_os = "windows")] #[test] - fn canonical_tier_strings_match_isolation_tier() { + fn every_isolation_tier_is_reachable_from_select_tier() { use crate::process_container_common::fallback_detector::IsolationTier; - assert_eq!(IsolationTier::BaseContainer.as_str(), CANONICAL_TIERS[0]); - assert_eq!(IsolationTier::AppContainerBfs.as_str(), CANONICAL_TIERS[1]); - assert_eq!(IsolationTier::AppContainerDacl.as_str(), CANONICAL_TIERS[2]); + let reachable: Vec = + [(true, true), (true, false), (false, true), (false, false)] + .into_iter() + .map(|(base_container_usable, tier2_bfs_enabled)| { + select_tier(base_container_usable, tier2_bfs_enabled) + }) + .collect(); + for tier in IsolationTier::ALL { + assert!( + reachable.contains(&tier), + "select_tier can never report {:?}; the probe tier ladder is missing a rung", + tier.as_str() + ); + } } #[cfg(target_os = "windows")] @@ -462,7 +489,7 @@ mod tests { .expect("processcontainer must always be reported on Windows"); let tier = pc.tier.as_deref().expect("processcontainer carries a tier"); assert!( - CANONICAL_TIERS.contains(&tier), + canonical_tiers().contains(&tier), "unexpected processcontainer tier: {tier:?}" ); } From 6b693776121fc499ae88f02fa5fe9355bfd7c81e Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:59:57 -0700 Subject: [PATCH 2/6] Tie probe coverage to real detectors and fix tier parity source Address review feedback on #1491: - ProbeCoverage::Detected was inert metadata. Add an exhaustive host_detector() mapping in probe.rs and a test requiring Detected to name a detector and NotDetected to have none, so a backend cannot be registered as detected without a branch in available_backends(). - Correct the wslc test-plan bullet: availability comes from wslc_common::is_available() (WSL2 plus the WSLC runtime), not from resolving wslcsdk.dll. Fix the Repository consistency check: check-dotnet-api-parity.js read tier wire names from probe.rs's CANONICAL_TIERS, which this branch removed. Read the isolation_tiers! ladder in fallback_detector.rs instead, so the parity check compares against the canonical declaration rather than a test mirror. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 25b86546-f178-4ab4-bda2-7443aa01f541 --- .../plans/backend-support-probe-api.md | 3 +- scripts/check-dotnet-api-parity.js | 15 ++++-- src/mxc-sdk/src/core/mxc_engine/probe.rs | 48 +++++++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/docs/development/plans/backend-support-probe-api.md b/docs/development/plans/backend-support-probe-api.md index dc47408c58..65c4021d42 100644 --- a/docs/development/plans/backend-support-probe-api.md +++ b/docs/development/plans/backend-support-probe-api.md @@ -171,7 +171,8 @@ the backend itself still appears either way. Both directions are covered by exer test host happens to have `slirp4netns` installed. - A serde snapshot pins the camelCase capability name (`"proxyEnforcement"`) and verifies that `capabilities` and `warnings` are **omitted** when empty. -- `wslc` appears when `WslcSdk::load()` resolves `wslcsdk.dll`. Each backend declares a +- `wslc` appears when `wslc_common::is_available()` confirms WSL2 and the WSLC runtime are present; +resolving `wslcsdk.dll` alone is not sufficient. Each backend declares a `ProbeCoverage` in `mxc_engine::backend_registry`, and a test asserts that one registered as `NotDetected` never appears in the output. - The TypeScript `getPlatformSupport()` output matches the native probe (parity by projection, §4.2), diff --git a/scripts/check-dotnet-api-parity.js b/scripts/check-dotnet-api-parity.js index 030bce5e54..3deeb96117 100644 --- a/scripts/check-dotnet-api-parity.js +++ b/scripts/check-dotnet-api-parity.js @@ -444,13 +444,20 @@ for (const member of rustCapabilityMembers) { } } -const tierMatch = /const CANONICAL_TIERS:[^=]+=\s*\[([^\]]+)\]/s.exec( - rustProbeFull +const rustTierLadder = read( + "src", + "mxc-sdk", + "src", + "backends", + "process_container", + "common", + "fallback_detector.rs" ); +const tierMatch = /isolation_tiers!\s*\{(.*?)\n\}/s.exec(rustTierLadder); if (!tierMatch) { - errors.push("probe.rs: could not find CANONICAL_TIERS"); + errors.push("fallback_detector.rs: could not find the isolation_tiers! ladder"); } else { - const rustTiers = [...tierMatch[1].matchAll(/"([^"]+)"/g)].map( + const rustTiers = [...tierMatch[1].matchAll(/=>\s*"([^"]+)"/g)].map( (match) => match[1] ); const managedTiers = [ diff --git a/src/mxc-sdk/src/core/mxc_engine/probe.rs b/src/mxc-sdk/src/core/mxc_engine/probe.rs index 4edf9711f3..f5c58d85ff 100644 --- a/src/mxc-sdk/src/core/mxc_engine/probe.rs +++ b/src/mxc-sdk/src/core/mxc_engine/probe.rs @@ -296,6 +296,54 @@ mod tests { IsolationTier::ALL.iter().map(|t| t.as_str()).collect() } + /// The host detector [`available_backends`] calls for each backend, or + /// `None` when it has none. Exhaustive on purpose: a new backend cannot + /// compile without naming its detector here, and + /// `probe_coverage_matches_the_host_detector_mapping` holds it to the + /// `ProbeCoverage` the registry declares. + fn host_detector(backend: &ContainmentBackend) -> Option<&'static str> { + match backend { + ContainmentBackend::ProcessContainer => Some("always reported on Windows"), + ContainmentBackend::Wslc => Some("wslc_common::is_available"), + ContainmentBackend::Lxc => Some("lxc_common::availability::is_lxc_available"), + ContainmentBackend::Vm => None, + ContainmentBackend::MicroVm => None, + ContainmentBackend::Hyperlight => Some("hyperlight_common::is_whp_available"), + ContainmentBackend::WindowsSandbox => { + Some("windows_sandbox_lifecycle::availability::is_windows_sandbox_available") + } + ContainmentBackend::IsolationSession => { + Some("isolation_session_common::availability::is_isolation_session_available") + } + ContainmentBackend::Seatbelt => Some("/usr/bin/sandbox-exec presence"), + ContainmentBackend::Bubblewrap => Some("bwrap_common::bwrap_version::probe_bwrap"), + } + } + + /// Ties the registry's declaration to real detection: a backend registered + /// as `Detected` must name a detector, and one registered as `NotDetected` + /// must not have one. + #[test] + fn probe_coverage_matches_the_host_detector_mapping() { + for entry in backend_registry::all() { + let detector = host_detector(&entry.backend); + match &entry.probe_coverage { + ProbeCoverage::Detected => assert!( + detector.is_some(), + "{:?} is registered as Detected but available_backends() has no detector \ + for it", + entry.backend + ), + ProbeCoverage::NotDetected(reason) => assert!( + detector.is_none(), + "{:?} is registered as NotDetected ({reason}) but names the detector {:?}", + entry.backend, + detector + ), + } + } + } + #[test] fn tier_is_omitted_from_json_when_none() { let backend = AvailableBackend::tierless("seatbelt"); From a7314ddca9889519506e81a13e625c929bbc47c8 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:49:06 -0700 Subject: [PATCH 3/6] Prove probe coverage by running the real reporting path The probe-coverage gate compared a declaration against another declaration: a backend registered as Detected only had to name a detector string, which a typo could satisfy. Make the gate run the production reporting code instead. ProbeCoverage::Detected now names the target_os values a backend is detected on. The platform reporting functions take their host checks as parameters, so tests can force every check to pass and assert that each backend declared detected on the current target is actually emitted, and that a NotDetected one still is not. Registering a backend as detected without adding a branch to available_backends() now fails. Also drop the completed windows_sandbox, isolation_session, and lxc detector items from the plan doc's follow-up list and gap tables, which no longer match the code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 25b86546-f178-4ab4-bda2-7443aa01f541 --- .../architecture/repository-architecture.md | 9 +- .../plans/backend-support-probe-api.md | 59 +++-- .../src/core/mxc_engine/backend_registry.rs | 57 ++++- src/mxc-sdk/src/core/mxc_engine/probe.rs | 213 ++++++++++++------ 4 files changed, 232 insertions(+), 106 deletions(-) diff --git a/docs/development/architecture/repository-architecture.md b/docs/development/architecture/repository-architecture.md index ec655fd437..754d39b58c 100644 --- a/docs/development/architecture/repository-architecture.md +++ b/docs/development/architecture/repository-architecture.md @@ -57,10 +57,11 @@ Shared parsing and normalization live in the `mxc_common` module; backend-specif validation and enforcement live with each backend. `mxc_engine/src/backend_registry.rs` owns backend registration metadata, -including experimental classification, keyed by the shared `ContainmentBackend` -enum. Runtime authorization consults that registry. Exact-contract publication, -build-feature availability, and host-capability probing remain separate; the -registry neither dispatches workloads nor adds backend dependencies to +including experimental classification and probe coverage, keyed by the shared +`ContainmentBackend` enum. Runtime authorization consults that registry. +Exact-contract publication, build-feature availability, and host-capability +probing remain separate; the registry declares which backends the probe covers +but neither detects them, dispatches workloads, nor adds backend dependencies to `mxc_common`. ## Request flow diff --git a/docs/development/plans/backend-support-probe-api.md b/docs/development/plans/backend-support-probe-api.md index 65c4021d42..f1731e7db9 100644 --- a/docs/development/plans/backend-support-probe-api.md +++ b/docs/development/plans/backend-support-probe-api.md @@ -98,9 +98,9 @@ ones. | Backend | How presence is detected today | Where | Risk with this method | | --- | --- | --- | --- | | `base-container` | `BaseContainerRunner::is_base_container_api_present()` calls `IsApiSetImplemented` for the process-security-environment API set. It does not load `processmodel.dll`, create a security environment, or start a process. | Rust | This proves that Windows implements the API-set contract; request-specific support bits are queried separately when the policy requires them. | -| `windows_sandbox` | `isWindowsSandboxAvailable()` runs `dism /online /get-featureinfo `
`/featurename:Containers-DisposableClientVM ` and looks for `State : Enabled`
if DISM throws (usually non-elevated) it falls back to
`fs.existsSync(%SystemRoot%\\System32\\WindowsSandbox.exe)`; result cached. | TypeScript SDK | `dism /online` needs elevation, so a non-elevated caller can't tell *disabled* from
*no permission* and drops to the exe-existence checkwhich proves the feature is installed,
not that a sandbox VM can boot. (Will move to Rust) | -| `lxc` | `isLxcAvailable()` runs `lxc-ls --version`; a clean exit means available. | TypeScript SDK | Only proves the `lxc-ls` CLI is on `PATH`, not that liblxc is loadable or that the caller has
the privileges to actually start a container, so it can report available on a host where a real run fails.
(Will move to Rust) | -| `wslc` | `WslcSdk::load()` loads `wslcsdk.dll` from the executable's own directory;
validates that every required export resolves. | Rust (execute path) | Runs on the *execute* path, not as a cheap standalone probe:
it actually loads the DLL and resolves symbols. Proves the SDK runtime loads,
not that a WSL distro/runtime is functional. Feature-gated. | +| `windows_sandbox` | `isWindowsSandboxAvailable()` runs `dism /online /get-featureinfo `
`/featurename:Containers-DisposableClientVM ` and looks for `State : Enabled`
if DISM throws (usually non-elevated) it falls back to
`fs.existsSync(%SystemRoot%\\System32\\WindowsSandbox.exe)`; result cached. | TypeScript SDK | `dism /online` needs elevation, so a non-elevated caller can't tell *disabled* from
*no permission* and drops to the exe-existence checkwhich proves the feature is installed,
not that a sandbox VM can boot. (Also in Rust: `windows_sandbox_lifecycle::availability`; the TypeScript copy is removed by §4.2 step 2.) | +| `lxc` | `isLxcAvailable()` runs `lxc-ls --version`; a clean exit means available. | TypeScript SDK | Only proves the `lxc-ls` CLI is on `PATH`, not that liblxc is loadable or that the caller has
the privileges to actually start a container, so it can report available on a host where a real run fails.
(Also in Rust: `lxc_common::availability`; the TypeScript copy is removed by §4.2 step 2.) | +| `wslc` | `wslc_common::is_available()` confirms WSL2 and the WSLC runtime are present. | Rust | Resolving `wslcsdk.dll` alone is not sufficient and is not what this checks;
`WslcSdk::load()` still runs on the *execute* path. Feature-gated. | ### 3.2 Isolation tiers (process-container only) @@ -120,27 +120,36 @@ reachable, already exist in `process_container_common`: ### 4.1 Backends still missing a Rust detector -Four backends have no *probe-suitable* (cheap, no persistent host mutation, no -process/VM launch) host detector in Rust today, so a truthful availability signal -for them **cannot be built just yet**. +Only `microvm` lacks a *probe-suitable* (cheap, no persistent host mutation, no +process/VM launch) host detector in Rust, so a truthful availability signal for +it **cannot be built just yet**. `vm` is an abstract intent that resolves to a +concrete backend per host and is never probed in its own right. | Backend | What a real probe needs | What exists today | Risk if faked | | --- | --- | --- | --- | -| `windows_sandbox` | DISM/registry check of the *Containers-DisposableClientVM* optional feature | only a private "is the `.exe` on disk" check | reports available when the feature is off → launch fails | -| `isolation_session` | activation of the in-proc `Windows.AI.IsolationSession.Preview` `IsoSessionOps` runtime class succeeds (the API class is registered on the OS **and** its OS feature gate is on) **and** the backend feature is compiled | as of #761, detection queries whether the API class is registered rather than gating on a build number; a `CLASS_E_CLASSNOTAVAILABLE` / `REGDB_E_CLASSNOTREG` activation failure means unavailable | none for false-availability now — a machine without the API registered fails activation cleanly; still needs a cheap probe seam so callers don't have to attempt a real activation | | `microvm` | feature compiled, NanVix runtime files staged, and WHP usable on Windows or `/dev/kvm` readable/writable on Linux | nothing | checking only a hypervisor can report availability when required runtime files are missing | -| `hyperlight` | hypervisor present + feature compiled | nothing | same VM-boot risk | + +The detectors for the other previously-missing backends: + +| Backend | Detector | Note | +| --- | --- | --- | +| `windows_sandbox` | `WindowsSandbox.exe` present in the system directory | Windows installs it only with *Containers-DisposableClientVM* enabled. The DISM query needs elevation and this probe runs unelevated, so the `.exe` check is the probe, not a fallback. | +| `isolation_session` | `IsoSessionOps` activates and reports a `LocalAgentUser` feature level above zero | Queries API-class registration rather than a build number (#761); a `CLASS_E_CLASSNOTAVAILABLE` / `REGDB_E_CLASSNOTREG` activation failure means unavailable. | +| `lxc` | `lxc-ls --version` exits cleanly within a 3s bound | §4.2, step 1. | + +`hyperlight` has `is_whp_available()`, which checks the hypervisor without +booting a VM. ### 4.2 The parity rule: detect once, project into TS -Detection is split by layer today : `base-container`/`wslc` are Rust-only, while -`windows_sandbox`/`lxc` are TypeScript-only, so the two layers can and already -do disagree. The fix is: detect each capability in exactly one place (Rust), and -have TypeScript read that result rather than compute its own. +Rust detects every backend that has a detector, but `getPlatformSupport()` still +runs its own `dism`/`lxc-ls`, so the two layers can disagree. The fix is: detect +each capability in exactly one place (Rust), and have TypeScript read that +result rather than compute its own. | Step | What | Why it gives parity | | --- | --- | --- | -| 1. Consolidate detectors in Rust | Port the two TypeScript-only checks (`windows_sandbox` DISM/feature check, `lxc-ls`) into Rust
so `available_backends()` covers every backend. | Each backend has exactly one detector. | +| 1. Consolidate detectors in Rust — **landed** | The `windows_sandbox` and `lxc` checks live in Rust
(`windows_sandbox_lifecycle::availability`, `lxc_common::availability`), so `available_backends()` covers every backend with a detector. | Each backend has exactly one detector. | | 2. TypeScript stops probing itself | `getPlatformSupport()` reads the native backend-availability result instead of running its own `dism`/`lxc-ls`.
The transport must be **side-effect-free**: `wxc-exec --probe` runs *after* `recover_orphaned_state()` (which can restore/prune DACL state on the host), so it cannot be extended unchanged without violating the read-only contract.
Instead, expose backend availability via a dedicated mode handled **before** DACL recovery (e.g. `wxc-exec --available-backends`), or directly through `mxc_ffi`. | TypeScript becomes a pure projection of the Rust result, without triggering host mutation. | | 3. Names flow from serde | Backend names (`Containment`) and tier strings (`IsolationTier::as_str()`) are already Rust-serialized;
TypeScript consumes them as-is instead of hand-re-encoding. | Removes the wire-name drift class structurally. | @@ -172,22 +181,24 @@ test host happens to have `slirp4netns` installed. - A serde snapshot pins the camelCase capability name (`"proxyEnforcement"`) and verifies that `capabilities` and `warnings` are **omitted** when empty. - `wslc` appears when `wslc_common::is_available()` confirms WSL2 and the WSLC runtime are present; -resolving `wslcsdk.dll` alone is not sufficient. Each backend declares a -`ProbeCoverage` in `mxc_engine::backend_registry`, and a test asserts that one registered as -`NotDetected` never appears in the output. +resolving `wslcsdk.dll` alone is not sufficient. Each backend declares a `ProbeCoverage` in +`mxc_engine::backend_registry`, naming either the targets it is detected on or why it has no +detector. The platform reporting functions take their host checks as parameters, so two tests run +the real reporting with every check forced to pass: one asserts every backend declared detected on +the current target is reported, the other that a backend declared `NotDetected` still never is. +Registering a backend as detected without adding a branch to `available_backends()` fails the first. - The TypeScript `getPlatformSupport()` output matches the native probe (parity by projection, §4.2), guarding against the two layers drifting. ## 6. Follow-up work items -Writing the missing detectors and wiring the TypeScript projection, one issue each: -1. `windows_sandbox` - optional-feature (DISM/registry) detector. -2. `isolation_session` - probe whether the `Windows.AI.IsolationSession.Preview` `IsoSessionOps` API class is registered on the OS (activation-factory resolvable), replacing the old build-number gate (see #761), and expose it to Rust. -3. `microvm` - hypervisor-presence probe. `hyperlight` has one (`is_whp_available()`); `microvm` +Remaining detector and TypeScript-projection work, one issue each. The +`windows_sandbox`, `isolation_session`, and `lxc` detectors have landed in Rust +and are registered as `ProbeCoverage::Detected`. + +1. `microvm` - hypervisor-presence probe. `hyperlight` has one (`is_whp_available()`); `microvm` is registered as `ProbeCoverage::NotDetected` until its detector lands. -4. `lxc` - port the `lxc-ls` presence check from TypeScript to Rust, -so the probe (not just the SDK) can report it (§4.2, step 1). -5. TypeScript projection - make `getPlatformSupport()` read the native backend availability via a +2. TypeScript projection - make `getPlatformSupport()` read the native backend availability via a side-effect-free transport (e.g. a new `wxc-exec --available-backends` mode handled **before** `recover_orphaned_state()`, or `mxc_ffi`) instead of running its own `dism`/`lxc-ls`, so the two layers can't drift (§4.2, step 2). Do **not** extend the existing `--probe` flag: it runs after diff --git a/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs b/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs index ad279412ff..8801659e79 100644 --- a/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs +++ b/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs @@ -15,8 +15,9 @@ use crate::mxc_common::models::ContainmentBackend; /// someone deciding whether the host probe reports it. #[derive(Debug, PartialEq, Eq)] pub(crate) enum ProbeCoverage { - /// Reported by `available_backends()` whenever its host detection succeeds. - Detected, + /// Reported by `available_backends()` on the listed `target_os` values + /// whenever its host detection succeeds. + Detected(&'static [&'static str]), /// Never reported, with the reason it has no host detector. NotDetected(&'static str), } @@ -34,17 +35,17 @@ static BACKENDS: [BackendRegistration; 10] = [ BackendRegistration { backend: ContainmentBackend::ProcessContainer, experimental: false, - probe_coverage: ProbeCoverage::Detected, + probe_coverage: ProbeCoverage::Detected(&["windows"]), }, BackendRegistration { backend: ContainmentBackend::Wslc, experimental: false, - probe_coverage: ProbeCoverage::Detected, + probe_coverage: ProbeCoverage::Detected(&["windows"]), }, BackendRegistration { backend: ContainmentBackend::Lxc, experimental: false, - probe_coverage: ProbeCoverage::Detected, + probe_coverage: ProbeCoverage::Detected(&["linux"]), }, BackendRegistration { backend: ContainmentBackend::Vm, @@ -65,27 +66,27 @@ static BACKENDS: [BackendRegistration; 10] = [ BackendRegistration { backend: ContainmentBackend::Hyperlight, experimental: true, - probe_coverage: ProbeCoverage::Detected, + probe_coverage: ProbeCoverage::Detected(&["windows"]), }, BackendRegistration { backend: ContainmentBackend::WindowsSandbox, experimental: true, - probe_coverage: ProbeCoverage::Detected, + probe_coverage: ProbeCoverage::Detected(&["windows"]), }, BackendRegistration { backend: ContainmentBackend::IsolationSession, experimental: false, - probe_coverage: ProbeCoverage::Detected, + probe_coverage: ProbeCoverage::Detected(&["windows"]), }, BackendRegistration { backend: ContainmentBackend::Seatbelt, experimental: false, - probe_coverage: ProbeCoverage::Detected, + probe_coverage: ProbeCoverage::Detected(&["macos"]), }, BackendRegistration { backend: ContainmentBackend::Bubblewrap, experimental: false, - probe_coverage: ProbeCoverage::Detected, + probe_coverage: ProbeCoverage::Detected(&["linux"]), }, ]; @@ -121,6 +122,20 @@ pub(crate) fn all_containment_names() -> Vec { .collect() } +/// Wire names of the backends the registry claims the probe detects on the +/// target this build runs on. +#[cfg(test)] +pub(crate) fn detected_on_current_target() -> Vec<&'static str> { + all() + .iter() + .filter(|entry| match &entry.probe_coverage { + ProbeCoverage::Detected(targets) => targets.contains(&std::env::consts::OS), + ProbeCoverage::NotDetected(_) => false, + }) + .map(|entry| entry.backend.wire_name()) + .collect() +} + /// Every new backend must select a registration; there is no implicit default. pub(crate) fn registration(backend: &ContainmentBackend) -> &'static BackendRegistration { let index = match backend { @@ -200,4 +215,26 @@ mod tests { } } } + + /// An empty or unrecognized target list would satisfy the probe-coverage + /// gate vacuously, so a detected backend must name real targets. + #[test] + fn detected_backends_name_supported_targets() { + for entry in all() { + if let ProbeCoverage::Detected(targets) = &entry.probe_coverage { + assert!( + !targets.is_empty(), + "{:?} is registered as Detected but names no target", + entry.backend + ); + for target in *targets { + assert!( + ["windows", "linux", "macos"].contains(target), + "{:?} names the unknown target {target:?}", + entry.backend + ); + } + } + } + } } diff --git a/src/mxc-sdk/src/core/mxc_engine/probe.rs b/src/mxc-sdk/src/core/mxc_engine/probe.rs index f5c58d85ff..16d508da86 100644 --- a/src/mxc-sdk/src/core/mxc_engine/probe.rs +++ b/src/mxc-sdk/src/core/mxc_engine/probe.rs @@ -116,7 +116,17 @@ pub fn available_backends() -> Vec { "failed to query identity-less loopback proxy support: {error}" )), } - windows_backends(tier, support) + windows_backends( + tier, + support, + WindowsHostAvailability { + windows_sandbox: + crate::windows_sandbox_lifecycle::availability::is_windows_sandbox_available(), + wslc: wslc_host_available(), + isolation_session: isolation_session_host_available(), + hyperlight: hyperlight_host_available(), + }, + ) } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] { @@ -134,8 +144,15 @@ pub fn to_json_pretty(backends: &[AvailableBackend]) -> Result Vec { + macos_backends_with(std::path::Path::new("/usr/bin/sandbox-exec").exists()) +} + +/// Split from [`macos_backends`] so the reporting is testable without +/// depending on what the test host has installed. +#[cfg(target_os = "macos")] +fn macos_backends_with(sandbox_exec_present: bool) -> Vec { let mut backends = Vec::new(); - if std::path::Path::new("/usr/bin/sandbox-exec").exists() { + if sandbox_exec_present { backends.push(AvailableBackend::tierless( ContainmentBackend::Seatbelt.wire_name(), )); @@ -145,13 +162,28 @@ fn macos_backends() -> Vec { #[cfg(target_os = "linux")] fn linux_backends() -> Vec { + linux_backends_with( + crate::bwrap_common::bwrap_version::probe_bwrap().is_ok(), + crate::bwrap_common::proxy_network::probe_proxy_enforcement, + crate::lxc_common::availability::is_lxc_available(), + ) +} + +/// Split from [`linux_backends`] so the reporting is testable without a host +/// that has (or lacks) the backends. `proxy_enforcement` stays a closure +/// because the dependency walk costs several subprocess spawns and is only +/// meaningful once `bwrap` itself is usable. +#[cfg(target_os = "linux")] +fn linux_backends_with( + bwrap_available: bool, + proxy_enforcement: impl FnOnce() -> Result<(), String>, + lxc_available: bool, +) -> Vec { let mut backends = Vec::new(); - if crate::bwrap_common::bwrap_version::probe_bwrap().is_ok() { - backends.push(bubblewrap_backend( - crate::bwrap_common::proxy_network::probe_proxy_enforcement(), - )); + if bwrap_available { + backends.push(bubblewrap_backend(proxy_enforcement())); } - if crate::lxc_common::availability::is_lxc_available() { + if lxc_available { backends.push(AvailableBackend::tierless( ContainmentBackend::Lxc.wire_name(), )); @@ -189,10 +221,65 @@ fn capture_denials_available(native_capture: bool, guarded_capture: bool) -> boo native_capture || guarded_capture } +/// Host checks for the optional Windows backends, resolved by +/// [`available_backends`] and injected so the reporting is testable without a +/// host that has (or lacks) them. A field is `false` when its feature or +/// architecture gate is off. +#[cfg(target_os = "windows")] +#[derive(Debug, Clone, Copy, Default)] +struct WindowsHostAvailability { + windows_sandbox: bool, + wslc: bool, + isolation_session: bool, + hyperlight: bool, +} + +#[cfg(target_os = "windows")] +fn wslc_host_available() -> bool { + // Requires the host to actually run it (WSL2 + the WSLC runtime present), + // matching `platform_support()` and the runner preflight. `WslcSdk::load()` + // alone only proves the DLL and its exports resolve. + #[cfg(feature = "wslc")] + { + crate::wslc_common::is_available() + } + #[cfg(not(feature = "wslc"))] + { + false + } +} + +#[cfg(target_os = "windows")] +fn isolation_session_host_available() -> bool { + // Available when the `IsoSessionOps` WinRT class is registered on the OS. + #[cfg(feature = "isolation_session")] + { + crate::isolation_session_common::availability::is_isolation_session_available() + } + #[cfg(not(feature = "isolation_session"))] + { + false + } +} + +#[cfg(target_os = "windows")] +fn hyperlight_host_available() -> bool { + // WHP is delay-loaded; check before setup boots a VM. + #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] + { + crate::hyperlight_common::is_whp_available() + } + #[cfg(not(all(feature = "hyperlight", target_arch = "x86_64")))] + { + false + } +} + #[cfg(target_os = "windows")] fn windows_backends( tier: crate::process_container_common::fallback_detector::IsolationTier, support: ProcessContainerCapabilities, + hosts: WindowsHostAvailability, ) -> Vec { // `processcontainer` is always present and the only backend with a tier // ladder, so it carries its effective (highest-reachable) tier. @@ -222,33 +309,25 @@ fn windows_backends( }; let mut backends = vec![process_container]; - if crate::windows_sandbox_lifecycle::availability::is_windows_sandbox_available() { + if hosts.windows_sandbox { backends.push(AvailableBackend::tierless( ContainmentBackend::WindowsSandbox.wire_name(), )); } - // Report WSLC only when the host can actually run it (WSL2 + the WSLC - // runtime present), matching `platform_support()` and the runner preflight. - // `WslcSdk::load()` alone only proves the DLL and its exports resolve. - #[cfg(feature = "wslc")] - if crate::wslc_common::is_available() { + if hosts.wslc { backends.push(AvailableBackend::tierless( ContainmentBackend::Wslc.wire_name(), )); } - // Available when the `IsoSessionOps` WinRT class is registered on the OS. - #[cfg(feature = "isolation_session")] - if crate::isolation_session_common::availability::is_isolation_session_available() { + if hosts.isolation_session { backends.push(AvailableBackend::tierless( ContainmentBackend::IsolationSession.wire_name(), )); } - // WHP is delay-loaded; check before setup boots a VM. - #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] - if crate::hyperlight_common::is_whp_available() { + if hosts.hyperlight { backends.push(AvailableBackend::tierless( ContainmentBackend::Hyperlight.wire_name(), )); @@ -296,51 +375,47 @@ mod tests { IsolationTier::ALL.iter().map(|t| t.as_str()).collect() } - /// The host detector [`available_backends`] calls for each backend, or - /// `None` when it has none. Exhaustive on purpose: a new backend cannot - /// compile without naming its detector here, and - /// `probe_coverage_matches_the_host_detector_mapping` holds it to the - /// `ProbeCoverage` the registry declares. - fn host_detector(backend: &ContainmentBackend) -> Option<&'static str> { - match backend { - ContainmentBackend::ProcessContainer => Some("always reported on Windows"), - ContainmentBackend::Wslc => Some("wslc_common::is_available"), - ContainmentBackend::Lxc => Some("lxc_common::availability::is_lxc_available"), - ContainmentBackend::Vm => None, - ContainmentBackend::MicroVm => None, - ContainmentBackend::Hyperlight => Some("hyperlight_common::is_whp_available"), - ContainmentBackend::WindowsSandbox => { - Some("windows_sandbox_lifecycle::availability::is_windows_sandbox_available") - } - ContainmentBackend::IsolationSession => { - Some("isolation_session_common::availability::is_isolation_session_available") - } - ContainmentBackend::Seatbelt => Some("/usr/bin/sandbox-exec presence"), - ContainmentBackend::Bubblewrap => Some("bwrap_common::bwrap_version::probe_bwrap"), - } + /// Runs the real platform reporting with every host check forced to pass, + /// so the assertions below exercise production wiring rather than whatever + /// this particular host happens to have installed. + #[cfg(target_os = "windows")] + fn backends_with_host_checks_passing() -> Vec { + windows_backends( + select_tier(true, false), + ProcessContainerCapabilities::default(), + WindowsHostAvailability { + windows_sandbox: true, + wslc: true, + isolation_session: true, + hyperlight: true, + }, + ) + } + + #[cfg(target_os = "linux")] + fn backends_with_host_checks_passing() -> Vec { + linux_backends_with(true, || Ok(()), true) } - /// Ties the registry's declaration to real detection: a backend registered - /// as `Detected` must name a detector, and one registered as `NotDetected` - /// must not have one. + #[cfg(target_os = "macos")] + fn backends_with_host_checks_passing() -> Vec { + macos_backends_with(true) + } + + /// The forward half of the probe-coverage gate: every backend the registry + /// claims to detect on this target must actually be emitted by the real + /// reporting function once its host check passes. Registering a backend as + /// `Detected` without adding a branch to `available_backends()` fails here. #[test] - fn probe_coverage_matches_the_host_detector_mapping() { - for entry in backend_registry::all() { - let detector = host_detector(&entry.backend); - match &entry.probe_coverage { - ProbeCoverage::Detected => assert!( - detector.is_some(), - "{:?} is registered as Detected but available_backends() has no detector \ - for it", - entry.backend - ), - ProbeCoverage::NotDetected(reason) => assert!( - detector.is_none(), - "{:?} is registered as NotDetected ({reason}) but names the detector {:?}", - entry.backend, - detector - ), - } + fn every_detected_backend_is_reported_when_its_host_checks_pass() { + let reported = backends_with_host_checks_passing(); + for name in backend_registry::detected_on_current_target() { + assert!( + reported.iter().any(|entry| entry.backend == name), + "{name} is registered as detected on {}, but the probe never reports it even \ + with every host check passing", + std::env::consts::OS + ); } } @@ -457,19 +532,16 @@ mod tests { } } - /// A backend registered as having no host detector must not surface here; - /// the registration and the probe are two halves of one decision. + /// The reverse half of the gate: a backend registered as having no host + /// detector must stay absent even with every host check forced to pass. #[test] fn backends_without_a_host_detector_are_never_reported() { - let reported: Vec = available_backends() - .into_iter() - .map(|entry| entry.backend) - .collect(); + let reported = backends_with_host_checks_passing(); for entry in backend_registry::all() { if let ProbeCoverage::NotDetected(reason) = &entry.probe_coverage { let name = entry.backend.wire_name(); assert!( - !reported.contains(&name.to_string()), + !reported.iter().any(|entry| entry.backend == name), "{name} is reported by available_backends() but registered as undetected: \ {reason}" ); @@ -567,6 +639,7 @@ mod tests { capture_denials: capture_denials_available, ..Default::default() }, + WindowsHostAvailability::default(), ); let process_container = backends .iter() @@ -594,6 +667,7 @@ mod tests { ingress_host_loopback_allow: true, ..Default::default() }, + WindowsHostAvailability::default(), ); let process_container = backends .iter() @@ -628,6 +702,7 @@ mod tests { identityless_loopback_proxy: true, ..Default::default() }, + WindowsHostAvailability::default(), ); let process_container = backends .iter() @@ -651,6 +726,7 @@ mod tests { ingress_host_loopback_allow: ingress_supported, ..Default::default() }, + WindowsHostAvailability::default(), ); let process_container = &backends[0]; assert_eq!( @@ -680,6 +756,7 @@ mod tests { warnings: vec![warning.clone()], ..Default::default() }, + WindowsHostAvailability::default(), ); assert_eq!(backends[0].warnings, vec![warning]); assert!(!backends[0] From befe666348fc7fbf2099f268ee9cfb7facd8fc7b Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:06:39 -0700 Subject: [PATCH 4/6] Report Hyperlight from the Linux probe Hyperlight runs on Linux under KVM, but available_backends() only reported it on Windows, so the probe omitted a backend the host can run. Registering it as detected on Windows alone also kept the Linux coverage test from catching that. linux_backends_with() now takes a hyperlight availability input, and hyperlight_host_available() resolves WHP on Windows and KVM on Linux behind the same feature and x86_64 gates that resolve_hyperlight() uses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 25b86546-f178-4ab4-bda2-7443aa01f541 --- .../plans/backend-support-probe-api.md | 7 ++++--- .../src/core/mxc_engine/backend_registry.rs | 2 +- src/mxc-sdk/src/core/mxc_engine/probe.rs | 21 +++++++++++++++---- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/development/plans/backend-support-probe-api.md b/docs/development/plans/backend-support-probe-api.md index f1731e7db9..13c34ecd2b 100644 --- a/docs/development/plans/backend-support-probe-api.md +++ b/docs/development/plans/backend-support-probe-api.md @@ -137,8 +137,8 @@ The detectors for the other previously-missing backends: | `isolation_session` | `IsoSessionOps` activates and reports a `LocalAgentUser` feature level above zero | Queries API-class registration rather than a build number (#761); a `CLASS_E_CLASSNOTAVAILABLE` / `REGDB_E_CLASSNOTREG` activation failure means unavailable. | | `lxc` | `lxc-ls --version` exits cleanly within a 3s bound | §4.2, step 1. | -`hyperlight` has `is_whp_available()`, which checks the hypervisor without -booting a VM. +`hyperlight` has a hypervisor check on each supported host — `is_whp_available()` +on Windows, `is_kvm_available()` on Linux — neither of which boots a VM. ### 4.2 The parity rule: detect once, project into TS @@ -196,7 +196,8 @@ Remaining detector and TypeScript-projection work, one issue each. The `windows_sandbox`, `isolation_session`, and `lxc` detectors have landed in Rust and are registered as `ProbeCoverage::Detected`. -1. `microvm` - hypervisor-presence probe. `hyperlight` has one (`is_whp_available()`); `microvm` +1. `microvm` - hypervisor-presence probe. `hyperlight` has one per host +(`is_whp_available()` / `is_kvm_available()`); `microvm` is registered as `ProbeCoverage::NotDetected` until its detector lands. 2. TypeScript projection - make `getPlatformSupport()` read the native backend availability via a side-effect-free transport (e.g. a new `wxc-exec --available-backends` mode handled **before** diff --git a/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs b/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs index 8801659e79..2415fcbdbd 100644 --- a/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs +++ b/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs @@ -66,7 +66,7 @@ static BACKENDS: [BackendRegistration; 10] = [ BackendRegistration { backend: ContainmentBackend::Hyperlight, experimental: true, - probe_coverage: ProbeCoverage::Detected(&["windows"]), + probe_coverage: ProbeCoverage::Detected(&["windows", "linux"]), }, BackendRegistration { backend: ContainmentBackend::WindowsSandbox, diff --git a/src/mxc-sdk/src/core/mxc_engine/probe.rs b/src/mxc-sdk/src/core/mxc_engine/probe.rs index 16d508da86..cd717e2a48 100644 --- a/src/mxc-sdk/src/core/mxc_engine/probe.rs +++ b/src/mxc-sdk/src/core/mxc_engine/probe.rs @@ -166,6 +166,7 @@ fn linux_backends() -> Vec { crate::bwrap_common::bwrap_version::probe_bwrap().is_ok(), crate::bwrap_common::proxy_network::probe_proxy_enforcement, crate::lxc_common::availability::is_lxc_available(), + hyperlight_host_available(), ) } @@ -178,6 +179,7 @@ fn linux_backends_with( bwrap_available: bool, proxy_enforcement: impl FnOnce() -> Result<(), String>, lxc_available: bool, + hyperlight_available: bool, ) -> Vec { let mut backends = Vec::new(); if bwrap_available { @@ -188,6 +190,11 @@ fn linux_backends_with( ContainmentBackend::Lxc.wire_name(), )); } + if hyperlight_available { + backends.push(AvailableBackend::tierless( + ContainmentBackend::Hyperlight.wire_name(), + )); + } backends } @@ -262,13 +269,19 @@ fn isolation_session_host_available() -> bool { } } -#[cfg(target_os = "windows")] +/// Hyperlight needs x86_64 and a hypervisor: WHP on Windows, KVM on Linux. +/// Both are checked here so a missing hypervisor never reaches the delay-loaded +/// WHP import or a VM boot. +#[cfg(any(target_os = "windows", target_os = "linux"))] fn hyperlight_host_available() -> bool { - // WHP is delay-loaded; check before setup boots a VM. - #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] + #[cfg(all(feature = "hyperlight", target_arch = "x86_64", target_os = "windows"))] { crate::hyperlight_common::is_whp_available() } + #[cfg(all(feature = "hyperlight", target_arch = "x86_64", target_os = "linux"))] + { + crate::hyperlight_common::is_kvm_available() + } #[cfg(not(all(feature = "hyperlight", target_arch = "x86_64")))] { false @@ -394,7 +407,7 @@ mod tests { #[cfg(target_os = "linux")] fn backends_with_host_checks_passing() -> Vec { - linux_backends_with(true, || Ok(()), true) + linux_backends_with(true, || Ok(()), true, true) } #[cfg(target_os = "macos")] From 1a8dabb11bc8aea77aede6f9390fce6ca1aa0c05 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:40:03 -0700 Subject: [PATCH 5/6] address feedback --- .github/workflows/Build.Linux.Job.yml | 11 ++ .../plans/backend-support-probe-api.md | 35 +--- src/mxc-sdk/src/core/mxc_engine/probe.rs | 160 ++++++++++++++++-- 3 files changed, 165 insertions(+), 41 deletions(-) diff --git a/.github/workflows/Build.Linux.Job.yml b/.github/workflows/Build.Linux.Job.yml index ec36841310..30a5f74fa8 100644 --- a/.github/workflows/Build.Linux.Job.yml +++ b/.github/workflows/Build.Linux.Job.yml @@ -160,6 +160,17 @@ jobs: run_pinned "LXC dispatch arm" -p mxc-sdk --lib streaming_lxc_reaches_the_backend_on_linux run_pinned "LXC FFI routing" -p mxc_ffi --test ffi extern_spawn_json_reaches_the_lxc_backend run_pinned "SDK platform helpers" -p mxc-sdk --test sdk_helpers + run_pinned "Probe coverage" -p mxc-sdk --lib mxc_engine::probe + run_pinned "Backend registry" -p mxc-sdk --lib mxc_engine::backend_registry + + # Hyperlight is a Linux backend only on x64, and the probe reports it + # from a feature-gated branch the default build never compiles. + if [ -n "${{ matrix.features }}" ]; then + run_pinned "Probe coverage (${{ matrix.features }})" \ + --features "${{ matrix.features }}" -p mxc-sdk --lib mxc_engine::probe + run_pinned "Backend registry (${{ matrix.features }})" \ + --features "${{ matrix.features }}" -p mxc-sdk --lib mxc_engine::backend_registry + fi # Runs the Bubblewrap executor characterization tests. lxc-exec was built # into src/target//release above, where find_binary() locates it. diff --git a/docs/development/plans/backend-support-probe-api.md b/docs/development/plans/backend-support-probe-api.md index 13c34ecd2b..06349a18e6 100644 --- a/docs/development/plans/backend-support-probe-api.md +++ b/docs/development/plans/backend-support-probe-api.md @@ -98,9 +98,11 @@ ones. | Backend | How presence is detected today | Where | Risk with this method | | --- | --- | --- | --- | | `base-container` | `BaseContainerRunner::is_base_container_api_present()` calls `IsApiSetImplemented` for the process-security-environment API set. It does not load `processmodel.dll`, create a security environment, or start a process. | Rust | This proves that Windows implements the API-set contract; request-specific support bits are queried separately when the policy requires them. | -| `windows_sandbox` | `isWindowsSandboxAvailable()` runs `dism /online /get-featureinfo `
`/featurename:Containers-DisposableClientVM ` and looks for `State : Enabled`
if DISM throws (usually non-elevated) it falls back to
`fs.existsSync(%SystemRoot%\\System32\\WindowsSandbox.exe)`; result cached. | TypeScript SDK | `dism /online` needs elevation, so a non-elevated caller can't tell *disabled* from
*no permission* and drops to the exe-existence checkwhich proves the feature is installed,
not that a sandbox VM can boot. (Also in Rust: `windows_sandbox_lifecycle::availability`; the TypeScript copy is removed by §4.2 step 2.) | -| `lxc` | `isLxcAvailable()` runs `lxc-ls --version`; a clean exit means available. | TypeScript SDK | Only proves the `lxc-ls` CLI is on `PATH`, not that liblxc is loadable or that the caller has
the privileges to actually start a container, so it can report available on a host where a real run fails.
(Also in Rust: `lxc_common::availability`; the TypeScript copy is removed by §4.2 step 2.) | +| `windows_sandbox` | `isWindowsSandboxAvailable()` runs `dism /online /get-featureinfo `
`/featurename:Containers-DisposableClientVM ` and looks for `State : Enabled`
if DISM throws (usually non-elevated) it falls back to
`fs.existsSync(%SystemRoot%\\System32\\WindowsSandbox.exe)`; result cached. | TypeScript SDK | `dism /online` needs elevation, so a non-elevated caller can't tell *disabled* from
*no permission* and drops to the exe-existence checkwhich proves the feature is installed,
not that a sandbox VM can boot. (Dupe'd in Rust: `windows_sandbox_lifecycle::availability`) | +| `lxc` | `isLxcAvailable()` runs `lxc-ls --version`; a clean exit means available. | TypeScript SDK | Only proves the `lxc-ls` CLI is on `PATH`, not that liblxc is loadable or that the caller has
the privileges to actually start a container, so it can report available on a host where a real run fails.
(Dupe'd in Rust: `lxc_common::availability`) | | `wslc` | `wslc_common::is_available()` confirms WSL2 and the WSLC runtime are present. | Rust | Resolving `wslcsdk.dll` alone is not sufficient and is not what this checks;
`WslcSdk::load()` still runs on the *execute* path. Feature-gated. | +| `isolation_session` | `IsoSessionOps` activates and reports a `LocalAgentUser` feature level above zero. Queries API-class registration rather than a build number (#761); a `CLASS_E_CLASSNOTAVAILABLE` / `REGDB_E_CLASSNOTREG` activation failure means unavailable. | Rust | | +| `hyperlight` | Has a hypervisor check on each supported host — `is_whp_available()` on Windows, `is_kvm_available()` on Linux — neither of which boots a VM. | Rust | | ### 3.2 Isolation tiers (process-container only) @@ -115,30 +117,12 @@ reachable, already exist in `process_container_common`: | `appcontainer-dacl` | always (universal Windows floor) | — | - ## 4. The probe gap today -### 4.1 Backends still missing a Rust detector - -Only `microvm` lacks a *probe-suitable* (cheap, no persistent host mutation, no -process/VM launch) host detector in Rust, so a truthful availability signal for -it **cannot be built just yet**. `vm` is an abstract intent that resolves to a -concrete backend per host and is never probed in its own right. - -| Backend | What a real probe needs | What exists today | Risk if faked | -| --- | --- | --- | --- | -| `microvm` | feature compiled, NanVix runtime files staged, and WHP usable on Windows or `/dev/kvm` readable/writable on Linux | nothing | checking only a hypervisor can report availability when required runtime files are missing | - -The detectors for the other previously-missing backends: - -| Backend | Detector | Note | -| --- | --- | --- | -| `windows_sandbox` | `WindowsSandbox.exe` present in the system directory | Windows installs it only with *Containers-DisposableClientVM* enabled. The DISM query needs elevation and this probe runs unelevated, so the `.exe` check is the probe, not a fallback. | -| `isolation_session` | `IsoSessionOps` activates and reports a `LocalAgentUser` feature level above zero | Queries API-class registration rather than a build number (#761); a `CLASS_E_CLASSNOTAVAILABLE` / `REGDB_E_CLASSNOTREG` activation failure means unavailable. | -| `lxc` | `lxc-ls --version` exits cleanly within a 3s bound | §4.2, step 1. | - -`hyperlight` has a hypervisor check on each supported host — `is_whp_available()` -on Windows, `is_kvm_available()` on Linux — neither of which boots a VM. +### 4.1. Backends with no probe +- `microvm` needs a probe that checks for: (1) feature compiled, (2) NanVix runtime files staged, and (3) WHP usable on Windows or `/dev/kvm` readable/writable on Linux +- `vm` is an abstract intent that resolves to a + concrete backend per host and is never probed in its own right. ### 4.2 The parity rule: detect once, project into TS @@ -155,9 +139,6 @@ result rather than compute its own. See §7.9 for why the canonical probe stays in Rust rather than moving into the TypeScript layer. - - - ## 5. Testing - Every returned `backend` is a valid `mxc_common::models::ContainmentBackend::wire_name()` value. diff --git a/src/mxc-sdk/src/core/mxc_engine/probe.rs b/src/mxc-sdk/src/core/mxc_engine/probe.rs index cd717e2a48..afd2d3f75f 100644 --- a/src/mxc-sdk/src/core/mxc_engine/probe.rs +++ b/src/mxc-sdk/src/core/mxc_engine/probe.rs @@ -163,34 +163,45 @@ fn macos_backends_with(sandbox_exec_present: bool) -> Vec { #[cfg(target_os = "linux")] fn linux_backends() -> Vec { linux_backends_with( - crate::bwrap_common::bwrap_version::probe_bwrap().is_ok(), + LinuxHostAvailability { + bubblewrap: crate::bwrap_common::bwrap_version::probe_bwrap().is_ok(), + lxc: crate::lxc_common::availability::is_lxc_available(), + hyperlight: hyperlight_host_available(), + }, crate::bwrap_common::proxy_network::probe_proxy_enforcement, - crate::lxc_common::availability::is_lxc_available(), - hyperlight_host_available(), ) } +/// Host checks for the Linux backends, resolved by [`available_backends`] and +/// injected so the reporting is testable without a host that has (or lacks) +/// them. Named fields so a detector cannot be wired to the wrong backend. +#[cfg(target_os = "linux")] +#[derive(Debug, Clone, Copy, Default)] +struct LinuxHostAvailability { + bubblewrap: bool, + lxc: bool, + hyperlight: bool, +} + /// Split from [`linux_backends`] so the reporting is testable without a host /// that has (or lacks) the backends. `proxy_enforcement` stays a closure /// because the dependency walk costs several subprocess spawns and is only /// meaningful once `bwrap` itself is usable. #[cfg(target_os = "linux")] fn linux_backends_with( - bwrap_available: bool, + hosts: LinuxHostAvailability, proxy_enforcement: impl FnOnce() -> Result<(), String>, - lxc_available: bool, - hyperlight_available: bool, ) -> Vec { let mut backends = Vec::new(); - if bwrap_available { + if hosts.bubblewrap { backends.push(bubblewrap_backend(proxy_enforcement())); } - if lxc_available { + if hosts.lxc { backends.push(AvailableBackend::tierless( ContainmentBackend::Lxc.wire_name(), )); } - if hyperlight_available { + if hosts.hyperlight { backends.push(AvailableBackend::tierless( ContainmentBackend::Hyperlight.wire_name(), )); @@ -407,7 +418,14 @@ mod tests { #[cfg(target_os = "linux")] fn backends_with_host_checks_passing() -> Vec { - linux_backends_with(true, || Ok(()), true, true) + linux_backends_with( + LinuxHostAvailability { + bubblewrap: true, + lxc: true, + hyperlight: true, + }, + || Ok(()), + ) } #[cfg(target_os = "macos")] @@ -415,6 +433,110 @@ mod tests { macos_backends_with(true) } + fn reported_names(backends: &[AvailableBackend]) -> Vec<&str> { + backends + .iter() + .map(|entry| entry.backend.as_str()) + .collect() + } + + /// Enabling all host checks at once cannot tell one detector's wiring from + /// another's, so each is enabled alone and must produce exactly its own + /// backend. A detector wired to the wrong branch fails here. + #[cfg(target_os = "linux")] + #[test] + fn each_linux_host_check_reports_only_its_own_backend() { + let cases = [ + ( + "bubblewrap", + LinuxHostAvailability { + bubblewrap: true, + ..Default::default() + }, + ), + ( + "lxc", + LinuxHostAvailability { + lxc: true, + ..Default::default() + }, + ), + ( + "hyperlight", + LinuxHostAvailability { + hyperlight: true, + ..Default::default() + }, + ), + ]; + for (expected, hosts) in cases { + let reported = linux_backends_with(hosts, || Ok(())); + assert_eq!(reported_names(&reported), vec![expected]); + } + assert!(linux_backends_with(LinuxHostAvailability::default(), || Ok(())).is_empty()); + } + + /// `processcontainer` has no host check and is always reported, so each + /// optional backend's check must add exactly itself. + #[cfg(target_os = "windows")] + #[test] + fn each_windows_host_check_reports_only_its_own_backend() { + let cases = [ + ( + "windows_sandbox", + WindowsHostAvailability { + windows_sandbox: true, + ..Default::default() + }, + ), + ( + "wslc", + WindowsHostAvailability { + wslc: true, + ..Default::default() + }, + ), + ( + "isolation_session", + WindowsHostAvailability { + isolation_session: true, + ..Default::default() + }, + ), + ( + "hyperlight", + WindowsHostAvailability { + hyperlight: true, + ..Default::default() + }, + ), + ]; + for (expected, hosts) in cases { + let reported = windows_backends( + select_tier(true, false), + ProcessContainerCapabilities::default(), + hosts, + ); + assert_eq!( + reported_names(&reported), + vec!["processcontainer", expected] + ); + } + let reported = windows_backends( + select_tier(true, false), + ProcessContainerCapabilities::default(), + WindowsHostAvailability::default(), + ); + assert_eq!(reported_names(&reported), vec!["processcontainer"]); + } + + #[cfg(target_os = "macos")] + #[test] + fn the_macos_host_check_reports_only_seatbelt() { + assert_eq!(reported_names(&macos_backends_with(true)), vec!["seatbelt"]); + assert!(macos_backends_with(false).is_empty()); + } + /// The forward half of the probe-coverage gate: every backend the registry /// claims to detect on this target must actually be emitted by the real /// reporting function once its host check passes. Registering a backend as @@ -536,7 +658,12 @@ mod tests { #[test] fn every_reported_backend_uses_a_canonical_name() { let known = backend_registry::all_containment_names(); - for entry in available_backends() { + let reported = backends_with_host_checks_passing(); + assert!( + !reported.is_empty(), + "every target reports at least one backend once its host checks pass" + ); + for entry in reported { assert!( known.contains(&entry.backend), "reported backend {:?} is not a canonical backend name", @@ -567,7 +694,12 @@ mod tests { #[cfg(not(target_os = "windows"))] #[test] fn no_backend_reports_a_tier_off_windows() { - for entry in available_backends() { + let reported = backends_with_host_checks_passing(); + assert!( + !reported.is_empty(), + "every target reports at least one backend once its host checks pass" + ); + for entry in reported { assert_eq!( entry.tier, None, "{} reported a tier, but no non-Windows backend has a tier ladder", @@ -580,7 +712,7 @@ mod tests { #[test] fn every_reported_tier_is_a_canonical_tier_string() { let canonical = canonical_tiers(); - for entry in available_backends() { + for entry in backends_with_host_checks_passing() { if let Some(tier) = entry.tier { assert!( canonical.contains(&tier.as_str()), @@ -793,7 +925,7 @@ mod tests { #[cfg(not(target_os = "windows"))] #[test] fn processcontainer_never_appears_off_windows() { - assert!(available_backends() + assert!(backends_with_host_checks_passing() .iter() .all(|b| b.backend != "processcontainer")); } From bdcaee66118b500940c3f974e946b3c31ce55821 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:05:52 -0700 Subject: [PATCH 6/6] compare available_backends to detector results --- src/mxc-sdk/src/core/mxc_engine/probe.rs | 64 ++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/mxc-sdk/src/core/mxc_engine/probe.rs b/src/mxc-sdk/src/core/mxc_engine/probe.rs index afd2d3f75f..446f5471b8 100644 --- a/src/mxc-sdk/src/core/mxc_engine/probe.rs +++ b/src/mxc-sdk/src/core/mxc_engine/probe.rs @@ -537,6 +537,70 @@ mod tests { assert!(macos_backends_with(false).is_empty()); } + /// Holds each backend in the real [`available_backends`] to its own + /// detector, catching a disconnected or misassigned detector. + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + fn assert_probe_matches_detectors(detectors: &[(&str, bool)]) { + let backends = available_backends(); + let reported = reported_names(&backends); + for (name, detected) in detectors { + assert_eq!( + reported.contains(name), + *detected, + "{name}: the probe reported {}, but its detector returned {detected}", + reported.contains(name) + ); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn the_linux_probe_reports_what_its_detectors_found() { + assert_probe_matches_detectors(&[ + ( + ContainmentBackend::Bubblewrap.wire_name(), + crate::bwrap_common::bwrap_version::probe_bwrap().is_ok(), + ), + ( + ContainmentBackend::Lxc.wire_name(), + crate::lxc_common::availability::is_lxc_available(), + ), + ( + ContainmentBackend::Hyperlight.wire_name(), + hyperlight_host_available(), + ), + ]); + } + + #[cfg(target_os = "windows")] + #[test] + fn the_windows_probe_reports_what_its_detectors_found() { + assert_probe_matches_detectors(&[ + ( + ContainmentBackend::WindowsSandbox.wire_name(), + crate::windows_sandbox_lifecycle::availability::is_windows_sandbox_available(), + ), + (ContainmentBackend::Wslc.wire_name(), wslc_host_available()), + ( + ContainmentBackend::IsolationSession.wire_name(), + isolation_session_host_available(), + ), + ( + ContainmentBackend::Hyperlight.wire_name(), + hyperlight_host_available(), + ), + ]); + } + + #[cfg(target_os = "macos")] + #[test] + fn the_macos_probe_reports_what_its_detector_found() { + assert_probe_matches_detectors(&[( + ContainmentBackend::Seatbelt.wire_name(), + std::path::Path::new("/usr/bin/sandbox-exec").exists(), + )]); + } + /// The forward half of the probe-coverage gate: every backend the registry /// claims to detect on this target must actually be emitted by the real /// reporting function once its host check passes. Registering a backend as