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/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 d0f3250f2e..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. (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. (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,40 +117,28 @@ 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 - -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**. - -| 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 | +### 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 -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. | 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. @@ -160,7 +150,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,20 +161,26 @@ 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 `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`, 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` / `hyperlight` - hypervisor-presence probe. -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 +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 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** `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/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/backend_registry.rs b/src/mxc-sdk/src/core/mxc_engine/backend_registry.rs index ff57e9392a..2415fcbdbd 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,133 @@ 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()` 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), +} + #[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(&["windows"]), }, BackendRegistration { backend: ContainmentBackend::Wslc, experimental: false, + probe_coverage: ProbeCoverage::Detected(&["windows"]), }, BackendRegistration { backend: ContainmentBackend::Lxc, experimental: false, + probe_coverage: ProbeCoverage::Detected(&["linux"]), }, 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(&["windows", "linux"]), }, BackendRegistration { backend: ContainmentBackend::WindowsSandbox, experimental: true, + probe_coverage: ProbeCoverage::Detected(&["windows"]), }, BackendRegistration { backend: ContainmentBackend::IsolationSession, experimental: false, + probe_coverage: ProbeCoverage::Detected(&["windows"]), }, BackendRegistration { backend: ContainmentBackend::Seatbelt, experimental: false, + probe_coverage: ProbeCoverage::Detected(&["macos"]), }, BackendRegistration { backend: ContainmentBackend::Bubblewrap, experimental: false, + probe_coverage: ProbeCoverage::Detected(&["linux"]), }, ]; +/// 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() +} + +/// 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 { @@ -102,4 +180,61 @@ 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 + ); + } + } + } + + /// 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/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..446f5471b8 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,17 +162,50 @@ fn macos_backends() -> Vec { #[cfg(target_os = "linux")] fn linux_backends() -> Vec { + linux_backends_with( + 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, + ) +} + +/// 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( + hosts: LinuxHostAvailability, + proxy_enforcement: impl FnOnce() -> Result<(), String>, +) -> 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 hosts.bubblewrap { + backends.push(bubblewrap_backend(proxy_enforcement())); } - if crate::lxc_common::availability::is_lxc_available() { + if hosts.lxc { backends.push(AvailableBackend::tierless( ContainmentBackend::Lxc.wire_name(), )); } + if hosts.hyperlight { + backends.push(AvailableBackend::tierless( + ContainmentBackend::Hyperlight.wire_name(), + )); + } backends } @@ -189,10 +239,71 @@ 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 + } +} + +/// 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 { + #[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 + } +} + #[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 +333,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(), )); @@ -286,36 +389,234 @@ 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 + use crate::mxc_engine::backend_registry::{self, ProbeCoverage}; + + /// 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() + } + + /// 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( + LinuxHostAvailability { + bubblewrap: true, + lxc: true, + hyperlight: true, + }, + || Ok(()), + ) + } + + #[cfg(target_os = "macos")] + fn backends_with_host_checks_passing() -> Vec { + macos_backends_with(true) + } + + fn reported_names(backends: &[AvailableBackend]) -> Vec<&str> { + backends .iter() - .map(|(_, name)| (*name).to_string()) + .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 backend_wire_names_match_the_canonical_table() { - for (backend, expected) in CANONICAL_BACKEND_NAMES { - assert_eq!(backend.wire_name(), *expected, "{backend:?}"); + 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()); + } + + /// 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) + ); } } - const CANONICAL_TIERS: [&str; 3] = ["base-container", "appcontainer-bfs", "appcontainer-dacl"]; + #[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 + /// `Detected` without adding a branch to `available_backends()` fails here. + #[test] + 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 + ); + } + } #[test] fn tier_is_omitted_from_json_when_none() { @@ -420,8 +721,13 @@ mod tests { #[test] fn every_reported_backend_uses_a_canonical_name() { - let known = all_containment_names(); - for entry in available_backends() { + let known = backend_registry::all_containment_names(); + 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", @@ -430,26 +736,76 @@ mod tests { } } + /// 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 = 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.iter().any(|entry| entry.backend == name), + "{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() { + 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", + entry.backend + ); + } + } + + #[cfg(target_os = "windows")] #[test] fn every_reported_tier_is_a_canonical_tier_string() { - for entry in available_backends() { + let canonical = canonical_tiers(); + for entry in backends_with_host_checks_passing() { 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 +818,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:?}" ); } @@ -492,6 +848,7 @@ mod tests { capture_denials: capture_denials_available, ..Default::default() }, + WindowsHostAvailability::default(), ); let process_container = backends .iter() @@ -519,6 +876,7 @@ mod tests { ingress_host_loopback_allow: true, ..Default::default() }, + WindowsHostAvailability::default(), ); let process_container = backends .iter() @@ -553,6 +911,7 @@ mod tests { identityless_loopback_proxy: true, ..Default::default() }, + WindowsHostAvailability::default(), ); let process_container = backends .iter() @@ -576,6 +935,7 @@ mod tests { ingress_host_loopback_allow: ingress_supported, ..Default::default() }, + WindowsHostAvailability::default(), ); let process_container = &backends[0]; assert_eq!( @@ -605,6 +965,7 @@ mod tests { warnings: vec![warning.clone()], ..Default::default() }, + WindowsHostAvailability::default(), ); assert_eq!(backends[0].warnings, vec![warning]); assert!(!backends[0] @@ -628,7 +989,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")); }