diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8d921bb05..93c3a7559 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -191,7 +191,7 @@ The workspace is organized into six top-level directories under `src/`: | Directory | Purpose | Examples | |-----------|---------|----------| | `core/` | Cross-platform foundation + per-platform aggregator binaries | `wxc_common/`, `wxc/`, `lxc/`, `mxc_darwin/`, `mxc_engine/`, `mxc-sdk/`, `mxc_pty/`, `mxc_build_common/`, `generated/` | -| `backends/` | Backend-specific code (one subfolder per containment backend) | `appcontainer/common`, `windows_sandbox/{daemon,guest,common,lifecycle}`, `isolation_session/{bindings,common}`, `hyperlight/common`, `nanvix/{common,build_common,binaries,runner}`, `lxc/common`, `bubblewrap/common`, `wslc/common`, `seatbelt/common` | +| `backends/` | Backend-specific code (one subfolder per containment backend or backend support component) | `appcontainer/common`, `windows_sandbox/{daemon,guest,common,lifecycle}`, `isolation_session/{bindings,common}`, `learning_mode/windows`, `hyperlight/common`, `nanvix/{common,build_common,binaries,runner}`, `lxc/common`, `bubblewrap/common`, `wslc/common`, `seatbelt/common` | | `ffi/` | Foreign-function-interface crates (C ABI for language bindings) | `mxc_ffi/` | | `host/` | Host-side utilities | `wxc_host_prep/`, `wxc_winhttp_proxy_shim/` | | `testing/` | Test infrastructure crates | `wxc_e2e_tests/`, `wxc_test_driver/`, `wxc_test_proxy/`, `unix_test_proxy/`, `wxc_ui_probe/`, `fuzz/` | @@ -199,6 +199,7 @@ The workspace is organized into six top-level directories under `src/`: - `wxc_common` is the **cross-platform foundation**: config parsing, models, errors, logger, `ScriptRunner` / `StatefulSandboxBackend` traits, state-aware dispatch helpers, validators, ids, ui-policy, encoding. Plus a few thin Windows API helpers shared by host tools and backends (`process_util`, `string_util`, `filesystem_dacl`, `diagnostic`). It must not depend on any `backends/*` crate. - Each Windows containment backend lives in its own `backends/*/common` crate (e.g. `appcontainer_common`, `windows_sandbox_common`, `isolation_session_common`, `hyperlight_common`, `nanvix_runner`). Backend crates depend on `wxc_common`; there are no cross-edges between backend crates. Windows Sandbox additionally has `windows_sandbox_lifecycle`, which owns the one-shot and state-aware runners and depends on `windows_sandbox_common` for the wire protocol, plus separate daemon and guest binaries. +- `learning_mode_windows` (`backends/learning_mode/windows`) is a Windows-only backend support crate for the AppInfo-brokered Learning Mode APIs in `processmodel.dll`. It runtime-resolves the Learning Mode trace and process security-environment exports, owns their typed handle/lifecycle wrappers, and depends only on `wxc_common`; runner integration consumes it from the AppContainer backend layer. - `wxc`, `lxc`, and `mxc_darwin` are thin binary crates (`wxc-exec` / `lxc-exec` / `mxc-exec-mac`) that wire up CLI args (`clap`), load/validate config, handle maintenance modes (`--probe`, `--delete`, `--setup-*`, `--audit`), and **delegate all backend dispatch to `mxc_engine`**. They contain no `match request.containment` of their own. `wxc-exec` additionally owns the Windows Ctrl-C / DACL-cleanup / `--audit` PLM-trace / telemetry orchestration around the engine call. - `mxc_engine` is the **single execution engine** — the one home for "given an `ExecutionRequest`, run it". It owns: run-to-completion backend selection (`run` / `resolve_runner`, covering **all** backends, incl. the Windows ProcessContainer BaseContainer/AppContainer BFS/DACL fallback tiers via `appcontainer_common::dispatcher::dispatch_with_fallback`, and every experimental backend, feature-gated); streaming (`spawn` → `Box`); state-aware lifecycle dispatch (`run_state_aware`, including Windows Sandbox and IsolationSession); host probing (`platform_support` / `PlatformSupport`); and config building (`build_request`, `SandboxPolicy` + sections, `available_tools_policy`/`user_profile_policy`/`temporary_files_policy`). It depends on the backend crates (cfg-split: appcontainer/windows_sandbox lifecycle/isolation_session/wslc/nanvix on Windows, bubblewrap/lxc/nanvix on Linux, seatbelt on macOS) so it can't live in `wxc_common`. Both the executor binaries and `mxc-sdk` call into it. `ResolvedRunner` carries the boxed runner plus (Windows only) the optional `DaclManager` guard, so `wxc-exec` can park the guard for its signal handler. - `mxc-sdk` is the **public Rust SDK** — a thin facade over `mxc_engine`. Build a `SandboxRequest` with `build_request`, then either `run(request)` (run-to-completion; returns an `Output` with the `WaitOutcome` + captured `stdout`/`stderr`) or `spawn_sandbox(request)` (returns a `Sandbox` handle for live bidirectional stdio — `take_stdin`/`take_stdout`/`take_stderr`, `kill()`, `wait()` returning a `WaitOutcome` (`Exited(i32)` / `TimedOut`) as `io::Result`, or `wait_with_output()`). It re-exports the engine's config-building surface (`build_request`, `mxc_sdk::policy::{SandboxPolicy sections}`, discovery helpers) and `platform_support`; `mod sandbox` (wrapping the engine's `SandboxProcess` in `Sandbox`) is its only local module. No pty is ever allocated. Streaming supports Seatbelt (macOS), Bubblewrap (Linux), and Windows ProcessContainer (AppContainer + BaseContainer); other backends return `ErrorCode::UnsupportedContainment`. diff --git a/src/Cargo.lock b/src/Cargo.lock index b05d4dbe5..8f98c42b8 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1364,6 +1364,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "learning_mode_windows" +version = "0.7.0" +dependencies = [ + "flatbuffers", + "sandbox_spec", + "thiserror", + "windows", + "windows-core", + "wxc_common", +] + [[package]] name = "leb128fmt" version = "0.1.0" diff --git a/src/Cargo.toml b/src/Cargo.toml index 1dca1950e..e01d0d0e9 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -22,6 +22,7 @@ members = [ "backends/nanvix/build_common", "backends/nanvix/binaries", "backends/nanvix/runner", + "backends/learning_mode/windows", "backends/lxc/common", "backends/bubblewrap/common", "backends/wslc/common", @@ -109,6 +110,7 @@ windows_sandbox_common = { path = "backends/windows_sandbox/common" } windows_sandbox_lifecycle = { path = "backends/windows_sandbox/lifecycle" } isolation_session_common = { path = "backends/isolation_session/common" } hyperlight_common = { path = "backends/hyperlight/common" } +learning_mode_windows = { path = "backends/learning_mode/windows" } nanvix_runner = { path = "backends/nanvix/runner" } tokio = { version = "1", features = ["full"] } uuid = { version = "1", features = ["v4"] } diff --git a/src/backends/learning_mode/windows/Cargo.toml b/src/backends/learning_mode/windows/Cargo.toml new file mode 100644 index 000000000..d99f48660 --- /dev/null +++ b/src/backends/learning_mode/windows/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "learning_mode_windows" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +thiserror = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +wxc_common = { workspace = true } +windows = { workspace = true } +windows-core = { workspace = true } + +[target.'cfg(target_os = "windows")'.dev-dependencies] +sandbox_spec = { workspace = true } +flatbuffers = { workspace = true } diff --git a/src/backends/learning_mode/windows/examples/lm_capture.rs b/src/backends/learning_mode/windows/examples/lm_capture.rs new file mode 100644 index 000000000..c38a0b5b3 --- /dev/null +++ b/src/backends/learning_mode/windows/examples/lm_capture.rs @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! End-to-end validation for the Learning Mode capture lifecycle, independent of the +//! MXC runner and the `captureDenials` config. +//! +//! It drives the full 2-phase sequence against a real child process: +//! +//! 1. build a minimal FlatBuffer sandbox spec with the `permissiveLearningMode` +//! capability (the token the OS learning-mode path recognises), +//! 2. [`CaptureSession::begin`] — create the security environment + start the trace, +//! 3. launch `cmd.exe` inside the environment via +//! `CreateProcessAsUserInsideSecurityEnvironment`, +//! 4. wait for it to exit, +//! 5. [`CaptureSession::finish`] — seal the ETL to a temp path + close the environment, +//! 6. assert the ETL file was produced (non-empty). +//! +//! Run on a feature-enabled Windows build (elevated): +//! +//! ```text +//! cargo run -p learning_mode_windows --example lm_capture +//! ``` +//! +//! Exit codes: `0` = ETL produced; `2` = API unavailable / off-feature build; `1` = a +//! step failed. + +#[cfg(not(target_os = "windows"))] +fn main() { + eprintln!("lm_capture is Windows-only"); + std::process::exit(2); +} + +#[cfg(target_os = "windows")] +fn main() { + std::process::exit(windows_impl::run()); +} + +#[cfg(target_os = "windows")] +mod windows_impl { + use std::path::PathBuf; + + use flatbuffers::FlatBufferBuilder; + use learning_mode_windows::{ + CaptureSession, LearningModeApi, SecurityEnvironmentApi, + PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE, + }; + use sandbox_spec::base_container_layout::{ + finish_sandbox_spec_buffer, SandboxSpec, SandboxSpecArgs, + }; + use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_FAILED, WAIT_OBJECT_0}; + use windows::Win32::System::Threading::{ + GetExitCodeProcess, WaitForSingleObject, INFINITE, PROCESS_INFORMATION, STARTUPINFOW, + }; + + /// Matches the schema version BaseContainer embeds in every spec payload. + const SANDBOX_SPEC_VERSION: &str = "0.1.0"; + + /// Build a minimal FlatBuffer `SandboxSpec` carrying the learning-mode capability. + fn build_sandbox_spec() -> Vec { + let mut builder = FlatBufferBuilder::with_capacity(256); + let version = builder.create_string(SANDBOX_SPEC_VERSION); + // `permissiveLearningMode` is the capability the SandboxEngine functest uses to + // exercise the learning-mode trace; it reliably drives recorded events. + let capabilities = builder.create_string("permissiveLearningMode"); + let spec = SandboxSpec::create( + &mut builder, + &SandboxSpecArgs { + version: Some(version), + app_container: true, + capabilities: Some(capabilities), + ..Default::default() + }, + ); + finish_sandbox_spec_buffer(&mut builder, spec); + builder.finished_data().to_vec() + } + + /// Null-terminated, mutable UTF-16 command line for the child. + fn wide_command_line() -> Vec { + let cmd = r#"cmd.exe /c echo Hello from the learning-mode sandbox & whoami"#; + cmd.encode_utf16().chain(std::iter::once(0)).collect() + } + + fn etl_output_path() -> PathBuf { + std::env::temp_dir().join(format!("lm_capture_{}.etl", std::process::id())) + } + + pub fn run() -> i32 { + let secenv_api = match SecurityEnvironmentApi::load() { + Ok(api) => api, + Err(e) => { + eprintln!("SecurityEnvironmentApi::load failed (off-feature build?): {e}"); + return 2; + } + }; + let learning_mode_api = match LearningModeApi::load() { + Ok(api) => api, + Err(e) => { + eprintln!("LearningModeApi::load failed (off-feature build?): {e}"); + return 2; + } + }; + + let spec = build_sandbox_spec(); + println!("built sandbox spec: {} bytes", spec.len()); + + let session = match CaptureSession::begin( + secenv_api, + learning_mode_api, + &spec, + PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE, + ) { + Ok(session) => session, + Err(e) => { + eprintln!("CaptureSession::begin failed: {e}"); + return 1; + } + }; + println!("CaptureSession::begin OK — environment + trace live"); + + let exit_code = match launch_and_wait(&secenv_api, session.environment()) { + Ok(code) => { + println!("child exited with code {code}"); + code + } + Err(e) => { + eprintln!("launch failed: {e}"); + // `session` drops here → trace discarded + environment closed. + return 1; + } + }; + let _ = exit_code; + + let etl_path = etl_output_path(); + if let Err(e) = session.finish(Some(&etl_path)) { + eprintln!("CaptureSession::finish failed: {e}"); + return 1; + } + println!("CaptureSession::finish OK — trace sealed, environment closed"); + + match std::fs::metadata(&etl_path) { + Ok(meta) => { + println!( + "ETL produced: {} ({} bytes)", + etl_path.display(), + meta.len() + ); + if meta.len() == 0 { + eprintln!("ETL validation failed: file is empty"); + 1 + } else { + 0 + } + } + Err(e) => { + eprintln!("expected ETL at {} but none found: {e}", etl_path.display()); + 1 + } + } + } + + /// Launch the child inside `environment` and wait for it to exit, returning its exit + /// code. + fn launch_and_wait( + secenv_api: &SecurityEnvironmentApi, + environment: HANDLE, + ) -> Result { + let launch = secenv_api.launch_fn(); + let mut cmd = wide_command_line(); + + // SAFETY: a zeroed STARTUPINFOW with only `cb` set is valid; the child inherits + // the caller's console for stdio (no STARTF_USESTDHANDLES). + let mut startup_info: STARTUPINFOW = unsafe { std::mem::zeroed() }; + startup_info.cb = u32::try_from(std::mem::size_of::()) + .map_err(|_| "STARTUPINFOW size overflow".to_string())?; + let mut process_information: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + + // SAFETY: `launch` was resolved from processmodel.dll and matches the declared C + // signature. `cmd` is a mutable, null-terminated UTF-16 buffer; `startup_info` + // and `process_information` are valid; `environment` is the live handle from the + // session. `lpEnvironment` is null, so CREATE_UNICODE_ENVIRONMENT is not needed. + let ok = unsafe { + launch( + HANDLE(std::ptr::null_mut()), // userToken: caller context + std::ptr::null(), // applicationName (from command line) + cmd.as_mut_ptr(), // commandLine + 0, // creationFlags + std::ptr::null(), // environment + std::ptr::null(), // currentDirectory + &startup_info, + environment, + &mut process_information, + ) + }; + if ok == 0 { + // SAFETY: reads the calling thread's last-error slot. + let err = unsafe { windows::Win32::Foundation::GetLastError() }; + return Err(format!( + "CreateProcessAsUserInsideSecurityEnvironment failed (GetLastError = {})", + err.0 + )); + } + + // SAFETY: `hProcess` is a valid process handle returned by the launch. + let wait = unsafe { WaitForSingleObject(process_information.hProcess, INFINITE) }; + let result = if wait == WAIT_OBJECT_0 { + let mut exit_code: u32 = 0; + // SAFETY: `hProcess` is valid and the process has signalled exit. + unsafe { GetExitCodeProcess(process_information.hProcess, &mut exit_code) } + .map(|()| exit_code) + .map_err(|e| format!("GetExitCodeProcess failed: {e}")) + } else if wait == WAIT_FAILED { + // SAFETY: reads the last-error value set by WaitForSingleObject. + let err = unsafe { windows::Win32::Foundation::GetLastError() }; + Err(format!( + "WaitForSingleObject failed (GetLastError = {})", + err.0 + )) + } else { + Err(format!( + "WaitForSingleObject returned unexpected status: {wait:?}" + )) + }; + + // SAFETY: both handles were returned by the launch and are not used again. + unsafe { + let _ = CloseHandle(process_information.hThread); + let _ = CloseHandle(process_information.hProcess); + } + result + } +} diff --git a/src/backends/learning_mode/windows/examples/lm_probe.rs b/src/backends/learning_mode/windows/examples/lm_probe.rs new file mode 100644 index 000000000..2eadf5b0b --- /dev/null +++ b/src/backends/learning_mode/windows/examples/lm_probe.rs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Manual validation probe for the Learning Mode trace + security-environment API. +//! +//! Prints whether `processmodel.dll` on this machine exposes the Learning Mode trace +//! exports (`StartLearningModeTrace` / `StopLearningModeTrace`) and the 2-phase +//! security-environment exports (`CreateProcessSecurityEnvironment` / +//! `CreateProcessAsUserInsideSecurityEnvironment` / `CloseProcessSecurityEnvironment`), +//! reporting the exact resolved name for each (plain vs `Experimental_`). Intended to +//! be run on a feature-enabled Windows build to confirm the runtime FFI resolves +//! against the real API. +//! +//! ```text +//! cargo run -p learning_mode_windows --example lm_probe +//! ``` + +fn main() { + std::process::exit(run_probe()); +} + +#[cfg(target_os = "windows")] +fn run_probe() -> i32 { + let learning_mode_available = learning_mode_windows::is_learning_mode_api_available(); + println!("is_learning_mode_api_available = {learning_mode_available}"); + + match learning_mode_windows::LearningModeApi::load() { + Ok(api) => println!("LearningModeApi::load = OK ({api:?})"), + Err(e) => println!("LearningModeApi::load = ERR ({e})"), + } + + let secenv_available = learning_mode_windows::is_security_environment_api_available(); + println!("is_security_environment_api_available = {secenv_available}"); + + let report = learning_mode_windows::probe_security_environment_exports(); + println!(" create export = {:?}", report.create); + println!(" launch export = {:?}", report.launch); + println!(" close export = {:?}", report.close); + + match learning_mode_windows::SecurityEnvironmentApi::load() { + Ok(api) => println!("SecurityEnvironmentApi::load = OK ({api:?})"), + Err(e) => println!("SecurityEnvironmentApi::load = ERR ({e})"), + } + + if learning_mode_available && secenv_available { + 0 + } else { + 2 + } +} + +#[cfg(not(target_os = "windows"))] +fn run_probe() -> i32 { + println!("is_learning_mode_api_available = false"); + 2 +} diff --git a/src/backends/learning_mode/windows/src/ffi.rs b/src/backends/learning_mode/windows/src/ffi.rs new file mode 100644 index 000000000..3b32288de --- /dev/null +++ b/src/backends/learning_mode/windows/src/ffi.rs @@ -0,0 +1,292 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Windows runtime FFI for the `processmodel.dll` Learning Mode trace exports. +//! +//! The two exports are resolved once via `LoadLibraryExW(LOAD_LIBRARY_SEARCH_SYSTEM32)` +//! and `GetProcAddress`. As with the sibling `Experimental_CreateProcessInSandbox` +//! adapter, `processmodel.dll` is intentionally never freed: it is a system DLL that +//! stays resident for the process lifetime, so the module handle is used only to +//! resolve exports and then dropped without `FreeLibrary`. + +use std::path::Path; +use std::ptr; + +use windows::Win32::Foundation::{GetLastError, HANDLE, HMODULE}; +use windows::Win32::System::LibraryLoader::{ + GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32, +}; +use windows_core::{PCSTR, PCWSTR}; +use wxc_common::string_util; + +use crate::LearningModeError; + +/// System DLL that hosts the flat Learning Mode trace exports. +const PROCESSMODEL_DLL: &str = "processmodel.dll"; + +/// `BOOL StartLearningModeTrace(HANDLE hProcessSecurityEnvironment, HLEARNINGMODE_TRACE* pphTrace)`. +/// +/// `HLEARNINGMODE_TRACE` is a `typedef HANDLE`; the export surfaces it through the +/// out-parameter. A zero (`FALSE`) return signals failure (`GetLastError`). +type PfnStartLearningModeTrace = + unsafe extern "system" fn(process_security_environment: HANDLE, trace_out: *mut HANDLE) -> i32; + +/// `BOOL StopLearningModeTrace(HLEARNINGMODE_TRACE* pphTrace, LPCWSTR lpOutputPath)`. +/// +/// A non-null `output_path` names a file the export opens under the caller's own +/// identity; the broker seals the ETL into it. A null `output_path` discards the +/// trace. `*trace` is set to null on return regardless. +type PfnStopLearningModeTrace = + unsafe extern "system" fn(trace: *mut HANDLE, output_path: *const u16) -> i32; + +/// Opaque handle to an in-progress Learning Mode trace (`HLEARNINGMODE_TRACE`). +/// +/// Obtained from [`LearningModeApi::start_trace`] and consumed by +/// [`LearningModeApi::stop_trace`]. The handle is owned by the AppInfo broker and +/// bound to this process; if the process exits without stopping, the broker discards +/// the trace automatically. +#[derive(Debug)] +pub struct LearningModeTraceHandle(HANDLE); + +/// Resolved Learning Mode trace exports from `processmodel.dll`. +/// +/// Construct with [`LearningModeApi::load`]. Cloning is cheap (the struct holds two +/// function pointers into the resident system DLL). +#[derive(Clone, Copy)] +pub struct LearningModeApi { + start: PfnStartLearningModeTrace, + stop: PfnStopLearningModeTrace, +} + +impl std::fmt::Debug for LearningModeApi { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LearningModeApi") + .field("start", &(self.start as *const ())) + .field("stop", &(self.stop as *const ())) + .finish() + } +} + +impl LearningModeApi { + /// Load `processmodel.dll` and resolve the Learning Mode trace exports. + /// + /// # Errors + /// - [`LearningModeError::DllLoad`] if `processmodel.dll` cannot be loaded. + /// - [`LearningModeError::ExportMissing`] if either export is absent (the OS + /// build predates the API or has it gated off). + pub fn load() -> Result { + let dll = string_util::to_wide(PROCESSMODEL_DLL); + + // SAFETY: `dll` is a valid null-terminated wide string that outlives the call. + // `LOAD_LIBRARY_SEARCH_SYSTEM32` restricts the search to System32, preventing + // DLL-planting. The module handle is used only for `GetProcAddress` below and + // is never freed (the DLL stays resident for the process lifetime). Each + // resolved pointer is transmuted to a signature that matches the C + // declaration of the corresponding export exactly. + unsafe { + let hmodule = LoadLibraryExW(PCWSTR(dll.as_ptr()), None, LOAD_LIBRARY_SEARCH_SYSTEM32) + .map_err(|e| LearningModeError::DllLoad(e.to_string()))?; + + let start_proc = resolve_export(hmodule, c"StartLearningModeTrace")?; + let stop_proc = resolve_export(hmodule, c"StopLearningModeTrace")?; + + let start: PfnStartLearningModeTrace = std::mem::transmute(start_proc); + let stop: PfnStopLearningModeTrace = std::mem::transmute(stop_proc); + + Ok(Self { start, stop }) + } + } + + /// Start a Learning Mode trace for the sandbox identified by + /// `security_environment`. + /// + /// # Safety + /// `security_environment` must be a live `HPROCESS_SECURITY_ENVIRONMENT` handle + /// obtained from the sandbox launch path; the broker resolves it to the target + /// AppContainer SID server-side. + /// + /// # Errors + /// [`LearningModeError::ApiCall`] carrying `GetLastError` if the export returns + /// `FALSE`. + pub unsafe fn start_trace( + &self, + security_environment: HANDLE, + ) -> Result { + let mut trace = HANDLE(ptr::null_mut()); + // SAFETY: `self.start` was resolved from `processmodel.dll` and matches the + // declared C signature; `trace` is a valid out-pointer. The caller upholds + // the validity of `security_environment` per this method's safety contract. + let ok = (self.start)(security_environment, &mut trace); + if ok == 0 { + return Err(LearningModeError::ApiCall { + function: "StartLearningModeTrace", + code: last_error(), + }); + } + Ok(LearningModeTraceHandle(trace)) + } + + /// Stop `trace`, sealing the ETL into `output_path`. Passing `None` discards the + /// trace (used for early-exit teardown). + /// + /// The handle is consumed; the export nulls it internally on return. + /// + /// # Errors + /// - [`LearningModeError::InvalidInput`] if `output_path` contains an embedded NUL. + /// - [`LearningModeError::ApiCall`] carrying `GetLastError` if the export returns + /// `FALSE`. + /// - [`LearningModeError::CleanupFailed`] if rejecting an invalid path also fails + /// to discard the live trace. + pub fn stop_trace( + &self, + trace: LearningModeTraceHandle, + output_path: Option<&Path>, + ) -> Result<(), LearningModeError> { + let wide_path = match encode_output_path(output_path) { + Ok(path) => path, + Err(primary) => return Err(self.discard_trace_after_error(trace, primary)), + }; + self.stop_trace_encoded(trace, wide_path.as_deref()) + } + + fn discard_trace_after_error( + &self, + trace: LearningModeTraceHandle, + primary: LearningModeError, + ) -> LearningModeError { + match self.stop_trace_encoded(trace, None) { + Ok(()) => primary, + Err(cleanup) => LearningModeError::CleanupFailed { + primary: Box::new(primary), + cleanup: Box::new(cleanup), + }, + } + } + + fn stop_trace_encoded( + &self, + trace: LearningModeTraceHandle, + wide_path: Option<&[u16]>, + ) -> Result<(), LearningModeError> { + let path_ptr = wide_path.map_or(ptr::null(), |path| path.as_ptr()); + let mut handle = trace.0; + + // SAFETY: `self.stop` was resolved from `processmodel.dll` and matches the + // declared C signature. `handle` came from a prior `start_trace`, and + // `path_ptr` is either null or points at the null-terminated `wide_path` + // buffer, which outlives the call. + let ok = unsafe { (self.stop)(&mut handle, path_ptr) }; + if ok == 0 { + return Err(LearningModeError::ApiCall { + function: "StopLearningModeTrace", + code: last_error(), + }); + } + Ok(()) + } +} + +fn encode_output_path(output_path: Option<&Path>) -> Result>, LearningModeError> { + output_path + .map(|path| { + string_util::os_str_to_wide(path.as_os_str()).map_err(|_| { + LearningModeError::InvalidInput { + parameter: "output_path", + detail: "path contains an embedded NUL".to_string(), + } + }) + }) + .transpose() +} + +/// Resolve a single export from an already-loaded module, mapping a missing symbol +/// to [`LearningModeError::ExportMissing`]. +/// +/// # Safety +/// `hmodule` must be a valid module handle. +unsafe fn resolve_export( + hmodule: HMODULE, + name: &'static std::ffi::CStr, +) -> Result isize, LearningModeError> { + // SAFETY: `name` is a valid null-terminated C string; `hmodule` is valid per the + // caller's contract. + match GetProcAddress(hmodule, PCSTR(name.as_ptr().cast())) { + Some(proc) => Ok(proc), + None => Err(LearningModeError::ExportMissing { + api: "Learning Mode trace", + export: name.to_str().unwrap_or(""), + detail: format!( + "GetProcAddress returned NULL (GetLastError = {})", + last_error() + ), + }), + } +} + +/// Capture `GetLastError` as a plain `u32`. +fn last_error() -> u32 { + // SAFETY: `GetLastError` has no preconditions and no side effects beyond reading + // the calling thread's last-error slot. + unsafe { GetLastError().0 } +} + +/// Capability probe: `true` only when `processmodel.dll` exposes both Learning Mode +/// trace exports on this machine. +#[must_use] +pub fn is_learning_mode_api_available() -> bool { + LearningModeApi::load().is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use std::path::PathBuf; + + #[test] + fn probe_does_not_panic_and_matches_load() { + // On a non-feature OS build the exports are absent and both return + // false/Err; on a feature build both are true/Ok. Either way the probe must + // agree with `load()` and never panic. + let available = is_learning_mode_api_available(); + assert_eq!(available, LearningModeApi::load().is_ok()); + } + + #[test] + fn load_failure_is_graceful_when_api_absent() { + // Where the API is unavailable, `load()` must return a typed error rather + // than panicking. Where it is available this is vacuously satisfied. + match LearningModeApi::load() { + Ok(api) => { + // Smoke: the resolved struct is Debug-formattable. + let _ = format!("{api:?}"); + } + Err(e) => { + let msg = e.to_string(); + assert!( + matches!( + e, + LearningModeError::DllLoad(_) | LearningModeError::ExportMissing { .. } + ), + "unexpected error variant: {msg}" + ); + } + } + } + + #[test] + fn output_path_rejects_embedded_nul() { + let path = PathBuf::from(OsString::from_wide(&['a' as u16, 0, 'b' as u16])); + + let error = encode_output_path(Some(&path)).expect_err("embedded NUL must be rejected"); + + assert!(matches!( + error, + LearningModeError::InvalidInput { + parameter: "output_path", + .. + } + )); + } +} diff --git a/src/backends/learning_mode/windows/src/lib.rs b/src/backends/learning_mode/windows/src/lib.rs new file mode 100644 index 000000000..8bf6c86d7 --- /dev/null +++ b/src/backends/learning_mode/windows/src/lib.rs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `learning_mode_windows` — runtime FFI adapter for the Windows AppInfo-brokered +//! **Learning Mode trace API** exported by `processmodel.dll`. +//! +//! Supported Windows builds expose a privileged, per-client learning-mode +//! ETW trace behind two flat C exports in `processmodel.dll` — the same system DLL +//! the BaseContainer backend already loads for `Experimental_CreateProcessInSandbox`: +//! +//! ```c +//! BOOL StartLearningModeTrace(HANDLE hProcessSecurityEnvironment, HLEARNINGMODE_TRACE* pphTrace); +//! BOOL StopLearningModeTrace (HLEARNINGMODE_TRACE* pphTrace, LPCWSTR lpOutputPath); +//! ``` +//! +//! The broker collects and filters the trace to the caller's user SID and the +//! sandbox identified by the supplied security-environment handle, then — on stop — +//! writes the sealed ETL into a caller-named `outputPath` (opened under the caller's +//! own identity to avoid a confused-deputy). There is **no real-time event access**; +//! denials are read from the ETL after the sandboxed process exits. +//! +//! Because the exports only exist on feature-enabled OS builds, this crate resolves +//! them at runtime via `LoadLibrary`/`GetProcAddress` behind the [`is_learning_mode_api_available`] +//! capability probe, mirroring the existing `Experimental_CreateProcessInSandbox` +//! adapter. The crate compiles on every platform: the capability probe returns +//! `false` on non-Windows targets, while the loader and capture lifecycle types are +//! exported only on Windows. + +use thiserror::Error; + +#[cfg(target_os = "windows")] +mod ffi; +#[cfg(target_os = "windows")] +mod lifecycle; +#[cfg(target_os = "windows")] +mod secenv; + +#[cfg(target_os = "windows")] +pub use ffi::{is_learning_mode_api_available, LearningModeApi, LearningModeTraceHandle}; +#[cfg(target_os = "windows")] +pub use lifecycle::CaptureSession; +#[cfg(target_os = "windows")] +pub use secenv::{ + is_security_environment_api_available, probe_security_environment_exports, + ProcessSecurityEnvironment, SecurityEnvironmentApi, SecurityEnvironmentExportReport, + PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE, +}; + +/// Errors surfaced while loading or invoking the Learning Mode trace API. +#[derive(Debug, Error)] +pub enum LearningModeError { + /// `processmodel.dll` itself could not be loaded from System32. + #[error("failed to load processmodel.dll: {0}")] + DllLoad(String), + + /// `processmodel.dll` loaded, but a required export is missing from the + /// named API surface. + #[error("export `{export}` not found in processmodel.dll ({detail}); this OS build lacks the required {api} API")] + ExportMissing { + /// The API surface that requires the export. + api: &'static str, + /// The undecorated export name that failed to resolve. + export: &'static str, + /// Additional diagnostic detail (e.g. the `GetLastError` code). + detail: String, + }, + + /// An API call returned `FALSE`; `code` is the captured `GetLastError` value. + #[error("{function} failed (GetLastError = {code})")] + ApiCall { + /// The name of the export that returned failure. + function: &'static str, + /// The `GetLastError` value captured immediately after the failed call. + code: u32, + }, + + /// A caller-provided value cannot be represented safely for the API call. + #[error("invalid {parameter}: {detail}")] + InvalidInput { + /// The invalid parameter. + parameter: &'static str, + /// Why the value is invalid. + detail: String, + }, + + /// A primary operation failed and the subsequent cleanup operation also failed. + #[error("{primary}; cleanup also failed: {cleanup}")] + CleanupFailed { + /// The error that triggered cleanup. + primary: Box, + /// The error returned while attempting cleanup. + cleanup: Box, + }, +} + +/// Capability probe: `true` only when `processmodel.dll` exposes the Learning Mode +/// trace exports on this machine. Always `false` on non-Windows targets. +#[cfg(not(target_os = "windows"))] +#[must_use] +pub fn is_learning_mode_api_available() -> bool { + false +} + +#[cfg(all(test, not(target_os = "windows")))] +mod stub_tests { + use super::*; + + #[test] + fn probe_is_false_off_windows() { + assert!(!is_learning_mode_api_available()); + } + + #[test] + fn error_messages_are_actionable() { + let e = LearningModeError::ExportMissing { + api: "Learning Mode trace", + export: "StartLearningModeTrace", + detail: "GetLastError = 127".to_string(), + }; + let msg = e.to_string(); + assert!(msg.contains("StartLearningModeTrace")); + assert!(msg.contains("Learning Mode trace API")); + } +} + +#[cfg(test)] +mod error_tests { + use super::*; + + #[test] + fn cleanup_error_preserves_both_failures() { + let error = LearningModeError::CleanupFailed { + primary: Box::new(LearningModeError::ApiCall { + function: "StartLearningModeTrace", + code: 5, + }), + cleanup: Box::new(LearningModeError::ApiCall { + function: "CloseProcessSecurityEnvironment", + code: 6, + }), + }; + + let message = error.to_string(); + assert!(message.contains("StartLearningModeTrace")); + assert!(message.contains("CloseProcessSecurityEnvironment")); + } + + #[test] + fn missing_export_identifies_the_api_surface() { + let error = LearningModeError::ExportMissing { + api: "process security-environment", + export: "CreateProcessSecurityEnvironment", + detail: "GetLastError = 127".to_string(), + }; + + let message = error.to_string(); + assert!(message.contains("process security-environment API")); + assert!(!message.contains("lacks the Learning Mode trace API")); + } +} diff --git a/src/backends/learning_mode/windows/src/lifecycle.rs b/src/backends/learning_mode/windows/src/lifecycle.rs new file mode 100644 index 000000000..fd0e7f9bb --- /dev/null +++ b/src/backends/learning_mode/windows/src/lifecycle.rs @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! RAII lifecycle for a Learning Mode capture: create a process security environment, +//! start a trace against it, hand the environment to the runner to launch the child, +//! then — after the child exits — seal the ETL and tear the environment down. +//! +//! The ordering the OS requires is: +//! +//! 1. `CreateProcessSecurityEnvironment(spec)` → env handle +//! 2. `StartLearningModeTrace(env)` → trace handle (**before** the child launches, so no +//! early denials are missed) +//! 3. `CreateProcessAsUserInsideSecurityEnvironment(env, …)` → child (**runner's job**; +//! the session exposes the env handle for it via [`CaptureSession::environment`]) +//! 4. wait for the child to exit +//! 5. `StopLearningModeTrace(trace, outputPath)` → sealed ETL (NULL path discards) +//! 6. `CloseProcessSecurityEnvironment(env)` → teardown +//! +//! [`CaptureSession::begin`] performs steps 1–2; the runner performs steps 3–4 with the +//! handle from [`CaptureSession::environment`]; [`CaptureSession::finish`] performs steps +//! 5–6 in order. If the session is dropped without `finish` (e.g. the launch failed or a +//! `?` unwound the stack), [`Drop`] runs a best-effort teardown — discard the trace, then +//! close the environment — so no broker-side trace or environment is leaked. + +use std::path::Path; + +use windows::Win32::Foundation::HANDLE; + +use crate::ffi::{LearningModeApi, LearningModeTraceHandle}; +use crate::secenv::{ProcessSecurityEnvironment, SecurityEnvironmentApi}; +use crate::LearningModeError; + +/// An in-flight Learning Mode capture: a live security environment with a trace already +/// started against it. +/// +/// Construct with [`CaptureSession::begin`]; drive the child launch with the handle from +/// [`CaptureSession::environment`]; seal and tear down with [`CaptureSession::finish`]. +/// Dropping without `finish` discards the trace and closes the environment on a +/// best-effort basis. +#[derive(Debug)] +pub struct CaptureSession { + secenv_api: SecurityEnvironmentApi, + learning_mode_api: LearningModeApi, + /// `Some` until `finish`/`Drop` closes it. + environment: Option, + /// `Some` until `finish`/`Drop` seals or discards it. + trace: Option, +} + +impl CaptureSession { + /// Create a security environment from `sandbox_specification` and start a Learning + /// Mode trace against it. Call **before** launching the child. + /// + /// `flags` is normally [`crate::PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE`]. + /// + /// # Errors + /// - [`LearningModeError::ApiCall`] if `CreateProcessSecurityEnvironment` fails. + /// - [`LearningModeError::ApiCall`] if `StartLearningModeTrace` fails — in which case + /// the just-created environment is closed before returning so it is not leaked. + /// - [`LearningModeError::CleanupFailed`] if starting the trace fails and closing + /// the just-created environment also fails. + pub fn begin( + secenv_api: SecurityEnvironmentApi, + learning_mode_api: LearningModeApi, + sandbox_specification: &[u8], + flags: u32, + ) -> Result { + let mut environment = secenv_api.create(sandbox_specification, flags)?; + + // SAFETY: `environment` was just created by `secenv_api.create` and is live for + // the duration of this call; `start_trace` only reads it. + let trace = match unsafe { learning_mode_api.start_trace(environment.raw()) } { + Ok(trace) => trace, + Err(start_err) => { + return match secenv_api.close(&mut environment) { + Ok(()) => Err(start_err), + Err(cleanup) => Err(LearningModeError::CleanupFailed { + primary: Box::new(start_err), + cleanup: Box::new(cleanup), + }), + }; + } + }; + + Ok(Self { + secenv_api, + learning_mode_api, + environment: Some(environment), + trace: Some(trace), + }) + } + + /// The `HPROCESS_SECURITY_ENVIRONMENT` handle to pass to + /// `CreateProcessAsUserInsideSecurityEnvironment`. + /// + /// # Panics + /// Panics only on an internal invariant violation — the environment is present for + /// the entire session lifetime (set by [`begin`](Self::begin), taken only by + /// [`finish`](Self::finish), which consumes `self`, or by [`Drop`]), so a live + /// `&self` here always holds one. Failing fast surfaces a misuse at the call site + /// rather than silently handing a NULL handle to a Win32 API. + #[must_use] + pub fn environment(&self) -> HANDLE { + match self.environment.as_ref() { + Some(env) => env.raw(), + None => { + panic!("CaptureSession::environment called after the environment was torn down") + } + } + } + + /// Seal the trace to `output_path` (or discard it when `None`), then close the + /// security environment. Call **after** the child has exited. + /// + /// Both teardown steps are attempted even if the first fails. If both fail, + /// [`LearningModeError::CleanupFailed`] preserves both errors. + /// + /// # Errors + /// - [`LearningModeError::ApiCall`] from `StopLearningModeTrace` or + /// `CloseProcessSecurityEnvironment`. + /// - [`LearningModeError::CleanupFailed`] if both teardown calls fail. + pub fn finish(mut self, output_path: Option<&Path>) -> Result<(), LearningModeError> { + let stop_result = match self.trace.take() { + Some(trace) => self.learning_mode_api.stop_trace(trace, output_path), + None => Ok(()), + }; + let close_result = match self.environment.as_mut() { + Some(environment) => self.secenv_api.close(environment), + None => Ok(()), + }; + if close_result.is_ok() { + self.environment.take(); + } + combine_teardown_results(stop_result, close_result) + } +} + +fn combine_teardown_results( + stop_result: Result<(), LearningModeError>, + close_result: Result<(), LearningModeError>, +) -> Result<(), LearningModeError> { + match (stop_result, close_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), + (Err(primary), Err(cleanup)) => Err(LearningModeError::CleanupFailed { + primary: Box::new(primary), + cleanup: Box::new(cleanup), + }), + } +} + +impl Drop for CaptureSession { + fn drop(&mut self) { + // Best-effort teardown for the early-exit / unwind path: discard the trace + // (NULL output path) before closing the environment. Errors are unrecoverable + // here and are intentionally ignored — `finish` is the fallible path. + if let Some(trace) = self.trace.take() { + let _ = self.learning_mode_api.stop_trace(trace, None); + } + if let Some(environment) = self.environment.take() { + drop(environment); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn api_error(function: &'static str, code: u32) -> LearningModeError { + LearningModeError::ApiCall { function, code } + } + + #[test] + fn teardown_preserves_both_failures() { + let result = combine_teardown_results( + Err(api_error("StopLearningModeTrace", 5)), + Err(api_error("CloseProcessSecurityEnvironment", 6)), + ); + + let LearningModeError::CleanupFailed { primary, cleanup } = + result.expect_err("both teardown failures must be returned") + else { + panic!("expected CleanupFailed"); + }; + assert!(primary.to_string().contains("StopLearningModeTrace")); + assert!(cleanup + .to_string() + .contains("CloseProcessSecurityEnvironment")); + } + + #[test] + fn teardown_returns_single_failure_unchanged() { + let result = + combine_teardown_results(Ok(()), Err(api_error("CloseProcessSecurityEnvironment", 6))); + + assert!(matches!( + result, + Err(LearningModeError::ApiCall { + function: "CloseProcessSecurityEnvironment", + code: 6 + }) + )); + } +} diff --git a/src/backends/learning_mode/windows/src/secenv.rs b/src/backends/learning_mode/windows/src/secenv.rs new file mode 100644 index 000000000..bf4533f2b --- /dev/null +++ b/src/backends/learning_mode/windows/src/secenv.rs @@ -0,0 +1,485 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Windows runtime FFI for the `processmodel.dll` **process security-environment** +//! exports — the 2-phase sandbox launch model that produces the +//! `HPROCESS_SECURITY_ENVIRONMENT` handle that [`crate::LearningModeApi::start_trace`] +//! keys the Learning Mode trace on. +//! +//! `StartLearningModeTrace` is keyed on a security-environment handle (the broker +//! resolves it to the target AppContainer SID server-side). Neither of MXC's existing +//! launch paths yields that handle — classic AppContainer uses `CreateProcess` + +//! `SECURITY_CAPABILITIES`, and BaseContainer uses the one-shot RPC-brokered +//! `Experimental_CreateProcessInSandbox`. To capture denials, MXC adopts the flat +//! 2-phase model exported by the same `processmodel.dll`: +//! +//! ```c +//! BOOL CreateProcessSecurityEnvironment( +//! LPCVOID sandboxSpecification, DWORD sandboxSpecificationSize, +//! PROCESS_SECURITY_ENVIRONMENT_FLAGS flags, +//! HPROCESS_SECURITY_ENVIRONMENT* processSecurityEnvironment); +//! BOOL CreateProcessAsUserInsideSecurityEnvironment( +//! HANDLE userToken, LPCWSTR lpApplicationName, LPWSTR lpCommandLine, +//! DWORD dwCreationFlags, LPCVOID lpEnvironment, LPCWSTR lpCurrentDirectory, +//! LPSTARTUPINFOW lpStartupInfo, HANDLE processSecurityEnvironment, +//! LPPROCESS_INFORMATION lpProcessInformation); +//! BOOL CloseProcessSecurityEnvironment(HPROCESS_SECURITY_ENVIRONMENT* processSecurityEnvironment); +//! ``` +//! +//! `sandboxSpecification`/`...Size` is a compiled FlatBuffer sandbox-spec blob (the +//! same `"SBOX"` format the BaseContainer runner already builds via `sandbox_spec`); +//! the spec must encode the learning-mode capability. Unlike the RPC one-shot, the +//! launch export returns a real `PROCESS_INFORMATION`, so wxc-exec owns the child +//! handle directly (stdio via `STARTUPINFOW`, wait, and job-object handling behave like +//! the classic path). `Close` tears the environment down; `Detach` (declared by the DLL +//! but not needed here) leaves the child running independently. +//! +//! As with the trace exports, each function is resolved at runtime and tolerates the +//! `Experimental_`-prefixed name as a fallback for OS builds that predate the +//! graduation out of the `Experimental_` prefix. + +use std::ffi::c_void; +use std::ptr; + +use windows::Win32::Foundation::{GetLastError, HANDLE, HMODULE}; +use windows::Win32::System::LibraryLoader::{ + GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32, +}; +use windows::Win32::System::Threading::{PROCESS_INFORMATION, STARTUPINFOW}; +use windows_core::{PCSTR, PCWSTR}; +use wxc_common::string_util; + +use crate::LearningModeError; + +/// System DLL that hosts the flat process security-environment exports. +const PROCESSMODEL_DLL: &str = "processmodel.dll"; + +/// No special behaviour when creating the security environment +/// (`PROCESS_SECURITY_ENVIRONMENT_FLAGS` value `0`). +/// +/// A `KILL_ON_CLOSE` bit exists (tears the child down when the environment closes) but +/// its numeric value is intentionally not declared here yet: explicit +/// [`SecurityEnvironmentApi::close`] after the child has exited already provides +/// deterministic teardown, so shipping code does not need to guess the flag value. +pub const PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE: u32 = 0; + +/// `BOOL CreateProcessSecurityEnvironment(LPCVOID sandboxSpecification, +/// DWORD sandboxSpecificationSize, PROCESS_SECURITY_ENVIRONMENT_FLAGS flags, +/// HPROCESS_SECURITY_ENVIRONMENT* processSecurityEnvironment)`. +/// +/// `PROCESS_SECURITY_ENVIRONMENT_FLAGS` is a C enum (`int`-sized), passed as `u32`. +type PfnCreateProcessSecurityEnvironment = unsafe extern "system" fn( + sandbox_specification: *const c_void, + sandbox_specification_size: u32, + flags: u32, + process_security_environment: *mut HANDLE, +) -> i32; + +/// `BOOL CreateProcessAsUserInsideSecurityEnvironment(HANDLE userToken, +/// LPCWSTR lpApplicationName, LPWSTR lpCommandLine, DWORD dwCreationFlags, +/// LPCVOID lpEnvironment, LPCWSTR lpCurrentDirectory, LPSTARTUPINFOW lpStartupInfo, +/// HANDLE processSecurityEnvironment, LPPROCESS_INFORMATION lpProcessInformation)`. +/// +/// `userToken`/`lpApplicationName`/`lpCommandLine`/`lpEnvironment`/`lpCurrentDirectory` +/// are optional; `lpStartupInfo`/`processSecurityEnvironment`/`lpProcessInformation` are +/// required. When `lpEnvironment` is non-null, `dwCreationFlags` must include +/// `CREATE_UNICODE_ENVIRONMENT`. +pub type PfnCreateProcessAsUserInsideSecurityEnvironment = unsafe extern "system" fn( + user_token: HANDLE, + application_name: *const u16, + command_line: *mut u16, + creation_flags: u32, + environment: *const c_void, + current_directory: *const u16, + startup_info: *const STARTUPINFOW, + process_security_environment: HANDLE, + process_information: *mut PROCESS_INFORMATION, +) -> i32; + +/// `BOOL CloseProcessSecurityEnvironment(HPROCESS_SECURITY_ENVIRONMENT* processSecurityEnvironment)`. +/// +/// The export nulls `*processSecurityEnvironment` on success. +type PfnCloseProcessSecurityEnvironment = + unsafe extern "system" fn(process_security_environment: *mut HANDLE) -> i32; + +/// Opaque handle to a process security environment (`HPROCESS_SECURITY_ENVIRONMENT`, a +/// `HANDLE`). +/// +/// Produced by [`SecurityEnvironmentApi::create`], threaded into the trace start and +/// the in-environment launch, and torn down by [`SecurityEnvironmentApi::close`]. The +/// wrapped [`HANDLE`] is passed by value to the launch/trace exports and by pointer to +/// the close export (which nulls it on success). If explicit close fails, the +/// wrapper retains ownership and retries once when dropped. +pub struct ProcessSecurityEnvironment { + handle: HANDLE, + close: PfnCloseProcessSecurityEnvironment, +} + +impl std::fmt::Debug for ProcessSecurityEnvironment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProcessSecurityEnvironment") + .field("handle", &self.handle) + .finish() + } +} + +impl ProcessSecurityEnvironment { + /// The raw `HPROCESS_SECURITY_ENVIRONMENT` handle, for passing to the trace-start + /// and in-environment launch exports. + #[must_use] + pub fn raw(&self) -> HANDLE { + self.handle + } + + fn close_with( + &mut self, + close: PfnCloseProcessSecurityEnvironment, + ) -> Result<(), LearningModeError> { + if self.handle.0.is_null() { + return Ok(()); + } + + // SAFETY: `close` was resolved from `processmodel.dll`; `self.handle` + // came from a successful create call and remains owned by this wrapper. + let ok = unsafe { close(&mut self.handle) }; + if ok == 0 { + return Err(LearningModeError::ApiCall { + function: "CloseProcessSecurityEnvironment", + code: last_error(), + }); + } + self.handle = HANDLE(ptr::null_mut()); + Ok(()) + } +} + +impl Drop for ProcessSecurityEnvironment { + fn drop(&mut self) { + let close = self.close; + let _ = self.close_with(close); + } +} + +/// Which candidate export name resolved for each function on this machine — a +/// diagnostic used by the capability probe to report the exact live surface (plain vs +/// `Experimental_`). +#[derive(Debug, Clone, Copy, Default)] +pub struct SecurityEnvironmentExportReport { + /// Resolved name of the create export, if present. + pub create: Option<&'static str>, + /// Resolved name of the in-environment launch export, if present. + pub launch: Option<&'static str>, + /// Resolved name of the close export, if present. + pub close: Option<&'static str>, +} + +impl SecurityEnvironmentExportReport { + /// `true` only when every export required for the 2-phase launch resolved. + #[must_use] + pub fn is_complete(&self) -> bool { + self.create.is_some() && self.launch.is_some() && self.close.is_some() + } +} + +/// Candidate names for each export: the graduated (plain) name is preferred, with the +/// `Experimental_`-prefixed name kept as a fallback for older feature builds. +const CREATE_NAMES: &[&core::ffi::CStr] = &[ + c"CreateProcessSecurityEnvironment", + c"Experimental_CreateProcessSecurityEnvironment", +]; +const LAUNCH_NAMES: &[&core::ffi::CStr] = &[ + c"CreateProcessAsUserInsideSecurityEnvironment", + c"Experimental_CreateProcessAsUserInsideSecurityEnvironment", +]; +const CLOSE_NAMES: &[&core::ffi::CStr] = &[ + c"CloseProcessSecurityEnvironment", + c"Experimental_CloseProcessSecurityEnvironment", +]; + +/// Resolved process security-environment exports from `processmodel.dll`. +#[derive(Clone, Copy)] +pub struct SecurityEnvironmentApi { + create: PfnCreateProcessSecurityEnvironment, + launch: PfnCreateProcessAsUserInsideSecurityEnvironment, + close: PfnCloseProcessSecurityEnvironment, +} + +impl std::fmt::Debug for SecurityEnvironmentApi { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SecurityEnvironmentApi") + .field("create", &(self.create as *const ())) + .field("launch", &(self.launch as *const ())) + .field("close", &(self.close as *const ())) + .finish() + } +} + +impl SecurityEnvironmentApi { + /// Load `processmodel.dll` and resolve the 2-phase security-environment exports. + /// + /// # Errors + /// - [`LearningModeError::DllLoad`] if `processmodel.dll` cannot be loaded. + /// - [`LearningModeError::ExportMissing`] if any required export is absent under + /// either its plain or `Experimental_`-prefixed name. + pub fn load() -> Result { + let dll = string_util::to_wide(PROCESSMODEL_DLL); + + // SAFETY: `dll` is a valid null-terminated wide string that outlives the call. + // `LOAD_LIBRARY_SEARCH_SYSTEM32` restricts the search to System32. The module + // handle is used only for `GetProcAddress` and is never freed (the DLL stays + // resident). Each resolved pointer is transmuted to a signature matching the C + // declaration of the corresponding export exactly. + unsafe { + let hmodule = LoadLibraryExW(PCWSTR(dll.as_ptr()), None, LOAD_LIBRARY_SEARCH_SYSTEM32) + .map_err(|e| LearningModeError::DllLoad(e.to_string()))?; + + let create_proc = resolve_any(hmodule, CREATE_NAMES)?; + let launch_proc = resolve_any(hmodule, LAUNCH_NAMES)?; + let close_proc = resolve_any(hmodule, CLOSE_NAMES)?; + + Ok(Self { + create: std::mem::transmute::< + unsafe extern "system" fn() -> isize, + PfnCreateProcessSecurityEnvironment, + >(create_proc), + launch: std::mem::transmute::< + unsafe extern "system" fn() -> isize, + PfnCreateProcessAsUserInsideSecurityEnvironment, + >(launch_proc), + close: std::mem::transmute::< + unsafe extern "system" fn() -> isize, + PfnCloseProcessSecurityEnvironment, + >(close_proc), + }) + } + } + + /// Create a process security environment from a compiled FlatBuffer sandbox-spec + /// blob. `flags` is currently always [`PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE`]. + /// + /// # Errors + /// [`LearningModeError::ApiCall`] carrying `GetLastError` if the export returns + /// `FALSE` (including a spec larger than `u32::MAX`, reported as + /// `ERROR_INVALID_PARAMETER`). + pub fn create( + &self, + sandbox_specification: &[u8], + flags: u32, + ) -> Result { + let mut env = HANDLE(ptr::null_mut()); + let spec_len = + u32::try_from(sandbox_specification.len()).map_err(|_| LearningModeError::ApiCall { + function: "CreateProcessSecurityEnvironment", + code: windows::Win32::Foundation::ERROR_INVALID_PARAMETER.0, + })?; + + // SAFETY: `self.create` was resolved from `processmodel.dll` and matches the + // declared C signature. `sandbox_specification`/`spec_len` describe a valid, + // contiguous byte buffer that outlives the call, and `env` is a valid + // out-pointer. + let ok = unsafe { + (self.create)( + sandbox_specification.as_ptr().cast(), + spec_len, + flags, + &mut env, + ) + }; + if ok == 0 { + return Err(LearningModeError::ApiCall { + function: "CreateProcessSecurityEnvironment", + code: last_error(), + }); + } + Ok(ProcessSecurityEnvironment { + handle: env, + close: self.close, + }) + } + + /// Close a process security environment, tearing down its server-side state and + /// (per the create flags) the child. The export nulls the handle on success. + /// On failure, `env` retains ownership so the caller can retry; its [`Drop`] + /// implementation also makes one best-effort retry. + /// + /// # Errors + /// [`LearningModeError::ApiCall`] carrying `GetLastError` if the export returns + /// `FALSE`. + pub fn close(&self, env: &mut ProcessSecurityEnvironment) -> Result<(), LearningModeError> { + env.close_with(self.close) + } + + /// The resolved in-environment launch export. + /// + /// The stdio/wait/job-object orchestration around + /// `CreateProcessAsUserInsideSecurityEnvironment` belongs to the runner, so the raw + /// function pointer is exposed rather than a fully-wrapped launch here. The runner + /// supplies the `STARTUPINFOW`, receives the real `PROCESS_INFORMATION`, and owns + /// the returned handles. Callers must pass the environment handle from + /// [`ProcessSecurityEnvironment::raw`], and — when supplying an environment block — + /// include `CREATE_UNICODE_ENVIRONMENT` in the creation flags. + #[must_use] + pub fn launch_fn(&self) -> PfnCreateProcessAsUserInsideSecurityEnvironment { + self.launch + } +} + +/// Resolve the first name in `names` that is present in `hmodule`. +/// +/// # Safety +/// `hmodule` must be a valid module handle. +unsafe fn resolve_any( + hmodule: HMODULE, + names: &[&'static core::ffi::CStr], +) -> Result isize, LearningModeError> { + let mut last_detail = String::new(); + for name in names { + // SAFETY: `name` is a valid null-terminated C string; `hmodule` is valid per + // the caller's contract. + if let Some(proc) = unsafe { GetProcAddress(hmodule, PCSTR(name.as_ptr().cast())) } { + return Ok(proc); + } + last_detail = format!( + "GetProcAddress returned NULL (GetLastError = {})", + last_error() + ); + } + Err(LearningModeError::ExportMissing { + api: "process security-environment", + export: names + .first() + .and_then(|n| n.to_str().ok()) + .unwrap_or(""), + detail: last_detail, + }) +} + +/// Capture `GetLastError` as a plain `u32`. +fn last_error() -> u32 { + // SAFETY: `GetLastError` has no preconditions and no side effects beyond reading + // the calling thread's last-error slot. + unsafe { GetLastError().0 } +} + +/// Diagnostic probe reporting which security-environment export name resolved for each +/// function (plain vs `Experimental_`). Returns an all-`None` report if the DLL itself +/// cannot be loaded. +#[must_use] +pub fn probe_security_environment_exports() -> SecurityEnvironmentExportReport { + let dll = string_util::to_wide(PROCESSMODEL_DLL); + // SAFETY: `dll` is a valid null-terminated wide string that outlives the call; + // `LOAD_LIBRARY_SEARCH_SYSTEM32` restricts the search to System32. + let hmodule = + match unsafe { LoadLibraryExW(PCWSTR(dll.as_ptr()), None, LOAD_LIBRARY_SEARCH_SYSTEM32) } { + Ok(h) => h, + Err(_) => return SecurityEnvironmentExportReport::default(), + }; + + // SAFETY: `hmodule` is valid; `first_present` only reads exports. + unsafe { + SecurityEnvironmentExportReport { + create: first_present(hmodule, CREATE_NAMES), + launch: first_present(hmodule, LAUNCH_NAMES), + close: first_present(hmodule, CLOSE_NAMES), + } + } +} + +/// Return the first candidate name that resolves in `hmodule`, or `None`. +/// +/// # Safety +/// `hmodule` must be a valid module handle. +unsafe fn first_present( + hmodule: HMODULE, + names: &[&'static core::ffi::CStr], +) -> Option<&'static str> { + for name in names { + // SAFETY: `name` is a valid null-terminated C string; `hmodule` is valid. + if unsafe { GetProcAddress(hmodule, PCSTR(name.as_ptr().cast())) }.is_some() { + return name.to_str().ok(); + } + } + None +} + +/// Capability probe: `true` only when `processmodel.dll` exposes every export required +/// for the 2-phase security-environment launch on this machine. +#[must_use] +pub fn is_security_environment_api_available() -> bool { + probe_security_environment_exports().is_complete() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static CLOSE_CALLS: AtomicUsize = AtomicUsize::new(0); + + unsafe extern "system" fn close_fails_then_succeeds(handle: *mut HANDLE) -> i32 { + if CLOSE_CALLS.fetch_add(1, Ordering::SeqCst) == 0 { + 0 + } else { + // SAFETY: the test passes a valid pointer to its owned HANDLE. + unsafe { + *handle = HANDLE(ptr::null_mut()); + } + 1 + } + } + + #[test] + fn probe_does_not_panic_and_agrees_with_load() { + let report = probe_security_environment_exports(); + assert_eq!(report.is_complete(), SecurityEnvironmentApi::load().is_ok()); + assert_eq!( + report.is_complete(), + is_security_environment_api_available() + ); + } + + #[test] + fn load_failure_is_graceful_when_api_absent() { + match SecurityEnvironmentApi::load() { + Ok(api) => { + let _ = format!("{api:?}"); + } + Err(e) => assert!( + matches!( + e, + LearningModeError::DllLoad(_) | LearningModeError::ExportMissing { .. } + ), + "unexpected error variant: {e}" + ), + } + } + + #[test] + fn flag_none_is_zero() { + assert_eq!(PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE, 0); + } + + #[test] + fn failed_close_retains_ownership_and_drop_retries() { + CLOSE_CALLS.store(0, Ordering::SeqCst); + let mut environment = ProcessSecurityEnvironment { + handle: HANDLE(std::ptr::dangling_mut::()), + close: close_fails_then_succeeds, + }; + + let error = environment + .close_with(close_fails_then_succeeds) + .expect_err("first close must fail"); + assert!(matches!( + error, + LearningModeError::ApiCall { + function: "CloseProcessSecurityEnvironment", + .. + } + )); + assert!(!environment.raw().0.is_null()); + + drop(environment); + assert_eq!(CLOSE_CALLS.load(Ordering::SeqCst), 2); + } +} diff --git a/src/core/wxc_common/src/string_util.rs b/src/core/wxc_common/src/string_util.rs index b525ee9fa..0ddf62204 100644 --- a/src/core/wxc_common/src/string_util.rs +++ b/src/core/wxc_common/src/string_util.rs @@ -2,6 +2,8 @@ // Licensed under the MIT License. use base64::{engine::general_purpose::STANDARD, Engine as _}; +use std::ffi::OsStr; +use std::os::windows::ffi::OsStrExt; use widestring::{U16CString, U16Str}; use windows::Win32::Foundation::{LocalFree, HLOCAL}; use windows::Win32::Security::Authorization::ConvertSidToStringSidW; @@ -12,6 +14,33 @@ pub fn to_wide(s: &str) -> Vec { U16CString::from_str_truncate(s).into_vec_with_nul() } +/// Error returned when an OS string contains an embedded NUL and therefore +/// cannot be passed as a single null-terminated Win32 string. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EmbeddedNulError; + +impl std::fmt::Display for EmbeddedNulError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("OS string contains an embedded NUL") + } +} + +impl std::error::Error for EmbeddedNulError {} + +/// Convert an OS string losslessly to null-terminated UTF-16. +/// +/// Unlike [`to_wide`], this preserves Windows `OsStr` data that is not valid +/// Unicode. Embedded NULs are rejected rather than silently truncating the +/// value seen by Win32. +pub fn os_str_to_wide(value: &OsStr) -> Result, EmbeddedNulError> { + let mut wide = value.encode_wide().collect::>(); + if wide.contains(&0) { + return Err(EmbeddedNulError); + } + wide.push(0); + Ok(wide) +} + /// Convert a UTF-16 slice to a UTF-8 String, stopping at the first null terminator if present. pub fn from_wide(wide: &[u16]) -> String { let len = wide.iter().position(|&c| c == 0).unwrap_or(wide.len()); @@ -115,6 +144,8 @@ impl Drop for CoTaskMemPWSTR { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; // ========== Base64 Encode ========== @@ -279,6 +310,23 @@ mod tests { assert_eq!(s, "Line1\nLine2\tTabbed"); } + #[test] + fn os_str_to_wide_preserves_non_unicode_units() { + let units = [b'a' as u16, 0xD800, b'b' as u16]; + let value = OsString::from_wide(&units); + + let wide = os_str_to_wide(&value).expect("non-Unicode units are preserved"); + + assert_eq!(wide, [units[0], units[1], units[2], 0]); + } + + #[test] + fn os_str_to_wide_rejects_embedded_nul() { + let value = OsString::from_wide(&[b'a' as u16, 0, b'b' as u16]); + + assert_eq!(os_str_to_wide(&value), Err(EmbeddedNulError)); + } + #[test] fn from_wide_empty_string() { let wide: Vec = vec![0u16];