diff --git a/src/backends/appcontainer/common/src/dispatcher.rs b/src/backends/appcontainer/common/src/dispatcher.rs index d46ac1bd7..8f5e2faaf 100644 --- a/src/backends/appcontainer/common/src/dispatcher.rs +++ b/src/backends/appcontainer/common/src/dispatcher.rs @@ -658,7 +658,7 @@ mod tests { } #[test] fn dispatch_t1_no_denied_paths_no_dacl() { - let _g = ForceTierGuard::set("base-container"); + let _g = ForceTierGuard::set_tier(IsolationTier::BaseContainer); let req = test_request(empty_policy()); let d = dispatch_with_fallback(&req).expect("T1 dispatch should succeed"); assert!(matches!(d.tier, IsolationTier::BaseContainer)); @@ -673,7 +673,7 @@ mod tests { // `deniedPaths`) to BaseContainer's native API; the dispatcher // attaches no `DaclManager` on the T1 path regardless of the // `deniedPaths` contents. - let _g = ForceTierGuard::set("base-container"); + let _g = ForceTierGuard::set_tier(IsolationTier::BaseContainer); let (policy, _tmp) = policy_with_denied_temp(); let req = test_request(policy); let d = dispatch_with_fallback(&req).expect("T1+deny dispatch should succeed"); @@ -685,7 +685,7 @@ mod tests { } #[test] fn dispatch_t2_with_denied_paths_has_dacl() { - let _g = ForceTierGuard::set("appcontainer-bfs"); + let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerBfs); let (policy, _tmp) = policy_with_denied_temp(); let req = test_request(policy); let d = dispatch_with_fallback(&req).expect("T2+deny dispatch should succeed"); @@ -694,7 +694,7 @@ mod tests { } #[test] fn dispatch_t3_always_has_dacl() { - let _g = ForceTierGuard::set("appcontainer-dacl"); + let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerDacl); let (policy, _tmp) = policy_with_rw_temp(); let req = test_request(policy); let d = dispatch_with_fallback(&req).expect("T3 dispatch should succeed"); @@ -770,7 +770,7 @@ mod tests { #[test] fn dispatch_fallback_disabled_errors() { - let _g = ForceTierGuard::set("appcontainer-dacl"); + let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerDacl); let (mut policy, _tmp) = policy_with_rw_temp(); policy.fallback.allow_dacl_mutation = false; let req = test_request(policy); @@ -939,7 +939,7 @@ mod tests { #[test] fn select_backend_t1_builds_base_container_no_dacl() { - let _g = ForceTierGuard::set("base-container"); + let _g = ForceTierGuard::set_tier(IsolationTier::BaseContainer); let req = test_request(empty_policy()); let (backend, dacl, tier, _w) = select_backend_with_fallback(&req).expect("T1 selection should succeed"); @@ -956,7 +956,7 @@ mod tests { #[test] fn select_backend_t2_no_deny_builds_appcontainer_no_dacl() { - let _g = ForceTierGuard::set("appcontainer-bfs"); + let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerBfs); let req = test_request(empty_policy()); let (backend, dacl, tier, _w) = select_backend_with_fallback(&req).expect("T2 selection should succeed"); @@ -970,7 +970,7 @@ mod tests { #[test] fn select_backend_t2_with_deny_builds_appcontainer_with_dacl() { - let _g = ForceTierGuard::set("appcontainer-bfs"); + let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerBfs); let (policy, _tmp) = policy_with_denied_temp(); let req = test_request(policy); let (backend, dacl, tier, _w) = @@ -985,7 +985,7 @@ mod tests { #[test] fn select_backend_t3_builds_appcontainer_with_dacl() { - let _g = ForceTierGuard::set("appcontainer-dacl"); + let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerDacl); let (policy, _tmp) = policy_with_rw_temp(); let req = test_request(policy); let (backend, dacl, tier, _w) = diff --git a/src/backends/appcontainer/common/src/fallback_detector.rs b/src/backends/appcontainer/common/src/fallback_detector.rs index c3e4ab0cb..fb4936641 100644 --- a/src/backends/appcontainer/common/src/fallback_detector.rs +++ b/src/backends/appcontainer/common/src/fallback_detector.rs @@ -21,27 +21,57 @@ use std::sync::OnceLock; use wxc_common::models::ContainerPolicy; -/// Selected isolation tier. The variant order corresponds to descending -/// security strength. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum IsolationTier { +/// Declares [`IsolationTier`] together with its variant↔string mapping in one +/// place, so [`ALL`](IsolationTier::ALL), [`as_str`](IsolationTier::as_str), and +/// the [`FromStr`] impl are all generated from the same tier list and cannot +/// drift. Adding a tier is a single line in the invocation below; the compiler +/// then forces `as_str`, `from_str`, and `ALL` to cover it. +macro_rules! isolation_tiers { + ($($(#[$variant_doc:meta])* $variant:ident => $name:literal),+ $(,)?) => { + /// Selected isolation tier. The variant order corresponds to descending + /// security strength. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum IsolationTier { + $($(#[$variant_doc])* $variant,)+ + } + + impl IsolationTier { + /// Every tier, ordered strongest-first (matching the variant order). + pub const ALL: [IsolationTier; [$(isolation_tiers!(@count $variant)),+].len()] = + [$(IsolationTier::$variant),+]; + + /// Stable kebab-case identifier for serialization. + pub fn as_str(self) -> &'static str { + match self { + $(IsolationTier::$variant => $name,)+ + } + } + } + + impl std::str::FromStr for IsolationTier { + type Err = (); + + /// Inverse of [`as_str`](Self::as_str). Generated from the same tier + /// list via an exhaustive match, so the two directions cannot drift. + fn from_str(s: &str) -> Result { + match s { + $($name => Ok(IsolationTier::$variant),)+ + _ => Err(()), + } + } + } + }; + // Counts each variant as one array element so `ALL`'s length tracks the list. + (@count $variant:ident) => { () }; +} + +isolation_tiers! { /// Tier 1 — a supported BaseContainer contract from `processmodel.dll`. - BaseContainer, + BaseContainer => "base-container", /// Tier 2 — AppContainer + `bfscfg.exe` BFS filesystem policy. - AppContainerBfs, + AppContainerBfs => "appcontainer-bfs", /// Tier 3 — AppContainer + DACL-based filesystem policy on host paths. - AppContainerDacl, -} - -impl IsolationTier { - /// Stable kebab-case identifier for serialization. - pub fn as_str(self) -> &'static str { - match self { - IsolationTier::BaseContainer => "base-container", - IsolationTier::AppContainerBfs => "appcontainer-bfs", - IsolationTier::AppContainerDacl => "appcontainer-dacl", - } - } + AppContainerDacl => "appcontainer-dacl", } /// Outcome of [`detect`]: the chosen tier plus any operator-visible warnings @@ -173,7 +203,7 @@ pub(crate) fn detect_with_base_container_capabilities( // the tests silently no-op'd). #[cfg(test)] if let Ok(forced) = std::env::var("MXC_FORCE_TIER") { - if let Some(tier) = parse_force_tier(&forced) { + if let Ok(tier) = forced.parse::() { return forced_decision(tier, policy, denied); } } @@ -435,16 +465,6 @@ fn check_write_dac_path(path: &Path) -> Result<(), FallbackError> { } } -#[cfg(test)] -fn parse_force_tier(s: &str) -> Option { - match s { - "base-container" => Some(IsolationTier::BaseContainer), - "appcontainer-bfs" => Some(IsolationTier::AppContainerBfs), - "appcontainer-dacl" => Some(IsolationTier::AppContainerDacl), - _ => None, - } -} - #[cfg(test)] fn forced_decision( tier: IsolationTier, @@ -705,7 +725,7 @@ mod tests { } #[test] fn empty_policy_t1_when_bc_present_and_preferred() { - let _g = ForceTierGuard::set("base-container"); + let _g = ForceTierGuard::set_tier(IsolationTier::BaseContainer); let policy = empty_policy(); let d = detect(&policy, true).expect("forced base-container should succeed"); assert!(matches!(d.tier, IsolationTier::BaseContainer)); @@ -714,7 +734,7 @@ mod tests { } #[test] fn empty_policy_no_filesystem_t2_path() { - let _g = ForceTierGuard::set("appcontainer-bfs"); + let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerBfs); let policy = empty_policy(); let d = detect(&policy, true).expect("forced bfs should succeed"); assert!(matches!(d.tier, IsolationTier::AppContainerBfs)); @@ -722,7 +742,7 @@ mod tests { } #[test] fn denied_paths_disabled_blocks_t1() { - let _g = ForceTierGuard::set("base-container"); + let _g = ForceTierGuard::set_tier(IsolationTier::BaseContainer); let mut policy = policy_with_denied(); policy.fallback.allow_dacl_mutation = false; assert!(matches!( @@ -732,7 +752,7 @@ mod tests { } #[test] fn denied_paths_disabled_blocks_t2() { - let _g = ForceTierGuard::set("appcontainer-bfs"); + let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerBfs); let mut policy = policy_with_denied(); policy.fallback.allow_dacl_mutation = false; assert!(matches!( @@ -742,7 +762,7 @@ mod tests { } #[test] fn denied_paths_disabled_blocks_t3() { - let _g = ForceTierGuard::set("appcontainer-dacl"); + let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerDacl); let mut policy = policy_with_denied(); policy.fallback.allow_dacl_mutation = false; assert!(matches!( @@ -818,20 +838,30 @@ mod tests { d.warnings ); } + #[test] + fn tier_name_round_trips_through_from_str() { + // `FromStr` is derived from `as_str` via `ALL`, so every tier must + // round-trip and the set stays in sync automatically. + for tier in IsolationTier::ALL { + assert_eq!(tier.as_str().parse::(), Ok(tier)); + } + } + #[test] fn force_tier_env_var_parses_all_three_values() { - assert!(matches!( - parse_force_tier("base-container"), - Some(IsolationTier::BaseContainer) - )); - assert!(matches!( - parse_force_tier("appcontainer-bfs"), - Some(IsolationTier::AppContainerBfs) - )); - assert!(matches!( - parse_force_tier("appcontainer-dacl"), - Some(IsolationTier::AppContainerDacl) - )); + assert_eq!( + "base-container".parse::(), + Ok(IsolationTier::BaseContainer) + ); + assert_eq!( + "appcontainer-bfs".parse::(), + Ok(IsolationTier::AppContainerBfs) + ); + assert_eq!( + "appcontainer-dacl".parse::(), + Ok(IsolationTier::AppContainerDacl) + ); + assert!("not-a-real-tier".parse::().is_err()); } #[test] fn force_tier_env_var_invalid_value_falls_through_to_real_probes() { @@ -989,7 +1019,7 @@ mod tests { } #[test] fn compute_decision_with_force_tier_carries_warnings_empty() { - let _g = ForceTierGuard::set("appcontainer-dacl"); + let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerDacl); let mut policy = empty_policy(); policy.fallback.allow_dacl_mutation = true; let d = detect(&policy, true).expect("forced dacl with allow_dacl_mutation=true"); diff --git a/src/backends/appcontainer/common/src/probe.rs b/src/backends/appcontainer/common/src/probe.rs index af60926ce..6183bfea6 100644 --- a/src/backends/appcontainer/common/src/probe.rs +++ b/src/backends/appcontainer/common/src/probe.rs @@ -270,7 +270,7 @@ mod tests { #[test] fn run_probe_with_force_tier() { - let _g = ForceTierGuard::set("appcontainer-bfs"); + let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerBfs); let policy = ContainerPolicy::default(); let out = run_probe(&policy); assert_eq!(out.tier, Some("appcontainer-bfs")); @@ -280,7 +280,7 @@ mod tests { #[test] fn run_probe_handles_dacl_disabled_error() { - let _g = ForceTierGuard::set("appcontainer-dacl"); + let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerDacl); let mut policy = ContainerPolicy::default(); policy.fallback.allow_dacl_mutation = false; let out = run_probe(&policy); @@ -296,7 +296,7 @@ mod tests { #[test] fn omitted_fields_when_error() { - let _g = ForceTierGuard::set("appcontainer-dacl"); + let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerDacl); let mut policy = ContainerPolicy::default(); policy.fallback.allow_dacl_mutation = false; let out = run_probe(&policy); diff --git a/src/backends/appcontainer/common/src/test_env.rs b/src/backends/appcontainer/common/src/test_env.rs index cf12e8bcd..6f9fd53fc 100644 --- a/src/backends/appcontainer/common/src/test_env.rs +++ b/src/backends/appcontainer/common/src/test_env.rs @@ -53,6 +53,13 @@ impl ForceTierGuard { } ForceTierGuard { _lock: guard } } + + /// Typed variant: forces a real tier by its canonical serialized name, so + /// call-sites don't hardcode the string. Prefer this over [`set`](Self::set), + /// which remains for negative tests that need an intentionally invalid value. + pub(crate) fn set_tier(tier: crate::fallback_detector::IsolationTier) -> Self { + Self::set(tier.as_str()) + } } impl Drop for ForceTierGuard { diff --git a/src/backends/isolation_session/common/src/availability.rs b/src/backends/isolation_session/common/src/availability.rs new file mode 100644 index 000000000..d8226c45f --- /dev/null +++ b/src/backends/isolation_session/common/src/availability.rs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! IsolationSession host-availability probe. +//! +//! Availability is whether the in-proc `Windows.AI.IsolationSession` +//! `IsoSessionOps` WinRT class is registered on the OS (its activation factory +//! resolves), matching PR #761 rather than gating on a Windows build number. + +use std::sync::OnceLock; + +use isolation_session_bindings::bindings::IsoSessionOps; +use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_MULTITHREADED}; +use windows_core::HRESULT; + +static AVAILABLE: OnceLock = OnceLock::new(); + +/// Cached for the process; never requires elevation. +pub fn is_isolation_session_available() -> bool { + *AVAILABLE.get_or_init(|| available_from(probe_activation())) +} + +/// Split from [`probe_activation`] so the decision is testable without COM/WinRT. +/// Any activation failure (class not registered, or the OS feature gate off) +/// means not available here. +fn available_from(activation: Result<(), HRESULT>) -> bool { + activation.is_ok() +} + +fn probe_activation() -> Result<(), HRESULT> { + // Guard uninitializes on drop, so a panic in `IsoSessionOps::new()` still + // balances `CoInitializeEx`. The `_ops` handle drops before `_apartment` + // (reverse declaration order), preserving COM's create-before-uninit rule. + let _apartment = ComApartment::enter(); + match IsoSessionOps::new() { + Ok(_ops) => Ok(()), + Err(e) => Err(e.code()), + } +} + +/// Owns the COM apartment for the duration of a probe and uninitializes it on +/// drop, but only when this guard actually performed the initialization. +struct ComApartment { + owns_com: bool, +} + +impl ComApartment { + fn enter() -> Self { + // `is_ok()` covers S_OK and the S_FALSE "already initialized (same + // mode)" success — both of which we own and must balance. A failure + // (e.g. RPC_E_CHANGED_MODE) means another apartment is already active on + // this thread: activation still works, and we must NOT uninitialize it. + // SAFETY: standard COM init; balanced by `CoUninitialize` in `drop`. + let owns_com = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }.is_ok(); + Self { owns_com } + } +} + +impl Drop for ComApartment { + fn drop(&mut self) { + if self.owns_com { + // SAFETY: balances the `CoInitializeEx` in `enter`; only when owned. + unsafe { CoUninitialize() }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use windows::Win32::Foundation::{CLASS_E_CLASSNOTAVAILABLE, REGDB_E_CLASSNOTREG}; + + #[test] + fn only_successful_activation_means_available() { + assert!(available_from(Ok(()))); + assert!(!available_from(Err(CLASS_E_CLASSNOTAVAILABLE))); + assert!(!available_from(Err(REGDB_E_CLASSNOTREG))); + assert!(!available_from(Err(HRESULT(0x8000_4005u32 as i32)))); + } +} diff --git a/src/backends/isolation_session/common/src/lib.rs b/src/backends/isolation_session/common/src/lib.rs index e6f265530..4e64a54cf 100644 --- a/src/backends/isolation_session/common/src/lib.rs +++ b/src/backends/isolation_session/common/src/lib.rs @@ -13,6 +13,8 @@ //! - `state_aware`: `StatefulSandboxBackend` — per-phase methods called //! across multiple `wxc-exec` invocations by an external orchestrator. +#[cfg(target_os = "windows")] +pub mod availability; #[cfg(target_os = "windows")] mod console_mode; #[cfg(target_os = "windows")] diff --git a/src/backends/lxc/common/src/availability.rs b/src/backends/lxc/common/src/availability.rs new file mode 100644 index 000000000..c92fd0826 --- /dev/null +++ b/src/backends/lxc/common/src/availability.rs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! LXC host-availability probe (ports the SDK's `isLxcAvailable()`). + +use std::process::{Child, Command, Stdio}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +/// Upper bound on the `lxc-ls --version` probe. A version check returns almost +/// instantly; anything slower is treated as unavailable rather than allowed to +/// block discovery. +const PROBE_TIMEOUT: Duration = Duration::from_secs(3); + +/// How often to poll the child while waiting for it to exit. +const POLL_INTERVAL: Duration = Duration::from_millis(25); + +/// Outcome of running `lxc-ls --version`. Only `ExitedSuccess` means available; +/// the other variants are distinct for clarity but map to unavailable. The exit +/// code isn't retained — nothing reads it, and keeping it would be dead code. +#[derive(Debug, Clone, PartialEq, Eq)] +enum LxcLsOutcome { + ExitedSuccess, + ExitedFailure, + SpawnFailed, + TimedOut, +} + +/// Whether the LXC backend looks usable on this host. +/// +/// Runs `lxc-ls --version`; only a clean exit counts as available. A shallow +/// check — it proves `lxc-ls` is on `PATH`, not that a container can start. +/// Probed once and cached for the process lifetime. +pub fn is_lxc_available() -> bool { + static AVAILABLE: OnceLock = OnceLock::new(); + *AVAILABLE.get_or_init(|| available_from(probe_lxc_ls())) +} + +fn probe_lxc_ls() -> LxcLsOutcome { + let child = Command::new("lxc-ls") + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + match child { + Ok(mut child) => wait_bounded(&mut child, PROBE_TIMEOUT), + Err(_) => LxcLsOutcome::SpawnFailed, + } +} + +/// Wait up to `timeout` for `child`; if it overruns, kill and reap it so a hung +/// `lxc-ls` can't block the probe (or leak a zombie) indefinitely. +fn wait_bounded(child: &mut Child, timeout: Duration) -> LxcLsOutcome { + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return LxcLsOutcome::ExitedSuccess, + Ok(Some(_)) => return LxcLsOutcome::ExitedFailure, + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return LxcLsOutcome::TimedOut; + } + std::thread::sleep(POLL_INTERVAL); + } + Err(_) => return LxcLsOutcome::SpawnFailed, + } + } +} + +/// Split from the I/O half so the decision is testable without an `lxc-ls` +/// binary on the host. +fn available_from(outcome: LxcLsOutcome) -> bool { + matches!(outcome, LxcLsOutcome::ExitedSuccess) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_a_clean_exit_means_available() { + assert!(available_from(LxcLsOutcome::ExitedSuccess)); + assert!(!available_from(LxcLsOutcome::ExitedFailure)); + assert!(!available_from(LxcLsOutcome::SpawnFailed)); + assert!(!available_from(LxcLsOutcome::TimedOut)); + } +} diff --git a/src/backends/lxc/common/src/lib.rs b/src/backends/lxc/common/src/lib.rs index 977c35f69..a552ca3ca 100644 --- a/src/backends/lxc/common/src/lib.rs +++ b/src/backends/lxc/common/src/lib.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +pub mod availability; pub mod filesystem_mounts; pub mod lxc_bindings; pub mod lxc_runner; diff --git a/src/backends/windows_sandbox/lifecycle/src/availability.rs b/src/backends/windows_sandbox/lifecycle/src/availability.rs new file mode 100644 index 000000000..91e2ae55c --- /dev/null +++ b/src/backends/windows_sandbox/lifecycle/src/availability.rs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Windows Sandbox host-availability probe (ports the SDK's +//! `isWindowsSandboxAvailable()`). +//! +//! Detects availability by the presence of `WindowsSandbox.exe`, which Windows +//! installs only when the `Containers-DisposableClientVM` feature is enabled. We +//! skip the SDK's DISM query: `dism /online` needs elevation and this probe only +//! runs unelevated, so it would always fall through to this same exe check. + +use wxc_common::system_dir::system_directory; + +pub fn is_windows_sandbox_available() -> bool { + // `system_directory()` resolves via `GetSystemDirectoryW`, not + // `%SystemRoot%`, so an unelevated user can't spoof the path. + system_directory().join("WindowsSandbox.exe").exists() +} diff --git a/src/backends/windows_sandbox/lifecycle/src/lib.rs b/src/backends/windows_sandbox/lifecycle/src/lib.rs index 30f2b073f..458eb9795 100644 --- a/src/backends/windows_sandbox/lifecycle/src/lib.rs +++ b/src/backends/windows_sandbox/lifecycle/src/lib.rs @@ -8,6 +8,8 @@ //! policy. The transient [`WindowsSandboxRunner`] composes these primitives for //! one-shot execution. +#[cfg(windows)] +pub mod availability; pub mod bridge; pub mod constants; pub mod control_plane; diff --git a/src/backends/windows_sandbox/lifecycle/src/one_shot.rs b/src/backends/windows_sandbox/lifecycle/src/one_shot.rs index 8096a56f7..c5322ee62 100644 --- a/src/backends/windows_sandbox/lifecycle/src/one_shot.rs +++ b/src/backends/windows_sandbox/lifecycle/src/one_shot.rs @@ -289,20 +289,16 @@ fn current_exe_dir() -> Result { .ok_or_else(|| "current_exe has no parent directory".to_string()) } -/// Check whether Windows Sandbox is installed by probing for -/// `WindowsSandbox.exe`. +/// Preflight: is Windows Sandbox installed here? /// -/// Detection is by binary presence rather than a DISM feature query, which -/// requires elevation and fails for ordinary users. +/// Delegates to [`crate::availability::is_windows_sandbox_available`] so the +/// runner's preflight uses the same trusted, `GetSystemDirectoryW`-based result +/// as capability reporting — rather than an env-spoofable `%SystemRoot%` path. fn check_sandbox_available() -> Result<(), String> { - let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string()); - let sandbox_exe = std::path::Path::new(&system_root) - .join("System32") - .join("WindowsSandbox.exe"); - if sandbox_exe.exists() { + if crate::availability::is_windows_sandbox_available() { Ok(()) } else { - Err(format!("{} not found", sandbox_exe.display())) + Err("WindowsSandbox.exe not found; enable the Windows Sandbox optional feature".to_string()) } } diff --git a/src/backends/windows_sandbox/lifecycle/src/vm.rs b/src/backends/windows_sandbox/lifecycle/src/vm.rs index 23a6cf1d0..5cc866afc 100644 --- a/src/backends/windows_sandbox/lifecycle/src/vm.rs +++ b/src/backends/windows_sandbox/lifecycle/src/vm.rs @@ -133,13 +133,17 @@ fn write_fresh(path: &Path, content: &str) -> std::io::Result<()> { /// Launch Windows Sandbox with the given .wsb file. pub(crate) async fn launch(wsb_path: &Path) -> Result<()> { - eprintln!("[daemon] launching WindowsSandbox.exe with {:?}", wsb_path); + // Resolve `WindowsSandbox.exe` under the trusted System directory + // (`GetSystemDirectoryW`, not `%SystemRoot%` or the executable search order), + // so a binary planted in the app dir or CWD can't be launched in its place. + let sandbox_exe = wxc_common::system_dir::system_directory().join("WindowsSandbox.exe"); + eprintln!("[daemon] launching {:?} with {:?}", sandbox_exe, wsb_path); - let status = Command::new("WindowsSandbox.exe") + let status = Command::new(&sandbox_exe) .arg(wsb_path) .status() .await - .context("spawn WindowsSandbox.exe")?; + .with_context(|| format!("spawn {}", sandbox_exe.display()))?; if !status.success() { anyhow::bail!( diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 9db711921..9fde579d2 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -53,8 +53,54 @@ Filesystem-policy discovery helpers (ports of the SDK's `policy.ts`) are also available to feed a policy: [`available_tools_policy`] (PATH + tool/SDK env dirs), [`user_profile_policy`], and [`temporary_files_policy`]. -[`platform_support`] is the Rust port of `getPlatformSupport` — reports host -support and the available containment backends. +## Discovering host backends + +Two read-only probes answer "what can I run here?" — for two different +questions: + +- [`platform_support`] — the Rust port of `getPlatformSupport`. Reports whether + MXC is supported on this host and the backends **this SDK can actually + launch** (the subset in [Supported backends](#supported-backends)). Use it to + decide whether `run` / `spawn_sandbox` will work before building a request. +- [`available_backends`] — a broader **host-capability** probe. Reports every + containment backend the *host* can run, including ones only the executor + binaries (`wxc-exec` etc.) can currently drive — Windows Sandbox, + IsolationSession, LXC — each with its effective isolation **tier** (for the + Windows ProcessContainer ladder). Use it for capability discovery, not as a + launchability guarantee. + +```rust,no_run +use mxc_sdk::{available_backends, platform_support}; + +// Will run()/spawn_sandbox() work here, and with which backends? +let support = platform_support(); +if support.is_supported { + println!("SDK-launchable: {:?}", support.available_methods); +} else { + println!("unsupported: {:?}", support.reason); +} + +// What can the host run at all, and at what isolation-tier ceiling? +for backend in available_backends() { + match backend.tier { + Some(tier) => println!("{} (tier: {tier})", backend.backend), + None => println!("{}", backend.backend), + } +} +``` + +The reported `tier` is a **ceiling** — the strongest isolation the host can +reach for that backend; a policy can still force a weaker tier at dispatch. And +a backend appearing in `available_backends()` is a host-capability signal, **not** +a guarantee this SDK can launch it — cross-check [`platform_support`] for that. + +> **Before / after.** Host-and-backend discovery previously lived only in the +> TypeScript SDK (`getPlatformSupport`), so Rust callers and the executor +> binaries had no in-process way to ask "what backends does this host support?" +> and could only learn a backend was unusable by trying to launch it. Now the +> engine answers both in-process — [`platform_support`] for the SDK-launchable +> subset and [`available_backends`] for the full host-capability set with tiers — +> with no TypeScript dependency and no trial spawn. ## Denial capture (Windows) diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 363d41a49..5fa533ff0 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -113,9 +113,10 @@ mod sandbox; pub use mxc_engine::policy; pub use mxc_engine::{ - available_tools_policy, build_request, build_request_with_containment, platform_support, - temporary_files_policy, user_profile_policy, Containment, Error, ErrorCode, - FilesystemPolicyResult, PlatformSupport, SandboxPolicy, SandboxRequest, WslcSection, + available_backends, available_tools_policy, build_request, build_request_with_containment, + platform_support, temporary_files_policy, user_profile_policy, AvailableBackend, Containment, + Error, ErrorCode, FilesystemPolicyResult, PlatformSupport, SandboxPolicy, SandboxRequest, + WslcSection, }; pub use sandbox::{ diff --git a/src/core/mxc-sdk/tests/sdk_helpers.rs b/src/core/mxc-sdk/tests/sdk_helpers.rs index 5dc3445ca..8bc8c3bdd 100644 --- a/src/core/mxc-sdk/tests/sdk_helpers.rs +++ b/src/core/mxc-sdk/tests/sdk_helpers.rs @@ -199,18 +199,22 @@ fn build_request_then_run_seatbelt() { #[cfg(target_os = "linux")] #[test] -fn platform_support_linux_methods_are_bubblewrap_only() { +fn platform_support_linux_reports_only_bubblewrap() { let support = platform_support(); - // The crate dispatches only Bubblewrap on Linux (LXC has no captured / - // streaming path), so that is the only method it should ever report. - for method in &support.available_methods { - assert_eq!(method, "bubblewrap", "unexpected Linux method: {method}"); - } + // Bubblewrap is the only SDK-launchable Linux backend; `lxc` is a + // host-capability backend reported by `available_backends()`, not here. + // Assert the exact set so re-advertising a non-launchable backend fails + // (an inclusive `for` check would pass vacuously and permit `lxc`). + assert_eq!( + support.available_methods, + vec!["bubblewrap".to_string()], + "Linux platform_support must report exactly bubblewrap (lxc excluded)" + ); } #[cfg(target_os = "windows")] #[test] -fn platform_support_windows_is_processcontainer() { +fn platform_support_windows_includes_processcontainer() { let support = platform_support(); assert!(support.is_supported, "reason: {:?}", support.reason); // ProcessContainer is always available on Windows and is reported first. @@ -218,12 +222,15 @@ fn platform_support_windows_is_processcontainer() { support.available_methods.first().map(String::as_str), Some("processcontainer") ); - // WSLC is the only other backend the crate can report, and only when it is - // compiled in *and* the host has the WSLC runtime. Nothing else may appear. + // Beyond processcontainer, only `wslc` may appear (SDK-launchable, opt-in). + // `windows_sandbox` and `isolation_session` are host-capability backends + // reported by `available_backends()`, not here — so assert they never leak + // into this launchable set, or a regression would slip through. for method in &support.available_methods { assert!( matches!(method.as_str(), "processcontainer" | "wslc"), - "unexpected Windows method: {method}" + "unexpected Windows method (only processcontainer + optional wslc \ + are SDK-launchable): {method}" ); } } @@ -234,9 +241,10 @@ fn platform_support_windows_is_processcontainer() { #[test] fn platform_support_windows_omits_wslc_when_not_compiled_in() { let support = platform_support(); - assert_eq!( - support.available_methods, - vec!["processcontainer".to_string()] + assert!( + !support.available_methods.iter().any(|m| m == "wslc"), + "wslc must not be advertised without the feature: {:?}", + support.available_methods ); } diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index c76a1c783..c57840c8d 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -24,6 +24,8 @@ //! selection and execution. //! - [`run_state_aware`] — state-aware lifecycle backend resolution + dispatch. //! - [`platform_support`] / [`PlatformSupport`] — host support detection. +//! - [`available_backends`] / [`AvailableBackend`] — read-only host +//! backend-availability probe (with effective isolation tier). //! - [`Error`] / [`ErrorCode`] — the crate-owned error facade over //! `wxc_common`'s internal error type. @@ -31,6 +33,7 @@ mod dispatch; mod error; mod platform; pub mod policy; +mod probe; #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] mod run; mod state_aware; @@ -42,6 +45,7 @@ pub use policy::{ user_profile_policy, Containment, FilesystemPolicyResult, SandboxPolicy, SandboxRequest, WslcSection, }; +pub use probe::{available_backends, AvailableBackend}; #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] pub use run::{resolve_runner, run, ResolvedRunner}; pub use state_aware::{exec_state_aware_json, run_state_aware, run_state_aware_json}; diff --git a/src/core/mxc_engine/src/platform.rs b/src/core/mxc_engine/src/platform.rs index 5d66f0b75..2a1ce0f29 100644 --- a/src/core/mxc_engine/src/platform.rs +++ b/src/core/mxc_engine/src/platform.rs @@ -31,7 +31,10 @@ pub struct PlatformSupport { /// `mxc-sdk` library can actually run. On Windows the isolation tier and UI /// capabilities come from the in-process fallback probe rather than a /// `wxc-exec --probe` subprocess, and `wslc` is reported when the host has the -/// WSL Container runtime (requires the `wslc` feature). +/// WSL Container runtime (requires the `wslc` feature). The broader +/// host-capability set (backends the host can run but the SDK cannot launch, +/// e.g. `lxc`, `windows_sandbox`, `isolation_session`) is reported separately by +/// [`available_backends`](crate::available_backends). pub fn platform_support() -> PlatformSupport { #[cfg(target_os = "macos")] { @@ -55,7 +58,9 @@ pub fn platform_support() -> PlatformSupport { { // Presence alone is not enough: `bwrap` must also be new enough for // every flag the argument builder emits (see - // `bwrap_common::bwrap_version::MIN_BWRAP_VERSION`). + // `bwrap_common::bwrap_version::MIN_BWRAP_VERSION`). `lxc` is a + // host-capability backend the SDK can't launch, so it is reported by + // `available_backends()` rather than here. match bwrap_common::bwrap_version::probe_bwrap() { Ok(_) => PlatformSupport { is_supported: true, @@ -72,6 +77,10 @@ pub fn platform_support() -> PlatformSupport { #[cfg(target_os = "windows")] { let mut available_methods = vec!["processcontainer".to_string()]; + // `windows_sandbox` and `isolation_session` are host-capability backends + // the SDK can't launch, so they are reported by `available_backends()` + // rather than here. + // // WSLC is an additional, opt-in backend rather than a fallback: report // it only when the host can actually run it (WSL2 + the WSLC runtime), // which is the same preflight the runner performs. @@ -108,3 +117,62 @@ fn wslc_available() -> bool { false } } + +#[cfg(test)] +mod tests { + use super::platform_support; + use wxc_common::wire::Containment; + + fn wire_name(containment: &Containment) -> String { + serde_json::to_string(containment) + .expect("Containment serializes") + .trim_matches('"') + .to_string() + } + + fn all_wire_names() -> Vec { + [ + Containment::Process, + Containment::ProcessContainer, + Containment::Vm, + Containment::WindowsSandbox, + Containment::Lxc, + Containment::Microvm, + Containment::Hyperlight, + Containment::Wslc, + Containment::Seatbelt, + Containment::IsolationSession, + Containment::Bubblewrap, + ] + .iter() + .map(wire_name) + .collect() + } + + /// Guards the reported string literals against drift from the `Containment` + /// serde wire names. + #[test] + fn reported_method_names_match_the_containment_wire_names() { + assert_eq!(wire_name(&Containment::Lxc), "lxc"); + assert_eq!(wire_name(&Containment::WindowsSandbox), "windows_sandbox"); + assert_eq!( + wire_name(&Containment::ProcessContainer), + "processcontainer" + ); + assert_eq!(wire_name(&Containment::Bubblewrap), "bubblewrap"); + assert_eq!(wire_name(&Containment::Seatbelt), "seatbelt"); + } + + /// Exercises the live per-target arm, catching a typo'd literal (e.g. + /// `"wsb"`) that the assertions above would miss. + #[test] + fn every_reported_method_is_a_real_wire_name() { + let known = all_wire_names(); + for method in platform_support().available_methods { + assert!( + known.contains(&method), + "reported method {method:?} is not a Containment wire name" + ); + } + } +} diff --git a/src/core/mxc_engine/src/probe.rs b/src/core/mxc_engine/src/probe.rs new file mode 100644 index 000000000..133395336 --- /dev/null +++ b/src/core/mxc_engine/src/probe.rs @@ -0,0 +1,320 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host backend-availability probe — the read-only [`available_backends`] API. +//! +//! Reports only the containment backends the current host can run, each with +//! its effective isolation tier when it has a tier ladder. Answers "what can I +//! use here?"; a backend's absence means "not currently usable, for any reason". +//! Separate from [`platform_support`](crate::platform_support), which answers the +//! narrower "what can `mxc-sdk` itself launch?" question and reports no tier. + +use serde::Serialize; +use wxc_common::models::ContainmentBackend; + +/// One host-available backend, plus its effective isolation tier (if any). +/// +/// Serializes to camelCase JSON such as `{"backend":"seatbelt"}` or +/// `{"backend":"processcontainer","tier":"appcontainer-dacl"}`; `tier` is +/// omitted (never `null`) when the backend has no tier ladder. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AvailableBackend { + /// Canonical [`wxc_common::wire::Containment`] wire name. + pub backend: String, + /// Highest-isolation tier the host supports for this backend (a canonical + /// `IsolationTier::as_str()` name); `None`, and omitted from JSON, for + /// backends with no tier ladder. + #[serde(skip_serializing_if = "Option::is_none")] + pub tier: Option, +} + +impl AvailableBackend { + fn tierless(backend: &str) -> Self { + Self { + backend: backend.to_string(), + tier: None, + } + } + + #[cfg(target_os = "windows")] + fn tiered(backend: &str, tier: &str) -> Self { + Self { + backend: backend.to_string(), + tier: Some(tier.to_string()), + } + } +} + +/// Probe the host and return only the backends it can currently run. +/// +/// An empty `Vec` is a normal result (unsupported platform, or Linux with +/// neither `bwrap` nor `lxc`), not an error. Order is stable but callers should +/// match by `backend` name, not position. +/// +/// Not cached — read once at startup, not in a hot loop. The reported `tier` is +/// a ceiling: policy can still force a weaker tier at dispatch. +pub fn available_backends() -> Vec { + #[cfg(target_os = "macos")] + { + macos_backends() + } + #[cfg(target_os = "linux")] + { + linux_backends() + } + #[cfg(target_os = "windows")] + { + windows_backends() + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + Vec::new() + } +} + +#[cfg(target_os = "macos")] +fn macos_backends() -> Vec { + let mut backends = Vec::new(); + if std::path::Path::new("/usr/bin/sandbox-exec").exists() { + backends.push(AvailableBackend::tierless( + ContainmentBackend::Seatbelt.wire_name(), + )); + } + backends +} + +#[cfg(target_os = "linux")] +fn linux_backends() -> Vec { + let mut backends = Vec::new(); + if bwrap_common::bwrap_version::probe_bwrap().is_ok() { + backends.push(AvailableBackend::tierless( + ContainmentBackend::Bubblewrap.wire_name(), + )); + } + if lxc_common::availability::is_lxc_available() { + backends.push(AvailableBackend::tierless( + ContainmentBackend::Lxc.wire_name(), + )); + } + backends +} + +#[cfg(target_os = "windows")] +fn windows_backends() -> Vec { + use appcontainer_common::fallback_detector::is_base_container_usable; + + // `processcontainer` is always present and the only backend with a tier + // ladder, so it carries its effective (highest-reachable) tier. + let tier = select_tier(is_base_container_usable(), cfg!(feature = "tier2_bfs")); + let mut backends = vec![AvailableBackend::tiered( + ContainmentBackend::ProcessContainer.wire_name(), + tier.as_str(), + )]; + + if windows_sandbox_lifecycle::availability::is_windows_sandbox_available() { + 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 wslc_common::is_available() { + backends.push(AvailableBackend::tierless( + ContainmentBackend::Wslc.wire_name(), + )); + } + + // Available when the `IsoSessionOps` WinRT class is registered on the OS. + #[cfg(feature = "isolation_session")] + if isolation_session_common::availability::is_isolation_session_available() { + backends.push(AvailableBackend::tierless( + ContainmentBackend::IsolationSession.wire_name(), + )); + } + + backends +} + +/// Effective process-container tier, strongest reachable rung first: +/// BaseContainer → AppContainerBfs → AppContainerDacl. Split from the host +/// detectors so precedence is testable without a real Windows host or the +/// `tier2_bfs` feature. +/// +/// This reports the tier **ceiling** — the strongest tier the host can reach +/// for *some* request. On a `tier2_bfs` build that is `AppContainerBfs` +/// regardless of `bfscfg.exe`: a request with no filesystem policy reaches BFS +/// without it (`fallback_detector::detect`). `bfscfg.exe` only decides whether a +/// *policy-carrying* request stays at BFS or drops to DACL, so it belongs in +/// request-time dispatch, not in the ceiling. +#[cfg(target_os = "windows")] +fn select_tier( + base_container_usable: bool, + tier2_bfs_enabled: bool, +) -> appcontainer_common::fallback_detector::IsolationTier { + use appcontainer_common::fallback_detector::IsolationTier; + if base_container_usable { + IsolationTier::BaseContainer + } else if tier2_bfs_enabled { + IsolationTier::AppContainerBfs + } else { + IsolationTier::AppContainerDacl + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wxc_common::wire::Containment; + + fn wire_name(containment: &Containment) -> String { + serde_json::to_string(containment) + .expect("Containment serializes") + .trim_matches('"') + .to_string() + } + + fn all_wire_names() -> Vec { + [ + Containment::Process, + Containment::ProcessContainer, + Containment::Vm, + Containment::WindowsSandbox, + Containment::Lxc, + Containment::Microvm, + Containment::Hyperlight, + Containment::Wslc, + Containment::Seatbelt, + Containment::IsolationSession, + Containment::Bubblewrap, + ] + .iter() + .map(wire_name) + .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"); + let json = serde_json::to_string(&backend).expect("serializes"); + assert_eq!(json, r#"{"backend":"seatbelt"}"#); + } + + #[test] + fn tier_is_serialized_in_camel_case_when_present() { + let backend = AvailableBackend { + backend: "processcontainer".to_string(), + tier: Some("appcontainer-dacl".to_string()), + }; + let json = serde_json::to_string(&backend).expect("serializes"); + assert_eq!( + json, + r#"{"backend":"processcontainer","tier":"appcontainer-dacl"}"# + ); + } + + #[test] + fn every_reported_backend_is_a_real_wire_name() { + let known = all_wire_names(); + for entry in available_backends() { + assert!( + known.contains(&entry.backend), + "reported backend {:?} is not a Containment wire name", + entry.backend + ); + } + } + + /// Every backend the probe can emit, across all platforms/features — derived + /// from `ContainmentBackend` (the same source as the `push` calls) so the + /// emitted names can't be typo'd, and checked against the `wire::Containment` + /// serde names so the two enums can't drift. + const EMITTABLE_BACKENDS: [ContainmentBackend; 7] = [ + ContainmentBackend::Seatbelt, + ContainmentBackend::Bubblewrap, + ContainmentBackend::Lxc, + ContainmentBackend::ProcessContainer, + ContainmentBackend::WindowsSandbox, + ContainmentBackend::Wslc, + ContainmentBackend::IsolationSession, + ]; + + /// Complements [`every_reported_backend_is_a_real_wire_name`] (host subset) + /// by checking every emittable backend unconditionally, so a mismatch for a + /// backend this host or feature doesn't exercise (e.g. `wslc`) still can't + /// drift between `ContainmentBackend::wire_name` and `wire::Containment`. + #[test] + fn all_emittable_backend_names_are_real_wire_names() { + let known = all_wire_names(); + for backend in EMITTABLE_BACKENDS { + assert!( + known.contains(&backend.wire_name().to_string()), + "emittable backend {backend:?} wire name {:?} is not a Containment wire name", + backend.wire_name() + ); + } + } + + #[test] + fn every_reported_tier_is_a_canonical_tier_string() { + for entry in available_backends() { + if let Some(tier) = entry.tier { + assert!( + CANONICAL_TIERS.contains(&tier.as_str()), + "reported tier {tier:?} is not a canonical IsolationTier string" + ); + } + } + } + + /// Guards `CANONICAL_TIERS` against drift from `IsolationTier::as_str()`. + #[cfg(target_os = "windows")] + #[test] + fn canonical_tier_strings_match_isolation_tier() { + use appcontainer_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]); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_always_reports_processcontainer_with_a_tier() { + let backends = available_backends(); + let pc = backends + .iter() + .find(|b| b.backend == "processcontainer") + .expect("processcontainer must always be reported on Windows"); + let tier = pc.tier.as_deref().expect("processcontainer carries a tier"); + assert!( + CANONICAL_TIERS.contains(&tier), + "unexpected processcontainer tier: {tier:?}" + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn tier_precedence_prefers_the_strongest_reachable_rung() { + use appcontainer_common::fallback_detector::IsolationTier; + // BaseContainer wins whenever usable, regardless of tier2_bfs. + assert_eq!(select_tier(true, false), IsolationTier::BaseContainer); + assert_eq!(select_tier(true, true), IsolationTier::BaseContainer); + // The ceiling is BFS on any tier2_bfs build (a no-policy request reaches + // it without bfscfg.exe); bfscfg gating lives in request-time dispatch. + assert_eq!(select_tier(false, true), IsolationTier::AppContainerBfs); + assert_eq!(select_tier(false, false), IsolationTier::AppContainerDacl); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn processcontainer_never_appears_off_windows() { + assert!(available_backends() + .iter() + .all(|b| b.backend != "processcontainer")); + } +} diff --git a/src/core/wxc_common/src/lib.rs b/src/core/wxc_common/src/lib.rs index ffe87af44..e80681957 100644 --- a/src/core/wxc_common/src/lib.rs +++ b/src/core/wxc_common/src/lib.rs @@ -52,6 +52,8 @@ pub mod filesystem_dacl; pub mod process_util; #[cfg(target_os = "windows")] pub mod string_util; +#[cfg(target_os = "windows")] +pub mod system_dir; // Unix-specific modules (shared by the Seatbelt and Bubblewrap backends). #[cfg(unix)] diff --git a/src/core/wxc_common/src/system_dir.rs b/src/core/wxc_common/src/system_dir.rs new file mode 100644 index 000000000..e440fc89c --- /dev/null +++ b/src/core/wxc_common/src/system_dir.rs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Resolve the Windows System directory via `GetSystemDirectoryW`. +//! +//! Security-critical: callers locate trusted system binaries (`wpr.exe`, +//! `WindowsSandbox.exe`) under this path, so it must come from the kernel, not +//! `%SystemRoot%`/`%SystemDirectory%`. UAC inherits an unelevated parent's +//! environment, so an env-derived path would let a standard user plant a fake +//! binary and spoof the result. `GetSystemDirectoryW` is published at process +//! creation and cannot be overridden by the env block. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// Resolve the System directory via `GetSystemDirectoryW`, or `None` if the +/// call fails outright (return value 0 — a broken/stripped Windows install; +/// does not happen on a real install). +pub fn resolve_system_directory() -> Option { + use windows::Win32::System::SystemInformation::GetSystemDirectoryW; + + let mut buf = vec![0u16; 260]; + // SAFETY: `buf` is initialized and owned for the call; we read only the + // returned prefix. + let mut n = unsafe { GetSystemDirectoryW(Some(&mut buf)) }; + if n == 0 { + return None; + } + // `n >= buf.len()`: buffer was too small and `n` is the required size + // (including the NUL) — grow and retry once. + if n as usize >= buf.len() { + buf = vec![0u16; n as usize]; + n = unsafe { GetSystemDirectoryW(Some(&mut buf)) }; + if n == 0 || n as usize >= buf.len() { + return None; + } + } + Some(PathBuf::from(crate::string_util::from_wide( + &buf[..n as usize], + ))) +} + +/// Cached System directory, falling back to the (non-env-derived) literal +/// `C:\Windows\System32` only if `GetSystemDirectoryW` fails. +pub fn system_directory() -> &'static Path { + static SYSTEM_DIR: OnceLock = OnceLock::new(); + SYSTEM_DIR + .get_or_init(|| { + resolve_system_directory().unwrap_or_else(|| PathBuf::from(r"C:\Windows\System32")) + }) + .as_path() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn system_directory_is_absolute_and_exists() { + let dir = system_directory(); + assert!(dir.is_absolute(), "system dir must be absolute: {dir:?}"); + assert!(dir.exists(), "system dir must exist: {dir:?}"); + } +} diff --git a/src/host/plm/src/wpr_path.rs b/src/host/plm/src/wpr_path.rs index e34141102..0653403da 100644 --- a/src/host/plm/src/wpr_path.rs +++ b/src/host/plm/src/wpr_path.rs @@ -47,42 +47,18 @@ use std::sync::OnceLock; /// Cached absolute path to `wpr.exe`, resolved on first use. static WPR_PATH: OnceLock> = OnceLock::new(); -/// Resolve `\wpr.exe` via `GetSystemDirectoryW`. The kernel -/// publishes this value at process creation and the env block cannot -/// override it, so this is safe even when the parent (unelevated) -/// process set `SystemRoot` to an attacker-controlled directory. -/// -/// If `GetSystemDirectoryW` reports the initial 260-wide buffer is -/// insufficient (return value `>= buf.len()`, per Win32 semantics), -/// we retry once with the required size. Only if the API returns 0 -/// (a Win32 failure, which does not happen on a real Windows install) -/// do we surface `None` to the caller. +/// Resolve `\wpr.exe`. The System directory comes from +/// `GetSystemDirectoryW` (kernel-published, not env-spoofable), so this is +/// safe even when the parent (unelevated) process set `SystemRoot` to an +/// attacker-controlled directory. Returns `None` only when +/// `GetSystemDirectoryW` fails outright (a broken Windows install, which does +/// not happen on a real one). #[cfg(target_os = "windows")] fn resolve_wpr_path() -> Option { - use windows::Win32::System::SystemInformation::GetSystemDirectoryW; - - let mut buf = vec![0u16; 260]; - // SAFETY: buf is initialized; we pass a valid length and own the - // memory for the duration of the call. - let mut n = unsafe { GetSystemDirectoryW(Some(&mut buf)) }; - if n == 0 { - return None; - } - // Per docs: on success `n` is the length WITHOUT the terminating - // NUL and is strictly less than the buffer size. If `n` is >= - // buffer size, the buffer was too small and `n` is the required - // size INCLUDING the NUL — grow and retry once. - if (n as usize) >= buf.len() { - buf = vec![0u16; n as usize]; - n = unsafe { GetSystemDirectoryW(Some(&mut buf)) }; - if n == 0 || (n as usize) >= buf.len() { - return None; - } - } - let dir = wxc_common::string_util::from_wide(&buf[..n as usize]); - let mut p = PathBuf::from(dir); - p.push("wpr.exe"); - Some(p) + wxc_common::system_dir::resolve_system_directory().map(|mut dir| { + dir.push("wpr.exe"); + dir + }) } /// Sanity-check that the resolved `wpr.exe` actually exists on disk.