diff --git a/.cargo/config.toml b/.cargo/config.toml index 78cd64f..715c5c5 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,9 @@ [env] # Allocation accounting is unused; its global mutex serializes SQLite callers. LIBSQLITE3_FLAGS = "-DSQLITE_DEFAULT_MEMSTATUS=0" + +# Link the C runtime statically on Windows, so no image imports a C runtime +# DLL. The confined worker should load only the system's own DLLs, and the +# dynamic C runtime is a separately installed component. +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..52e3f56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Check text out with LF on every OS. Tests read docs, fixtures and digest +# test vectors byte for byte, and a CRLF checkout on Windows would change them. +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c25e81..292d9b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -386,6 +386,42 @@ jobs: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('Cargo.lock') }}-${{ steps.rust.outputs.version }}-windows-baseline- - name: Prepare the log directory run: mkdir -p "$RUNNER_TEMP/windows-baseline" + # The Windows launcher's own tests start real confined children, so + # they can only run here. The deviations feature adds the test-only + # launch variants, one per confinement check. They run first and print + # what the parent read back, so the log shows it even when the + # workspace steps below fail. + - name: Test the Windows launcher with its test variants + id: test-launch + continue-on-error: true + timeout-minutes: 20 + run: cargo test -p basal-launch --features deviations --locked -- --show-output 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-launch.log" + # The worker's tests on their own: its Windows startup checks, its image + # and a real activation, each worker started through the launcher. The + # plain build is the production worker, which must refuse the + # launcher's request to skip a check. The deviations build is a test + # worker that honours that request, for the three checks the parent + # cannot break from outside. + - name: Test the worker on its own + id: test-worker + continue-on-error: true + timeout-minutes: 30 + run: cargo test -p basal-worker --locked --no-fail-fast -- --show-output 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-worker.log" + - name: Test the worker with its test variants + id: test-worker-deviations + continue-on-error: true + timeout-minutes: 30 + run: cargo test -p basal-worker --features deviations --locked --no-fail-fast -- --show-output 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-worker-deviations.log" + - name: Test the shared test kit on its own + id: test-testkit + continue-on-error: true + timeout-minutes: 30 + run: cargo test -p basal-testkit --locked --no-fail-fast 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-testkit.log" + - name: Test the integration rig on its own + id: test-rig + continue-on-error: true + timeout-minutes: 30 + run: cargo test -p basal-rig --locked --no-fail-fast 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-rig.log" - name: Check every workspace target id: check continue-on-error: true @@ -406,6 +442,13 @@ jobs: continue-on-error: true timeout-minutes: 40 run: cargo test --workspace --locked --no-fail-fast 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-no-fail-fast.log" + # The workspace test can't build until every crate compiles on Windows, + # so basal-host's own tests run separately to report its built-ins now. + - name: Test basal-host on its own + id: test-host + continue-on-error: true + timeout-minutes: 30 + run: cargo test -p basal-host --lib --locked --no-fail-fast 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-host.log" - name: Summarize the baseline if: always() run: | @@ -414,6 +457,9 @@ jobs: echo echo "- cargo check --workspace --all-targets --locked: ${{ steps.check.outcome }}" echo "- cargo test --workspace --locked: ${{ steps.test.outcome }}" + echo "- cargo test -p basal-launch --features deviations --locked: ${{ steps.test-launch.outcome }}" + echo "- cargo test -p basal-testkit --locked --no-fail-fast: ${{ steps.test-testkit.outcome }}" + echo "- cargo test -p basal-rig --locked --no-fail-fast: ${{ steps.test-rig.outcome }}" } >> "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() diff --git a/Cargo.lock b/Cargo.lock index 75a7c6a..d5d012c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,6 +90,14 @@ dependencies = [ "tokio", "tracing", "webpki-roots", + "windows-sys 0.61.2", +] + +[[package]] +name = "basal-launch" +version = "0.1.0" +dependencies = [ + "windows-sys 0.61.2", ] [[package]] @@ -130,6 +138,7 @@ version = "0.1.0" dependencies = [ "async-trait", "basal-host", + "basal-launch", "basal-proto", "blake3", "rusqlite", @@ -138,6 +147,7 @@ dependencies = [ "subc-client-rs", "subc-protocol", "tokio", + "windows-sys 0.61.2", ] [[package]] @@ -146,6 +156,7 @@ version = "0.1.0" dependencies = [ "basal-core", "basal-host", + "basal-launch", "basal-proto", "blake3", "jiff", @@ -155,12 +166,14 @@ dependencies = [ "rustls", "serde_json", "subc-protocol", + "windows-sys 0.61.2", ] [[package]] name = "basal-worker" version = "0.1.0" dependencies = [ + "basal-launch", "basal-proto", "basal-testkit", "landlock", @@ -168,6 +181,7 @@ dependencies = [ "rquickjs", "seccompiler", "serde_json", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f9ee08a..aabcda6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/basal-core", "crates/basal-testkit", "crates/basal-module", + "crates/basal-launch", "crates/basal-rig", ] @@ -20,6 +21,7 @@ rust-version = "1.88" # Dependencies do not require sibling checkouts. basal-core = { path = "crates/basal-core" } basal-host = { path = "crates/basal-host" } +basal-launch = { path = "crates/basal-launch" } basal-proto = { path = "crates/basal-proto" } basal-testkit = { path = "crates/basal-testkit" } blake3 = "1.8" diff --git a/crates/basal-host/Cargo.toml b/crates/basal-host/Cargo.toml index 4cadb55..79b7cce 100644 --- a/crates/basal-host/Cargo.toml +++ b/crates/basal-host/Cargo.toml @@ -36,5 +36,8 @@ tokio.workspace = true tracing.workspace = true webpki-roots.workspace = true +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_System_Threading"] } + [dev-dependencies] subc-transport.workspace = true diff --git a/crates/basal-host/src/builtins/fs.rs b/crates/basal-host/src/builtins/fs.rs index 8598478..b8f79fd 100644 --- a/crates/basal-host/src/builtins/fs.rs +++ b/crates/basal-host/src/builtins/fs.rs @@ -27,21 +27,34 @@ #[cfg(test)] mod tests; -use std::ffi::{CString, OsStr, OsString}; +#[cfg(unix)] +use std::ffi::CString; +use std::ffi::{OsStr, OsString}; +#[cfg(unix)] use std::fs::File; -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] use std::fs::OpenOptions; +#[cfg(unix)] use std::io::{Read, Write}; +#[cfg(unix)] use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +#[cfg(unix)] use std::os::unix::ffi::OsStrExt; -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] use std::os::unix::fs::OpenOptionsExt; -use std::path::{Component, Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - +#[cfg(unix)] +use std::path::Component; +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicU64; +#[cfg(unix)] +use std::sync::atomic::Ordering; + +#[cfg(unix)] use serde_json::{Value, json}; -use super::{Denial, codes, expand_home}; +#[cfg(unix)] +use super::expand_home; +use super::{Denial, codes}; #[cfg(target_os = "linux")] pub use linux::{ @@ -49,6 +62,13 @@ pub use linux::{ stat, }; +pub mod windows; +#[cfg(windows)] +pub use windows::{ + Target, list, open_checked, read, remove_legacy_temps, remove_temp, resolve, stat, write, + write_call, +}; + /// `fs.read`'s default cap. pub const DEFAULT_READ_BYTES: u64 = super::MAX_TEXT_RESULT_BYTES as u64; /// The largest cap `fs.read` accepts. @@ -70,7 +90,7 @@ pub enum Purpose { /// Where a path resolved to. #[derive(Debug, Clone, PartialEq, Eq)] -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] pub enum Target { /// It exists; this is its real path. Existing(PathBuf), @@ -81,7 +101,7 @@ pub enum Target { /// The roots, each resolved to its real path. A root that does not exist /// now grants nothing. -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] fn real_roots(roots: &[String]) -> Vec { roots .iter() @@ -111,6 +131,7 @@ fn io_denial(path: &Path, e: &std::io::Error) -> Denial { } /// The script's path as an absolute path, with `~` expanded. +#[cfg(unix)] fn absolute(path: &str) -> Result { if path.len() > MAX_PATH_BYTES || path.contains('\0') { return Err(Denial::invalid(format!( @@ -121,13 +142,13 @@ fn absolute(path: &str) -> Result { } /// Resolves `path` and requires it to lie under one of `roots`. -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] pub fn resolve(path: &str, roots: &[String], purpose: Purpose) -> Result { let roots = real_roots(roots); resolve_real(path, &roots, purpose) } -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] fn resolve_real(path: &str, roots: &[PathBuf], purpose: Purpose) -> Result { let path = absolute(path)?; if purpose == Purpose::Read { @@ -166,7 +187,7 @@ fn resolve_real(path: &str, roots: &[PathBuf], purpose: Purpose) -> Result std::io::Result { #[cfg(target_os = "macos")] { @@ -190,7 +211,7 @@ fn fd_path(fd: RawFd) -> std::io::Result { /// Requires the file behind `fd` (or, with `name`, the entry `name` in the /// directory behind `fd`) to lie under one of `roots`. -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] fn verify(fd: RawFd, name: Option<&OsStr>, roots: &[PathBuf]) -> Result<(), Denial> { let mut real = fd_path(fd).map_err(|e| Denial::new(codes::IO, e.to_string()))?; if let Some(name) = name { @@ -210,12 +231,12 @@ fn verify(fd: RawFd, name: Option<&OsStr>, roots: &[PathBuf]) -> Result<(), Deni /// it was resolved against. A symlink swapped into the last component /// since resolution fails the open; one swapped in higher up is caught by /// the check after it. -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] pub fn open_checked(resolved: &Path, roots: &[String], directory: bool) -> Result { open_checked_real(resolved, &real_roots(roots), directory) } -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] fn open_checked_real(resolved: &Path, roots: &[PathBuf], directory: bool) -> Result { let mut flags = libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK; if directory { @@ -240,6 +261,7 @@ fn open_checked_real(resolved: &Path, roots: &[PathBuf], directory: bool) -> Res } /// `fs.read`: the file's text, refused over `max_bytes` or when not UTF-8. +#[cfg(unix)] pub fn read(path: &str, roots: &[String], max_bytes: u64) -> Result { #[cfg(not(target_os = "linux"))] let roots = real_roots(roots); @@ -294,6 +316,7 @@ pub fn read(path: &str, roots: &[String], max_bytes: u64) -> Result &'static str { match mode & libc::S_IFMT { libc::S_IFREG => "file", @@ -303,12 +326,14 @@ fn kind_of(mode: libc::mode_t) -> &'static str { } } +#[cfg(unix)] fn c_name(name: &OsStr) -> Result { CString::new(name.as_bytes()).map_err(|_| Denial::invalid("a name with a NUL byte")) } /// `lstat` of `name` in the directory behind `dir`; `None` when it does /// not exist. +#[cfg(unix)] fn stat_at(dir: RawFd, name: &OsStr) -> Result, Denial> { let c = c_name(name)?; let mut st = std::mem::MaybeUninit::::uninit(); @@ -328,7 +353,7 @@ fn stat_at(dir: RawFd, name: &OsStr) -> Result, Denial> { } /// Opens the parent of an entry and checks the entry's real path. -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] fn open_parent(parent: &Path, name: &OsStr, roots: &[PathBuf]) -> Result { let file = OpenOptions::new() .read(true) @@ -343,7 +368,7 @@ fn open_parent(parent: &Path, name: &OsStr, roots: &[PathBuf]) -> Result Result { let roots = real_roots(roots); let (parent, name) = match resolve_real(path, &roots, Purpose::Read)? { @@ -370,6 +395,7 @@ pub fn stat(path: &str, roots: &[String]) -> Result { /// `fs.list`: the directory's entries, sorted by name, refused over /// [`MAX_LIST_ENTRIES`] or when a name is not UTF-8. +#[cfg(unix)] pub fn list(path: &str, roots: &[String]) -> Result { #[cfg(not(target_os = "linux"))] let roots = real_roots(roots); @@ -431,6 +457,7 @@ pub fn list(path: &str, roots: &[String]) -> Result { /// The entries of the directory behind `fd` (not `.` or `..`) with their /// `d_type`, reading at most one more than [`MAX_LIST_ENTRIES`]. +#[cfg(unix)] fn read_dir_fd(fd: RawFd) -> std::io::Result> { let mut out = Vec::new(); scan_dir(fd, |name, d_type| { @@ -442,6 +469,7 @@ fn read_dir_fd(fd: RawFd) -> std::io::Result> { /// Hands each entry of the directory behind `fd` (not `.` or `..`) with its /// `d_type` to `visit`, until `visit` answers false. +#[cfg(unix)] fn scan_dir(fd: RawFd, mut visit: impl FnMut(OsString, u8) -> bool) -> std::io::Result<()> { // fdopendir takes ownership of the descriptor it is given, so it gets // a duplicate and the caller keeps its own. @@ -580,6 +608,7 @@ pub fn is_legacy_temp_name(name: &OsStr) -> bool { } /// What [`unlink_regular`] found under a name. +#[cfg(unix)] enum Unlinked { Removed, Absent, @@ -590,6 +619,7 @@ enum Unlinked { /// Removes `name` from the directory behind `dir` only if it is a regular /// file. The name is examined without following a symlink, and unlinkat /// removes the entry itself, so a symlink's target is never touched. +#[cfg(unix)] fn unlink_regular(dir: RawFd, name: &OsStr) -> Result { match stat_at(dir, name)? { None => return Ok(Unlinked::Absent), @@ -611,6 +641,7 @@ fn unlink_regular(dir: RawFd, name: &OsStr) -> Result { } } +#[cfg(unix)] fn replacement_mode(mode: libc::mode_t) -> libc::mode_t { mode & 0o777 } @@ -627,6 +658,7 @@ pub(super) fn check_write_size(bytes: usize) -> Result<(), Denial> { } /// `fs.write` outside a journaled call, under a key of its own. +#[cfg(unix)] pub fn write(path: &str, roots: &[String], text: &str) -> Result { let key = format!( "local-{}-{}", @@ -639,6 +671,7 @@ pub fn write(path: &str, roots: &[String], text: &str) -> Result /// Resolves `path` for a write and opens its parent directory, checked /// against `roots`: the directory's real path, the name in it, and the /// open directory. +#[cfg(unix)] fn open_write_parent(path: &str, roots: &[String]) -> Result<(PathBuf, OsString, File), Denial> { #[cfg(not(target_os = "linux"))] let real = real_roots(roots); @@ -658,6 +691,7 @@ fn open_write_parent(path: &str, roots: &[String]) -> Result<(PathBuf, OsString, /// Creates the temporary file `name` (`c` is the same name) in the /// directory behind `dir`, never following a symlink. +#[cfg(unix)] fn create_temp(dir: RawFd, name: &OsStr, c: &CString, mode: libc::mode_t) -> std::io::Result { let mut replaced = false; loop { @@ -701,6 +735,7 @@ fn create_temp(dir: RawFd, name: &OsStr, c: &CString, mode: libc::mode_t) -> std /// `ledger`, the file is recorded there before it is created and the record /// is cleared only once the file is gone and that is durable, so however /// the call ends, crash included, a file it left behind is on record. +#[cfg(unix)] pub fn write_call( path: &str, roots: &[String], @@ -819,6 +854,7 @@ pub enum TempRemoval { /// following a symlink, and nothing else is ever removed. Removing a file /// that is already gone is not an error, so this may run any number of /// times. `Err` is a failure that may pass: keep the record and try again. +#[cfg(unix)] pub fn remove_temp(lease: &TempLease) -> Result { if !is_temp_name(&lease.temp) && !is_legacy_temp_name(&lease.temp) { return Ok(TempRemoval::Refused(Denial::invalid( @@ -861,6 +897,7 @@ pub fn remove_temp(lease: &TempLease) -> Result { /// is opened and checked against `roots` as a write to `path` would be; /// symlinks are never followed and nothing else is removed. Returns how /// many files were removed. +#[cfg(unix)] pub fn remove_legacy_temps(path: &str, roots: &[String]) -> Result { let (parent, _, dir) = open_write_parent(path, roots)?; let mut names = Vec::new(); diff --git a/crates/basal-host/src/builtins/fs/tests.rs b/crates/basal-host/src/builtins/fs/tests.rs index 9a482c9..216f67e 100644 --- a/crates/basal-host/src/builtins/fs/tests.rs +++ b/crates/basal-host/src/builtins/fs/tests.rs @@ -1,5 +1,7 @@ +#[cfg(unix)] use super::*; +#[cfg(unix)] #[test] fn replacement_mode_never_carries_setuid_or_setgid() { assert_eq!(replacement_mode(0o6755), 0o755); diff --git a/crates/basal-host/src/builtins/fs/windows.rs b/crates/basal-host/src/builtins/fs/windows.rs new file mode 100644 index 0000000..bf43b82 --- /dev/null +++ b/crates/basal-host/src/builtins/fs/windows.rs @@ -0,0 +1,3219 @@ +//! Windows-specific implementation of the `fs` built-in. +//! +//! A path's spelling is checked before anything is opened +//! ([`validate_raw_spelling`]). Every root and every path is then walked one +//! component at a time from `\??\X:\`, each component opened relative to the +//! handle of the one before, so Windows never rewrites a path, and a reparse +//! point (symlink, junction, mount point or any other) is refused wherever +//! it appears. The handle finally acted on is checked once more: its +//! volume-GUID path must lie under the root's, component by component. +//! +//! `fs.write` holds every directory from the volume root down to the +//! target's parent without `FILE_SHARE_DELETE` until its rename returns, so +//! none of them can be moved out of the root while it runs. It writes a +//! temporary file, flushes it, and renames it over the target with POSIX +//! semantics, failing rather than falling back to any other rename. A +//! replaced file keeps its DACL; a new file takes what NTFS inherits. + +#![cfg_attr(not(windows), allow(unused))] + +use serde_json::{Value, json}; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; + +pub use super::{ + DEFAULT_READ_BYTES, MAX_LIST_ENTRIES, MAX_PATH_BYTES, MAX_READ_BYTES, MAX_WRITE_BYTES, Purpose, + TempHold, TempLease, TempLedger, TempRemoval, inside, is_legacy_temp_name, temp_name, +}; +use super::{TEMP_SEQ, check_write_size, io_denial, is_temp_name, outside}; +use crate::builtins::Denial; +pub use crate::builtins::codes; + +/// Where a path resolved to on Windows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Target { + /// It exists; this is its real path. + Existing(PathBuf), + /// It does not exist (or, for a write, is about to be replaced): the + /// real path of its parent, and its last component. + Entry { parent: PathBuf, name: OsString }, +} + +/// Validates raw Windows path spelling before any normalization. +/// +/// Accepted: only drive-letter-rooted absolute paths (`X:\...`). +/// Refused prefixes: UNC (`\\server\share`), `C:relative`, and `\\.\`, `\\?\`, +/// `\??\`, `//./` namespaces. +/// Refused components (after the drive root): +/// - `.`, `..` and empty components +/// - `:` (alternate data streams and `::$DATA`) +/// - trailing dots or spaces +/// - reserved device names even with extension (`CON.txt`, `aux`, etc.) +pub fn validate_raw_spelling(path: &str) -> Result<(), Denial> { + if path.len() > MAX_PATH_BYTES || path.contains('\0') { + return Err(Denial::invalid(format!( + "a path is at most {MAX_PATH_BYTES} bytes with no NUL" + ))); + } + + // Refused prefixes + if path.starts_with(r"\\") || path.starts_with("//") { + return Err(Denial::invalid(format!( + "{path:?} is a UNC or device path, not an ordinary drive path" + ))); + } + if path.starts_with(r"\??\") || path.starts_with("/??/") { + return Err(Denial::invalid(format!( + "{path:?} uses the NT object namespace" + ))); + } + if path.starts_with(r"\\.\") || path.starts_with("//./") { + return Err(Denial::invalid(format!( + "{path:?} uses the Win32 device namespace" + ))); + } + if path.starts_with(r"\\?\") || path.starts_with("//?/") { + return Err(Denial::invalid(format!( + "{path:?} uses extended-length path syntax" + ))); + } + + let bytes = path.as_bytes(); + // Must start with X:\ where X is ASCII alphabetic + if bytes.len() < 3 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' || bytes[2] != b'\\' { + if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + return Err(Denial::invalid(format!( + "{path:?} is drive-relative, not an absolute drive path" + ))); + } + return Err(Denial::invalid(format!( + "{path:?} is not an absolute drive path (must start with X:\\)" + ))); + } + + let remainder = &path[3..]; + if remainder.is_empty() { + return Ok(()); + } + + if remainder.contains('/') { + return Err(Denial::invalid(format!( + "{path:?} contains forward slashes" + ))); + } + + for comp in remainder.split('\\') { + if comp.is_empty() { + return Err(Denial::invalid(format!( + "{path:?} contains an empty component" + ))); + } + if comp == "." || comp == ".." { + return Err(Denial::invalid(format!( + "{path:?} contains relative component {comp:?}" + ))); + } + if comp.contains(':') { + return Err(Denial::invalid(format!( + "{path:?} contains an alternate data stream selector" + ))); + } + if comp.ends_with('.') || comp.ends_with(' ') { + return Err(Denial::invalid(format!( + "{path:?} component {comp:?} ends with a dot or space" + ))); + } + let base = comp + .split('.') + .next() + .unwrap_or(comp) + .trim_end_matches([' ', '.']); + if is_reserved_device_name(base) { + return Err(Denial::invalid(format!( + "{path:?} component {comp:?} uses reserved device name {base:?}" + ))); + } + } + + Ok(()) +} + +/// Whether `name` is a Windows reserved DOS device name, compared ASCII-case-insensitively. +pub fn is_reserved_device_name(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + matches!( + upper.as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "CONIN$" + | "CONOUT$" + | "COM0" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT0" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + // Windows also reserves COM and LPT followed by a superscript one, two or three. + | "COM\u{b9}" + | "COM\u{b2}" + | "COM\u{b3}" + | "LPT\u{b9}" + | "LPT\u{b2}" + | "LPT\u{b3}" + ) +} + +/// Compares a target's volume-GUID path with a root's volume-GUID path +/// component-by-component without case folding or string-prefix matching. +pub fn guid_path_inside_component_wise( + target_guid: &str, + root_guid: &str, + is_directory_root: bool, +) -> bool { + let target_parts: Vec<&str> = target_guid.split('\\').filter(|s| !s.is_empty()).collect(); + let root_parts: Vec<&str> = root_guid.split('\\').filter(|s| !s.is_empty()).collect(); + + if target_parts.len() < root_parts.len() { + return false; + } + + for (r, t) in root_parts.iter().zip(target_parts.iter()) { + if r != t { + return false; + } + } + + if !is_directory_root && target_parts.len() != root_parts.len() { + return false; + } + + true +} + +#[cfg(windows)] +#[allow(non_snake_case, non_upper_case_globals, clippy::upper_case_acronyms)] +mod ffi { + use std::ffi::c_void; + + pub type NTSTATUS = i32; + pub type HANDLE = *mut c_void; + + pub const STATUS_OBJECT_NAME_NOT_FOUND: NTSTATUS = 0xC0000034_u32 as i32; + pub const STATUS_OBJECT_PATH_NOT_FOUND: NTSTATUS = 0xC000003A_u32 as i32; + pub const STATUS_OBJECT_NAME_COLLISION: NTSTATUS = 0xC0000035_u32 as i32; + pub const STATUS_ACCESS_DENIED: NTSTATUS = 0xC0000022_u32 as i32; + pub const STATUS_NOT_A_DIRECTORY: NTSTATUS = 0xC0000103_u32 as i32; + pub const STATUS_FILE_IS_A_DIRECTORY: NTSTATUS = 0xC00000BA_u32 as i32; + pub const STATUS_NOT_SUPPORTED: NTSTATUS = 0xC00000BB_u32 as i32; + pub const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000000D_u32 as i32; + pub const STATUS_NAME_TOO_LONG: NTSTATUS = 0xC0000106_u32 as i32; + pub const STATUS_FILE_CORRUPT_ERROR: NTSTATUS = 0xC0000102_u32 as i32; + pub const STATUS_NO_MORE_FILES: NTSTATUS = 0x80000006_u32 as i32; + #[cfg(test)] + pub const STATUS_IO_DEVICE_ERROR: NTSTATUS = 0xC0000185_u32 as i32; + + pub const FILE_LIST_DIRECTORY: u32 = 0x0001; + pub const FILE_WRITE_DATA: u32 = 0x0002; + pub const FILE_TRAVERSE: u32 = 0x0020; + pub const FILE_READ_ATTRIBUTES: u32 = 0x0080; + pub const DELETE: u32 = 0x00010000; + pub const READ_CONTROL: u32 = 0x00020000; + pub const SYNCHRONIZE: u32 = 0x00100000; + pub const FILE_GENERIC_READ: u32 = 0x00120089; + pub const FILE_GENERIC_WRITE: u32 = 0x00120116; + + pub const FILE_SHARE_READ: u32 = 0x00000001; + pub const FILE_SHARE_WRITE: u32 = 0x00000002; + pub const FILE_SHARE_DELETE: u32 = 0x00000004; + + pub const FILE_OPEN: u32 = 0x00000001; + pub const FILE_CREATE: u32 = 0x00000002; + + pub const FILE_DIRECTORY_FILE: u32 = 0x00000001; + pub const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x00000020; + pub const FILE_NON_DIRECTORY_FILE: u32 = 0x00000040; + pub const FILE_OPEN_REPARSE_POINT: u32 = 0x00200000; + + pub const FILE_ATTRIBUTE_NORMAL: u32 = 0x00000080; + pub const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x00000010; + pub const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x00000400; + + pub const OBJ_CASE_INSENSITIVE: u32 = 0x00000040; + + pub const FileDirectoryInformation: u32 = 1; + pub const FileBasicInformation: u32 = 4; + pub const FileStandardInformation: u32 = 5; + pub const FileDispositionInformation: u32 = 13; + pub const FileRenameInformationEx: u32 = 65; + + pub const FILE_RENAME_REPLACE_IF_EXISTS: u32 = 0x00000001; + pub const FILE_RENAME_POSIX_SEMANTICS: u32 = 0x00000002; + pub const FILE_RENAME_IGNORE_READONLY_ATTRIBUTE: u32 = 0x00000040; + + pub const FILE_NAME_NORMALIZED: u32 = 0x0; + pub const VOLUME_NAME_GUID: u32 = 0x1; + + pub const DACL_SECURITY_INFORMATION: u32 = 0x00000004; + pub const SE_FILE_OBJECT: u32 = 1; + + #[repr(C)] + pub struct UNICODE_STRING { + pub Length: u16, + pub MaximumLength: u16, + pub Buffer: *mut u16, + } + + #[repr(C)] + pub struct OBJECT_ATTRIBUTES { + pub Length: u32, + pub RootDirectory: HANDLE, + pub ObjectName: *mut UNICODE_STRING, + pub Attributes: u32, + pub SecurityDescriptor: *mut c_void, + pub SecurityQualityOfService: *mut c_void, + } + + #[repr(C)] + pub struct IO_STATUS_BLOCK { + pub Status: NTSTATUS, + pub Information: usize, + } + + #[repr(C)] + #[derive(Default, Clone, Copy)] + pub struct FILE_BASIC_INFORMATION { + pub CreationTime: i64, + pub LastAccessTime: i64, + pub LastWriteTime: i64, + pub ChangeTime: i64, + pub FileAttributes: u32, + pub Reserved: u32, + } + + #[repr(C)] + #[derive(Default, Clone, Copy)] + pub struct FILE_STANDARD_INFORMATION { + pub AllocationSize: i64, + pub EndOfFile: i64, + pub NumberOfLinks: u32, + pub DeletePending: u8, + pub Directory: u8, + pub Reserved: [u8; 2], + } + + /// One entry of an `NtQueryDirectoryFile` reply. Entries are never read + /// through this type: the reply is parsed as bytes at the offsets + /// `offset_of!` gives for these fields, so a misaligned or truncated + /// reply cannot make a reference to it. + #[allow(dead_code)] + #[repr(C)] + pub struct FILE_DIRECTORY_INFORMATION { + pub NextEntryOffset: u32, + pub FileIndex: u32, + pub CreationTime: i64, + pub LastAccessTime: i64, + pub LastWriteTime: i64, + pub ChangeTime: i64, + pub EndOfFile: i64, + pub AllocationSize: i64, + pub FileAttributes: u32, + pub FileNameLength: u32, + pub FileName: [u16; 1], + } + + /// The header of a rename request. It is written field by field through + /// a raw pointer into an 8-byte-aligned buffer that also holds the name, + /// never built as a value. + #[allow(dead_code)] + #[repr(C)] + pub struct FILE_RENAME_INFORMATION_EX { + pub Flags: u32, + pub RootDirectory: HANDLE, + pub FileNameLength: u32, + pub FileName: [u16; 1], + } + + #[repr(C)] + pub struct FILE_DISPOSITION_INFORMATION { + pub DeleteFile: u8, + } + + #[link(name = "ntdll")] + unsafe extern "system" { + pub fn NtCreateFile( + FileHandle: *mut HANDLE, + DesiredAccess: u32, + ObjectAttributes: *mut OBJECT_ATTRIBUTES, + IoStatusBlock: *mut IO_STATUS_BLOCK, + AllocationSize: *mut i64, + FileAttributes: u32, + ShareAccess: u32, + CreateDisposition: u32, + CreateOptions: u32, + EaBuffer: *mut c_void, + EaLength: u32, + ) -> NTSTATUS; + + pub fn NtClose(Handle: HANDLE) -> NTSTATUS; + + pub fn NtQueryInformationFile( + FileHandle: HANDLE, + IoStatusBlock: *mut IO_STATUS_BLOCK, + FileInformation: *mut c_void, + Length: u32, + FileInformationClass: u32, + ) -> NTSTATUS; + + pub fn NtSetInformationFile( + FileHandle: HANDLE, + IoStatusBlock: *mut IO_STATUS_BLOCK, + FileInformation: *mut c_void, + Length: u32, + FileInformationClass: u32, + ) -> NTSTATUS; + + pub fn NtQueryDirectoryFile( + FileHandle: HANDLE, + Event: HANDLE, + ApcRoutine: *mut c_void, + ApcContext: *mut c_void, + IoStatusBlock: *mut IO_STATUS_BLOCK, + FileInformation: *mut c_void, + Length: u32, + FileInformationClass: u32, + ReturnSingleEntry: u8, + FileName: *mut UNICODE_STRING, + RestartScan: u8, + ) -> NTSTATUS; + + pub fn NtWriteFile( + FileHandle: HANDLE, + Event: HANDLE, + ApcRoutine: *mut c_void, + ApcContext: *mut c_void, + IoStatusBlock: *mut IO_STATUS_BLOCK, + Buffer: *const c_void, + Length: u32, + ByteOffset: *mut i64, + Key: *mut u32, + ) -> NTSTATUS; + + pub fn NtFlushBuffersFile( + FileHandle: HANDLE, + IoStatusBlock: *mut IO_STATUS_BLOCK, + ) -> NTSTATUS; + } + + #[link(name = "kernel32")] + unsafe extern "system" { + pub fn GetFinalPathNameByHandleW( + hFile: HANDLE, + lpszFilePath: *mut u16, + cchFilePath: u32, + dwFlags: u32, + ) -> u32; + + pub fn LocalFree(hMem: *mut c_void) -> *mut c_void; + } + + #[link(name = "advapi32")] + unsafe extern "system" { + pub fn GetSecurityInfo( + handle: HANDLE, + ObjectType: u32, + SecurityInfo: u32, + ppsidOwner: *mut *mut c_void, + ppsidGroup: *mut *mut c_void, + ppDacl: *mut *mut c_void, + ppSacl: *mut *mut c_void, + ppSecurityDescriptor: *mut *mut c_void, + ) -> u32; + } +} + +/// Every sharing mode: what reads, stats and listings open with. +#[cfg(windows)] +const SHARE_ALL: u32 = ffi::FILE_SHARE_READ | ffi::FILE_SHARE_WRITE | ffi::FILE_SHARE_DELETE; + +/// The sharing mode of the directories a write holds while it runs. Without +/// `FILE_SHARE_DELETE` nobody else can open them for `DELETE`, which a +/// rename or a delete of the directory needs, so none of them can be moved +/// out of the root before the write's rename returns. +#[cfg(windows)] +const SHARE_NO_DELETE: u32 = ffi::FILE_SHARE_READ | ffi::FILE_SHARE_WRITE; + +/// The access a directory on a walked path is opened with. `FILE_TRAVERSE` +/// is there for sharing, not for the walk itself: the kernel records a +/// handle's sharing mode only when the handle has read, write, execute +/// (`FILE_TRAVERSE` is the execute bit) or delete access. A held directory +/// opened with attribute access alone would not stop anyone renaming it. +#[cfg(windows)] +const DIR_WALK: u32 = ffi::FILE_READ_ATTRIBUTES | ffi::FILE_TRAVERSE | ffi::SYNCHRONIZE; + +/// A handle this module opened and closes. The field is private and the +/// only constructor is [`nt_open_relative`], so no code can hand an +/// arbitrary pointer to `NtClose`. +#[cfg(windows)] +struct OwnedHandle(ffi::HANDLE); + +#[cfg(windows)] +impl OwnedHandle { + fn raw(&self) -> ffi::HANDLE { + self.0 + } + + fn into_file(self) -> std::fs::File { + use std::os::windows::io::FromRawHandle; + let raw = self.0; + std::mem::forget(self); + // SAFETY: `raw` is an open handle this value owned; forgetting the + // value hands that ownership to the File exactly once. + unsafe { std::fs::File::from_raw_handle(raw) } + } +} + +#[cfg(windows)] +impl Drop for OwnedHandle { + fn drop(&mut self) { + // SAFETY: the handle came from a successful NtCreateFile and is + // closed exactly once, here. + unsafe { ffi::NtClose(self.0) }; + } +} + +/// A security descriptor allocated by `GetSecurityInfo`, freed on drop. +#[cfg(windows)] +struct FileDacl { + sd: *mut std::ffi::c_void, +} + +#[cfg(windows)] +impl Drop for FileDacl { + fn drop(&mut self) { + if !self.sd.is_null() { + // SAFETY: `sd` came from GetSecurityInfo, whose descriptors are + // documented to be released with LocalFree, and is freed once. + unsafe { ffi::LocalFree(self.sd) }; + } + } +} + +#[cfg(windows)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OpenError { + NotFound, + ReparsePoint, + AccessDenied, + NotADirectory, + IsADirectory, + Collision, + Other(ffi::NTSTATUS), +} + +#[cfg(windows)] +impl OpenError { + fn status(self) -> ffi::NTSTATUS { + match self { + Self::NotFound => ffi::STATUS_OBJECT_NAME_NOT_FOUND, + // Not an NT status of its own: the open succeeded and the entry + // was refused for what it is. + Self::ReparsePoint => ffi::STATUS_ACCESS_DENIED, + Self::AccessDenied => ffi::STATUS_ACCESS_DENIED, + Self::NotADirectory => ffi::STATUS_NOT_A_DIRECTORY, + Self::IsADirectory => ffi::STATUS_FILE_IS_A_DIRECTORY, + Self::Collision => ffi::STATUS_OBJECT_NAME_COLLISION, + Self::Other(status) => status, + } + } +} + +/// How [`nt_open_relative`] opens a name. +#[cfg(windows)] +#[derive(Clone, Copy)] +struct Open { + access: u32, + share: u32, + disposition: u32, + options: u32, + security_descriptor: *mut std::ffi::c_void, +} + +#[cfg(windows)] +impl Open { + /// Opens an existing entry of any type, sharing everything. + fn existing(access: u32) -> Self { + Self { + access, + share: SHARE_ALL, + disposition: ffi::FILE_OPEN, + options: 0, + security_descriptor: std::ptr::null_mut(), + } + } + + fn directory(mut self) -> Self { + self.options |= ffi::FILE_DIRECTORY_FILE; + self + } + + fn non_directory(mut self) -> Self { + self.options |= ffi::FILE_NON_DIRECTORY_FILE; + self + } + + fn share(mut self, share: u32) -> Self { + self.share = share; + self + } +} + +/// Opens `name`, one component, relative to the directory `root` (or, with +/// a null `root`, the NT path `name`). An empty `name` opens `root` itself +/// again, with the access `open` asks for. +/// +/// No reparse point is ever followed (`FILE_OPEN_REPARSE_POINT`), and one +/// that was opened is refused: the handle is asked for its attributes, and +/// an entry that is a reparse point, or whose attributes cannot be read, is +/// closed and refused. `FILE_READ_ATTRIBUTES` is added to every request so +/// that check always has the access it needs. +#[cfg(windows)] +fn nt_open_relative(root: ffi::HANDLE, name: &str, open: Open) -> Result { + let mut wide: Vec = name.encode_utf16().collect(); + // A UNICODE_STRING counts bytes in a u16; a longer name would be cut to + // a prefix, which can name a different entry. + let length = + u16::try_from(wide.len() * 2).map_err(|_| OpenError::Other(ffi::STATUS_NAME_TOO_LONG))?; + let maximum = length + .checked_add(2) + .ok_or(OpenError::Other(ffi::STATUS_NAME_TOO_LONG))?; + wide.push(0); + let mut unicode_name = ffi::UNICODE_STRING { + Length: length, + MaximumLength: maximum, + Buffer: wide.as_mut_ptr(), + }; + let mut obj_attr = ffi::OBJECT_ATTRIBUTES { + Length: std::mem::size_of::() as u32, + RootDirectory: root, + ObjectName: &mut unicode_name, + Attributes: ffi::OBJ_CASE_INSENSITIVE, + SecurityDescriptor: open.security_descriptor, + SecurityQualityOfService: std::ptr::null_mut(), + }; + let mut handle = std::ptr::null_mut(); + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + // SAFETY: every pointer refers to a live local that outlives the call: + // the name buffer `wide`, the `unicode_name` describing it, the object + // attributes, the status block, and `handle`, which receives the new + // handle on success. + let status = unsafe { + ffi::NtCreateFile( + &mut handle, + open.access | ffi::FILE_READ_ATTRIBUTES, + &mut obj_attr, + &mut io_status, + std::ptr::null_mut(), + ffi::FILE_ATTRIBUTE_NORMAL, + open.share, + open.disposition, + open.options | ffi::FILE_SYNCHRONOUS_IO_NONALERT | ffi::FILE_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + 0, + ) + }; + if status < 0 { + return Err(match status { + ffi::STATUS_OBJECT_NAME_NOT_FOUND | ffi::STATUS_OBJECT_PATH_NOT_FOUND => { + OpenError::NotFound + } + ffi::STATUS_ACCESS_DENIED => OpenError::AccessDenied, + ffi::STATUS_NOT_A_DIRECTORY => OpenError::NotADirectory, + ffi::STATUS_FILE_IS_A_DIRECTORY => OpenError::IsADirectory, + ffi::STATUS_OBJECT_NAME_COLLISION => OpenError::Collision, + _ => OpenError::Other(status), + }); + } + let handle = OwnedHandle(handle); + // Fail closed: an entry whose attributes cannot be read is not known + // not to be a reparse point. + let basic = query_file_basic(handle.raw()).map_err(OpenError::Other)?; + if basic.FileAttributes & ffi::FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(OpenError::ReparsePoint); + } + Ok(handle) +} + +/// Opens the file or directory behind `handle` again, with the access +/// `open` asks for. An NT open of an empty name relative to a handle opens +/// that handle's own file object (this is how `ReOpenFile` works), so the +/// new handle is for the object already checked, not for whatever a path +/// names now. +#[cfg(windows)] +fn nt_reopen(handle: &OwnedHandle, open: Open) -> Result { + nt_open_relative(handle.raw(), "", open) +} + +/// Opens each of `components` beneath `start` as a directory, in order, and +/// returns every handle (the last is the deepest). +#[cfg(windows)] +fn walk_dirs( + start: &OwnedHandle, + components: &[&str], + open: Open, +) -> Result, OpenError> { + let mut held: Vec = Vec::with_capacity(components.len()); + for comp in components { + let current = held.last().unwrap_or(start); + let next = nt_open_relative(current.raw(), comp, open.directory())?; + held.push(next); + } + Ok(held) +} + +#[cfg(windows)] +fn query_file_basic(handle: ffi::HANDLE) -> Result { + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let mut info = ffi::FILE_BASIC_INFORMATION::default(); + // SAFETY: the output buffer is `info`, and the length passed is its size. + let status = unsafe { + ffi::NtQueryInformationFile( + handle, + &mut io_status, + (&mut info as *mut ffi::FILE_BASIC_INFORMATION).cast(), + std::mem::size_of::() as u32, + ffi::FileBasicInformation, + ) + }; + if status < 0 { + return Err(status); + } + Ok(info) +} + +#[cfg(windows)] +fn query_file_standard( + handle: ffi::HANDLE, +) -> Result { + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let mut info = ffi::FILE_STANDARD_INFORMATION::default(); + // SAFETY: the output buffer is `info`, and the length passed is its size. + let status = unsafe { + ffi::NtQueryInformationFile( + handle, + &mut io_status, + (&mut info as *mut ffi::FILE_STANDARD_INFORMATION).cast(), + std::mem::size_of::() as u32, + ffi::FileStandardInformation, + ) + }; + if status < 0 { + return Err(status); + } + Ok(info) +} + +/// A denial with code `IO` for an NT call on `path` that failed with +/// `status`, naming both. +#[cfg(windows)] +fn nt_io(path: &Path, what: &str, status: ffi::NTSTATUS) -> Denial { + Denial::new( + codes::IO, + format!( + "{}: {what} failed (NT status 0x{:08x})", + path.display(), + status as u32 + ), + ) +} + +#[cfg(windows)] +fn get_volume_guid_path(handle: ffi::HANDLE) -> Result { + let mut buf = vec![0u16; 1024]; + // SAFETY: `buf` is writable for the `buf.len()` UTF-16 units passed as + // the capacity. + let len = unsafe { + ffi::GetFinalPathNameByHandleW( + handle, + buf.as_mut_ptr(), + buf.len() as u32, + ffi::VOLUME_NAME_GUID | ffi::FILE_NAME_NORMALIZED, + ) + }; + if len == 0 { + return Err(Denial::denied("failed to get volume GUID path for handle")); + } + if len as usize > buf.len() { + buf.resize(len as usize + 1, 0); + // SAFETY: `buf` was grown to the length the first call asked for and + // is writable for the `buf.len()` units passed as the capacity. + let len2 = unsafe { + ffi::GetFinalPathNameByHandleW( + handle, + buf.as_mut_ptr(), + buf.len() as u32, + ffi::VOLUME_NAME_GUID | ffi::FILE_NAME_NORMALIZED, + ) + }; + if len2 == 0 || len2 as usize > buf.len() { + return Err(Denial::denied("failed to get volume GUID path for handle")); + } + buf.truncate(len2 as usize); + } else { + buf.truncate(len as usize); + } + String::from_utf16(&buf).map_err(|_| Denial::denied("volume GUID path is not UTF-16")) +} + +/// Whether `parent_guid` is the directory that holds the file root whose +/// volume-GUID path is `file_root_guid`, compared component by component. +#[cfg(windows)] +fn guid_is_parent_of(parent_guid: &str, file_root_guid: &str) -> bool { + let parent: Vec<&str> = parent_guid.split('\\').filter(|s| !s.is_empty()).collect(); + let root: Vec<&str> = file_root_guid + .split('\\') + .filter(|s| !s.is_empty()) + .collect(); + root.split_last() + .is_some_and(|(_, head)| head == parent.as_slice()) +} + +/// A manifest root after [`walk_from_volume_root`] opened it from its +/// volume's root directory, refusing reparse points, with its volume-GUID +/// path for checking where later handles really are. +#[cfg(windows)] +struct VerifiedRoot { + manifest: String, + guid_path: String, + is_directory: bool, + /// The root itself: [`walk_from_volume_root`] opens a directory with + /// [`DIR_WALK`] and a file with attribute access only. Operations that need more open it again with + /// [`nt_reopen`]. + handle: OwnedHandle, + /// The directories above the root, from the volume root down; the last + /// is the root's parent. Empty for a volume root. + ancestors: Vec, +} + +/// A directory and every ancestor held against rename or replacement while a +/// reader such as git opens the directory by path. Omitting delete sharing +/// prevents another process from moving any of those directories. +#[cfg(windows)] +pub(crate) struct PinnedDirectory(VerifiedRoot); + +#[cfg(windows)] +impl PinnedDirectory { + pub(crate) fn path(&self) -> &Path { + Path::new(&self.0.manifest) + } + + pub(crate) fn same_identity(&self, other: &Self) -> bool { + guid_path_inside_component_wise(&self.0.guid_path, &other.0.guid_path, false) + } +} + +#[cfg(windows)] +impl std::ops::Deref for PinnedDirectory { + type Target = Path; + + fn deref(&self) -> &Path { + self.path() + } +} + +/// Uses the filesystem builtin's walk, which refuses reparse points, retaining +/// every directory handle until the returned guard is dropped. The volume-GUID identity is +/// obtained from the opened directory, never from canonicalized input text. +#[cfg(windows)] +pub(crate) fn pin_directory(path: &str) -> Result { + let root = walk_from_volume_root(path, SHARE_NO_DELETE)?; + if !root.is_directory { + return Err(Denial::denied("the pinned path is not a directory")); + } + Ok(PinnedDirectory(root)) +} + +/// The denial for a failed open of `comp` while walking to `manifest_root`. +#[cfg(windows)] +fn root_walk_denial(e: OpenError, comp: &str, manifest_root: &str) -> Denial { + match e { + OpenError::ReparsePoint => Denial::denied(format!( + "{comp} in root {manifest_root} is a reparse point; fs refuses every reparse point" + )), + OpenError::NotFound => Denial::new( + codes::NOT_FOUND, + format!("root {manifest_root} does not exist"), + ), + OpenError::AccessDenied => Denial::denied(format!( + "access to {comp} in root {manifest_root} is denied" + )), + OpenError::NotADirectory => { + Denial::denied(format!("{comp} in root {manifest_root} is not a directory")) + } + other => Denial::new( + codes::IO, + format!( + "opening {comp} in root {manifest_root} failed (NT status 0x{:08x})", + other.status() as u32 + ), + ), + } +} + +/// Walks `manifest_root` from its volume's root directory, one component at +/// a time, refusing a reparse point anywhere. Every handle is opened with +/// `share`. +#[cfg(windows)] +fn walk_from_volume_root(manifest_root: &str, share: u32) -> Result { + validate_raw_spelling(manifest_root)?; + let drive = &manifest_root[..3]; // e.g. "C:\" + let volume = nt_open_relative( + std::ptr::null_mut(), + &format!(r"\??\{drive}"), + Open::existing(DIR_WALK).directory().share(share), + ) + .map_err(|e| root_walk_denial(e, drive, manifest_root))?; + + let rest = &manifest_root[3..]; + if rest.is_empty() { + let guid_path = get_volume_guid_path(volume.raw())?; + return Ok(VerifiedRoot { + manifest: manifest_root.to_owned(), + guid_path, + is_directory: true, + handle: volume, + ancestors: Vec::new(), + }); + } + + let components: Vec<&str> = rest.split('\\').collect(); + let Some((last, middle)) = components.split_last() else { + return Err(outside(Path::new(manifest_root))); + }; + let mut ancestors = vec![volume]; + for comp in middle { + let current = ancestors.last().expect("the volume root is held"); + let next = nt_open_relative( + current.raw(), + comp, + Open::existing(DIR_WALK).directory().share(share), + ) + .map_err(|e| root_walk_denial(e, comp, manifest_root))?; + ancestors.push(next); + } + let parent = ancestors.last().expect("the volume root is held"); + // The root's type is not known before it is open, so it is opened first + // with attribute access, which any type grants. A directory is then + // opened again with FILE_TRAVERSE, which a held directory needs to take + // part in sharing checks; a file is not, because on a file that bit is + // the execute right and may not be granted. A file root keeps the + // first handle, which shares everything: it is the very file a write + // to the root replaces, so it must never stand in the way of that. + let first = nt_open_relative(parent.raw(), last, Open::existing(ffi::SYNCHRONIZE)) + .map_err(|e| root_walk_denial(e, last, manifest_root))?; + let std_info = + query_file_standard(first.raw()).map_err(|s| nt_io(Path::new(manifest_root), "stat", s))?; + let is_directory = std_info.Directory != 0; + let handle = if is_directory { + nt_reopen(&first, Open::existing(DIR_WALK).directory().share(share)) + .map_err(|e| root_walk_denial(e, last, manifest_root))? + } else { + first + }; + let guid_path = get_volume_guid_path(handle.raw())?; + Ok(VerifiedRoot { + manifest: manifest_root.to_owned(), + guid_path, + is_directory, + handle, + ancestors, + }) +} + +#[cfg(windows)] +fn filetime_to_mtime_ms(ft: i64) -> i64 { + const UNIX_EPOCH_DIFF_100NS: i64 = 116_444_736_000_000_000; + if ft < UNIX_EPOCH_DIFF_100NS { + 0 + } else { + (ft - UNIX_EPOCH_DIFF_100NS) / 10_000 + } +} + +/// Whether `path` is `root` or lies beneath it, by spelling. A volume root +/// (`X:\`, the only root spelling that ends in a separator) covers every +/// path on its drive. +fn path_under(path: &str, root: &str) -> bool { + path == root + || path + .strip_prefix(root) + .is_some_and(|rest| root.ends_with('\\') || rest.starts_with('\\')) +} + +/// The components of `path` below `root`, which it must lie under +/// ([`path_under`]); empty when it is the root. +fn components_below<'a>(path: &'a str, root: &str) -> Vec<&'a str> { + let rest = &path[root.len()..]; + let rest = rest.strip_prefix('\\').unwrap_or(rest); + if rest.is_empty() { + Vec::new() + } else { + rest.split('\\').collect() + } +} + +/// How bad a root's failure is, for choosing which one to report: a +/// failure that may pass beats a missing root, which beats a refusal. +#[cfg(windows)] +fn failure_rank(d: &Denial) -> u8 { + if d.code == codes::IO { + 2 + } else if d.code == codes::NOT_FOUND { + 1 + } else { + 0 + } +} + +/// Finds a manifest root that `path` lies under and walks it +/// ([`walk_from_volume_root`]). A file root grants only its own path. When +/// every root `path` lies under fails to walk, the most telling failure is +/// returned: an `IO` error (which may succeed if tried again), then +/// `NOT_FOUND` (the root is gone), then a refusal. `remove_temp` relies on +/// that order to keep a record after a transient failure and to drop one +/// whose directory no longer exists. +#[cfg(windows)] +fn select_root(path: &str, roots: &[String], share: u32) -> Result { + validate_raw_spelling(path)?; + let mut failure: Option = None; + for r in roots { + if validate_raw_spelling(r).is_err() || !path_under(path, r) { + continue; + } + match walk_from_volume_root(r, share) { + Ok(vr) if vr.is_directory || path == vr.manifest => return Ok(vr), + Ok(_) => {} + Err(d) => { + if failure + .as_ref() + .is_none_or(|f| failure_rank(&d) > failure_rank(f)) + { + failure = Some(d); + } + } + } + } + Err(failure.unwrap_or_else(|| outside(Path::new(path)))) +} + +/// The denial for a failed open of a directory between a root and the +/// entry `path` names: a reparse point is refused as one, an unexpected NT +/// failure is `IO`, and a missing, unreadable or non-directory component +/// is "outside the manifest's roots or missing", the answer the Unix +/// implementation gives when resolving such a path fails. +#[cfg(windows)] +fn intermediate_denial(e: OpenError, path: &Path) -> Denial { + match e { + OpenError::ReparsePoint => Denial::denied(format!( + "{} contains a reparse point; fs refuses every reparse point", + path.display() + )), + OpenError::Other(status) => nt_io(path, "opening a directory", status), + _ => outside(path), + } +} + +#[cfg(windows)] +pub fn resolve(path: &str, roots: &[String], purpose: Purpose) -> Result { + validate_raw_spelling(path)?; + let vr = select_root(path, roots, SHARE_ALL)?; + let p = Path::new(path); + + if purpose == Purpose::Read { + if path == vr.manifest { + return Ok(Target::Existing(PathBuf::from(path))); + } + let components = components_below(path, &vr.manifest); + let Some((last, middle)) = components.split_last() else { + return Err(outside(p)); + }; + let dirs = walk_dirs(&vr.handle, middle, Open::existing(ffi::SYNCHRONIZE)) + .map_err(|e| intermediate_denial(e, p))?; + let parent = dirs.last().unwrap_or(&vr.handle); + match nt_open_relative(parent.raw(), last, Open::existing(ffi::SYNCHRONIZE)) { + Ok(leaf) => { + let guid = get_volume_guid_path(leaf.raw())?; + return if guid_path_inside_component_wise(&guid, &vr.guid_path, vr.is_directory) { + Ok(Target::Existing(PathBuf::from(path))) + } else { + Err(outside(p)) + }; + } + // A missing entry: answered below as the name in its parent + // directory, which must lie under the root. + Err(OpenError::NotFound) => {} + Err(OpenError::ReparsePoint) => { + return Err(Denial::denied(format!( + "{path} contains a reparse point; fs refuses every reparse point" + ))); + } + Err(_) => return Err(outside(p)), + } + } + + let parent = p.parent().ok_or_else(|| outside(p))?; + let name = p.file_name().ok_or_else(|| outside(p))?; + + if vr.is_directory { + // The directory root itself is not an entry a write can replace. + let parent_str = parent.to_str().ok_or_else(|| outside(p))?; + if path == vr.manifest || !path_under(parent_str, &vr.manifest) { + return Err(outside(p)); + } + } else if path != vr.manifest { + return Err(outside(p)); + } + + Ok(Target::Entry { + parent: parent.to_path_buf(), + name: name.to_os_string(), + }) +} + +/// The denial for a failed open of the entry `path` itself. +#[cfg(windows)] +fn leaf_denial(e: OpenError, path: &Path) -> Denial { + match e { + OpenError::ReparsePoint => Denial::denied(format!( + "{} became a symlink while it was being opened", + path.display() + )), + OpenError::NotFound => io_denial(path, &std::io::Error::from(std::io::ErrorKind::NotFound)), + OpenError::NotADirectory => { + Denial::new(codes::IO, format!("{} is not a directory", path.display())) + } + OpenError::AccessDenied => { + Denial::denied(format!("access to {} is denied", path.display())) + } + other => nt_io(path, "opening", other.status()), + } +} + +#[cfg(windows)] +pub fn open_checked( + resolved: &Path, + roots: &[String], + directory: bool, +) -> Result { + let path_str = resolved.to_str().ok_or_else(|| outside(resolved))?; + validate_raw_spelling(path_str)?; + let vr = select_root(path_str, roots, SHARE_ALL)?; + let leaf_open = if directory { + Open::existing(ffi::FILE_GENERIC_READ | ffi::FILE_TRAVERSE).directory() + } else { + Open::existing(ffi::FILE_GENERIC_READ) + }; + + let opened = if path_str == vr.manifest { + if directory && !vr.is_directory { + return Err(outside(resolved)); + } + // The walk opened the root with attribute access only; reading or + // listing it needs a handle with the access for that. + nt_reopen(&vr.handle, leaf_open).map_err(|e| leaf_denial(e, resolved))? + } else { + let components = components_below(path_str, &vr.manifest); + let Some((last, middle)) = components.split_last() else { + return Err(outside(resolved)); + }; + let dirs = walk_dirs(&vr.handle, middle, Open::existing(DIR_WALK)) + .map_err(|e| intermediate_denial(e, resolved))?; + let parent = dirs.last().unwrap_or(&vr.handle); + nt_open_relative(parent.raw(), last, leaf_open).map_err(|e| leaf_denial(e, resolved))? + }; + + let guid = get_volume_guid_path(opened.raw())?; + if !guid_path_inside_component_wise(&guid, &vr.guid_path, vr.is_directory) { + return Err(outside(resolved)); + } + Ok(opened.into_file()) +} + +/// Test-only hooks. They are per thread, so a test that sets one affects +/// only the calls it makes itself, never a test running beside it. +#[cfg(all(test, windows))] +mod hooks { + use std::cell::{Cell, RefCell}; + + thread_local! { + /// Runs in `read` between resolving the path and opening it. + pub(super) static BEFORE_READ_OPEN: RefCell>> = + const { RefCell::new(None) }; + /// Makes `write_call` treat the rename as refused by the volume. + pub(super) static REFUSE_POSIX_RENAME: Cell = const { Cell::new(false) }; + /// Makes `write_call` treat flushing its temporary file as failed. + pub(super) static FAIL_TEMP_FLUSH: Cell = const { Cell::new(false) }; + } +} + +#[cfg(windows)] +pub fn read(path: &str, roots: &[String], max_bytes: u64) -> Result { + let max_bytes = max_bytes.min(MAX_READ_BYTES); + let target = resolve(path, roots, Purpose::Read)?; + let real = match target { + Target::Existing(real) => real, + Target::Entry { parent, name } => { + return Err(Denial::new( + codes::NOT_FOUND, + format!("{} does not exist", parent.join(name).display()), + )); + } + }; + + #[cfg(test)] + hooks::BEFORE_READ_OPEN.with(|hook| { + if let Some(hook) = hook.borrow().as_ref() { + hook(); + } + }); + + let file = open_checked(&real, roots, false)?; + let meta = file.metadata().map_err(|e| io_denial(&real, &e))?; + if !meta.is_file() { + return Err(Denial::invalid(format!( + "{} is not a regular file", + real.display() + ))); + } + + let too_large = || { + Denial::new( + codes::TOO_LARGE, + format!("{} is larger than {max_bytes} bytes", real.display()), + ) + }; + if meta.len() > max_bytes { + return Err(too_large()); + } + + use std::io::Read; + let mut bytes = Vec::new(); + file.take(max_bytes + 1) + .read_to_end(&mut bytes) + .map_err(|e| io_denial(&real, &e))?; + // The file can grow between the size check and the read. + if bytes.len() as u64 > max_bytes { + return Err(too_large()); + } + + let text = String::from_utf8(bytes).map_err(|_| { + Denial::new( + codes::NOT_UTF8, + format!("{} is not UTF-8 text", real.display()), + ) + })?; + + Ok(json!({ "text": text })) +} + +/// `fs.stat`'s answer for the open entry behind `handle`. +#[cfg(windows)] +fn stat_value(handle: &OwnedHandle, path: &Path) -> Result { + let basic = query_file_basic(handle.raw()).map_err(|s| nt_io(path, "stat", s))?; + let std_info = query_file_standard(handle.raw()).map_err(|s| nt_io(path, "stat", s))?; + Ok(json!({ + "exists": true, + "kind": if std_info.Directory != 0 { "dir" } else { "file" }, + "size": std_info.EndOfFile, + "mtime_ms": filetime_to_mtime_ms(basic.LastWriteTime), + })) +} + +#[cfg(windows)] +pub fn stat(path: &str, roots: &[String]) -> Result { + validate_raw_spelling(path)?; + let vr = select_root(path, roots, SHARE_ALL)?; + let p = Path::new(path); + + if path == vr.manifest { + return stat_value(&vr.handle, p); + } + + let components = components_below(path, &vr.manifest); + let Some((last, middle)) = components.split_last() else { + return Err(outside(p)); + }; + let dirs = walk_dirs(&vr.handle, middle, Open::existing(ffi::SYNCHRONIZE)) + .map_err(|e| intermediate_denial(e, p))?; + let parent = dirs.last().unwrap_or(&vr.handle); + match nt_open_relative(parent.raw(), last, Open::existing(ffi::SYNCHRONIZE)) { + Ok(leaf) => { + let guid = get_volume_guid_path(leaf.raw())?; + if !guid_path_inside_component_wise(&guid, &vr.guid_path, vr.is_directory) { + return Err(outside(p)); + } + stat_value(&leaf, p) + } + Err(OpenError::NotFound) => Ok(json!({ "exists": false })), + Err(OpenError::ReparsePoint) => Err(Denial::denied(format!( + "{path} is a reparse point; fs refuses every reparse point" + ))), + Err(OpenError::AccessDenied) => Err(Denial::denied(format!("access to {path} is denied"))), + Err(e) => Err(nt_io(p, "opening", e.status())), + } +} + +/// Byte offsets of the `FILE_DIRECTORY_INFORMATION` fields a scan reads. +#[cfg(windows)] +const DIR_NEXT_OFFSET: usize = + std::mem::offset_of!(ffi::FILE_DIRECTORY_INFORMATION, NextEntryOffset); +#[cfg(windows)] +const DIR_ATTRIBUTES: usize = std::mem::offset_of!(ffi::FILE_DIRECTORY_INFORMATION, FileAttributes); +#[cfg(windows)] +const DIR_NAME_LENGTH: usize = + std::mem::offset_of!(ffi::FILE_DIRECTORY_INFORMATION, FileNameLength); +#[cfg(windows)] +const DIR_NAME: usize = std::mem::offset_of!(ffi::FILE_DIRECTORY_INFORMATION, FileName); + +#[cfg(windows)] +fn u32_at(bytes: &[u8], at: usize) -> u32 { + u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]) +} + +/// Hands each entry of the directory behind `handle` (not `.` or `..`) to +/// `visit` as its UTF-16 name and attributes, until `visit` answers false. +/// The handle needs `FILE_LIST_DIRECTORY`. Any failed query is an error, +/// the first one included: an unreadable directory is never reported as +/// an empty one. The reply is parsed as bytes and every entry is checked +/// against the length the kernel reported, so a malformed reply is an +/// error rather than a read past it. +#[cfg(windows)] +fn scan_directory( + handle: ffi::HANDLE, + mut visit: impl FnMut(&[u16], u32) -> bool, +) -> Result<(), ffi::NTSTATUS> { + // u64 storage: the kernel requires an 8-byte-aligned buffer. + let mut buffer = vec![0u64; 8 * 1024]; + let buffer_bytes = buffer.len() * std::mem::size_of::(); + let mut restart = 1u8; + loop { + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + // SAFETY: the buffer is `buffer_bytes` long and outlives the call. + let status = unsafe { + ffi::NtQueryDirectoryFile( + handle, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut io_status, + buffer.as_mut_ptr().cast(), + buffer_bytes as u32, + ffi::FileDirectoryInformation, + 0, + std::ptr::null_mut(), + restart, + ) + }; + restart = 0; + if status == ffi::STATUS_NO_MORE_FILES { + return Ok(()); + } + if status < 0 { + return Err(status); + } + let filled = io_status.Information; + if filled == 0 { + return Ok(()); + } + if filled > buffer_bytes { + return Err(ffi::STATUS_FILE_CORRUPT_ERROR); + } + // SAFETY: the first `filled` bytes of the buffer are initialised + // (it was zeroed) and lie within it. + let bytes = unsafe { std::slice::from_raw_parts(buffer.as_ptr().cast::(), filled) }; + let mut offset = 0usize; + loop { + let entry = &bytes[offset..]; + if entry.len() < DIR_NAME { + return Err(ffi::STATUS_FILE_CORRUPT_ERROR); + } + let next = u32_at(entry, DIR_NEXT_OFFSET) as usize; + let attributes = u32_at(entry, DIR_ATTRIBUTES); + let name_bytes = u32_at(entry, DIR_NAME_LENGTH) as usize; + let Some(raw_name) = entry.get(DIR_NAME..DIR_NAME + name_bytes) else { + return Err(ffi::STATUS_FILE_CORRUPT_ERROR); + }; + if !name_bytes.is_multiple_of(2) { + return Err(ffi::STATUS_FILE_CORRUPT_ERROR); + } + let name: Vec = raw_name + .as_chunks::<2>() + .0 + .iter() + .map(|unit| u16::from_le_bytes(*unit)) + .collect(); + let dot = u16::from(b'.'); + let is_dot = name == [dot] || name == [dot, dot]; + if !is_dot && !visit(&name, attributes) { + return Ok(()); + } + if next == 0 { + break; + } + offset = match offset.checked_add(next) { + Some(o) if o < filled => o, + _ => return Err(ffi::STATUS_FILE_CORRUPT_ERROR), + }; + } + } +} + +#[cfg(windows)] +pub fn list(path: &str, roots: &[String]) -> Result { + use std::os::windows::io::AsRawHandle; + let real = match resolve(path, roots, Purpose::Read)? { + Target::Existing(real) => real, + Target::Entry { parent, name } => { + return Err(Denial::new( + codes::NOT_FOUND, + format!("{} does not exist", parent.join(name).display()), + )); + } + }; + let dir = open_checked(&real, roots, true)?; + + let mut names: Vec<(Vec, u32)> = Vec::new(); + scan_directory(dir.as_raw_handle(), |name, attributes| { + names.push((name.to_vec(), attributes)); + names.len() <= MAX_LIST_ENTRIES + }) + .map_err(|s| nt_io(&real, "listing", s))?; + if names.len() > MAX_LIST_ENTRIES { + return Err(Denial::new( + codes::TOO_LARGE, + format!( + "{} has more than {MAX_LIST_ENTRIES} entries", + real.display() + ), + )); + } + + let mut entries = Vec::with_capacity(names.len()); + for (name, attributes) in names { + let kind = if attributes & ffi::FILE_ATTRIBUTE_REPARSE_POINT != 0 { + "symlink" + } else if attributes & ffi::FILE_ATTRIBUTE_DIRECTORY != 0 { + "dir" + } else { + "file" + }; + let Ok(text) = String::from_utf16(&name) else { + return Err(Denial::new( + codes::NOT_UTF8, + format!("{} holds a name that is not UTF-8", real.display()), + )); + }; + entries.push((text, kind)); + } + entries.sort(); + Ok(Value::Array( + entries + .into_iter() + .map(|(name, kind)| json!({ "name": name, "kind": kind })) + .collect(), + )) +} + +/// The DACL of the file a write is about to replace, or `None` when there +/// is no such file and the new one takes what NTFS inherits for it. +/// Refuses a reparse point and anything that is not a regular file, as the +/// Unix write refuses a symlink and a non-regular file. +#[cfg(windows)] +fn existing_target_dacl( + parent: &OwnedHandle, + leaf: &str, + target: &Path, +) -> Result, Denial> { + let file = match nt_open_relative( + parent.raw(), + leaf, + Open::existing(ffi::READ_CONTROL | ffi::SYNCHRONIZE).non_directory(), + ) { + Ok(file) => file, + Err(OpenError::NotFound) => return Ok(None), + Err(OpenError::ReparsePoint) => { + return Err(Denial::denied(format!( + "{} is a symlink; fs.write does not replace symlinks", + target.display() + ))); + } + Err(OpenError::IsADirectory) => { + return Err(Denial::invalid(format!( + "{} is not a regular file", + target.display() + ))); + } + Err(OpenError::AccessDenied) => { + return Err(Denial::denied(format!( + "the permissions of {} cannot be read", + target.display() + ))); + } + Err(e) => return Err(nt_io(target, "opening", e.status())), + }; + let mut sd = std::ptr::null_mut(); + // SAFETY: `file` is open with READ_CONTROL; on success `sd` receives a + // LocalAlloc'd descriptor that FileDacl frees. + let err = unsafe { + ffi::GetSecurityInfo( + file.raw(), + ffi::SE_FILE_OBJECT, + ffi::DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut sd, + ) + }; + if err != 0 { + return Err(Denial::new( + codes::IO, + format!( + "{}: reading its permissions failed (error {err})", + target.display() + ), + )); + } + Ok(Some(FileDacl { sd })) +} + +/// Marks the open file behind `handle` for deletion; it goes when the last +/// handle to it closes. +#[cfg(windows)] +fn delete_by_handle(handle: &OwnedHandle) -> Result<(), ffi::NTSTATUS> { + let mut disp = ffi::FILE_DISPOSITION_INFORMATION { DeleteFile: 1 }; + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + // SAFETY: the buffer passed is `disp`, and the length passed is its + // size. + let status = unsafe { + ffi::NtSetInformationFile( + handle.raw(), + &mut io_status, + (&mut disp as *mut ffi::FILE_DISPOSITION_INFORMATION).cast(), + std::mem::size_of::() as u32, + ffi::FileDispositionInformation, + ) + }; + if status < 0 { Err(status) } else { Ok(()) } +} + +/// What [`unlink_file`] found under a name. +#[cfg(windows)] +enum Unlinked { + Removed, + Absent, + /// Something other than a regular file, left in place: what it is. + NotRegular(&'static str), +} + +/// Removes `name` from the directory behind `parent` only if it is a +/// regular file, as the Unix `unlink_regular` does. A directory or a +/// reparse point under the name is left alone, and a link is never +/// followed, so nothing but that one entry can be removed. +#[cfg(windows)] +fn unlink_file(parent: ffi::HANDLE, name: &str) -> Result { + let file = match nt_open_relative( + parent, + name, + Open::existing(ffi::DELETE | ffi::SYNCHRONIZE).non_directory(), + ) { + Ok(file) => file, + Err(OpenError::NotFound) => return Ok(Unlinked::Absent), + Err(OpenError::ReparsePoint) => return Ok(Unlinked::NotRegular("reparse point")), + Err(OpenError::IsADirectory) => return Ok(Unlinked::NotRegular("directory")), + Err(e) => return Err(e.status()), + }; + delete_by_handle(&file)?; + Ok(Unlinked::Removed) +} + +/// Flushes the file or directory behind `handle` to disk. +#[cfg(windows)] +fn flush(handle: &OwnedHandle) -> ffi::NTSTATUS { + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + // SAFETY: `handle` is open; the status block is a live local. + unsafe { ffi::NtFlushBuffersFile(handle.raw(), &mut io_status) } +} + +#[cfg(windows)] +pub fn write(path: &str, roots: &[String], text: &str) -> Result { + let key = format!( + "local-{}-{}", + std::process::id(), + TEMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ); + write_call(path, roots, text, &key, None) +} + +/// The directory a write to a path acts in, held open from the volume root +/// down, and the name the write replaces in it. +#[cfg(windows)] +struct WriteParent { + root: VerifiedRoot, + /// The directories between a directory root and the parent, the parent + /// last. Empty when the parent is the root, or for a file root. + below: Vec, + /// The parent directory, as spelled in the path. + dir: PathBuf, + /// The last component of the path. + leaf: String, +} + +#[cfg(windows)] +impl WriteParent { + /// The handle on the directory the write acts in. + fn parent(&self) -> &OwnedHandle { + if let Some(dir) = self.below.last() { + dir + } else if self.root.is_directory { + &self.root.handle + } else { + self.root + .ancestors + .last() + .expect("a file root has a parent directory") + } + } + + /// Checks where the parent directory really is, against the root. + fn check_inside(&self) -> Result<(), Denial> { + let guid = get_volume_guid_path(self.parent().raw())?; + let inside = if self.root.is_directory { + guid_path_inside_component_wise(&guid, &self.root.guid_path, true) + } else { + guid_is_parent_of(&guid, &self.root.guid_path) + }; + if inside { + Ok(()) + } else { + Err(outside(&self.dir.join(&self.leaf))) + } + } +} + +/// Selects the root for `path` by the whole path (so a file root grants a +/// write to itself), walks to the directory that holds it and checks that +/// directory. Every directory from the volume root down to that one is +/// opened with the sharing mode `share` and stays open as long as the +/// returned value lives, so a caller passing [`SHARE_NO_DELETE`] keeps them +/// all from being moved until it drops the value. +#[cfg(windows)] +fn open_write_parent(path: &str, roots: &[String], share: u32) -> Result { + validate_raw_spelling(path)?; + let p = Path::new(path); + let (Some(parent), Some(leaf)) = (p.parent(), p.file_name().and_then(OsStr::to_str)) else { + return Err(outside(p)); + }; + let parent_str = parent.to_str().ok_or_else(|| outside(p))?; + let root = select_root(path, roots, share)?; + let below = if root.is_directory { + // The directory root itself is not an entry a write can replace. + if path == root.manifest || !path_under(parent_str, &root.manifest) { + return Err(outside(p)); + } + let components = components_below(parent_str, &root.manifest); + walk_dirs( + &root.handle, + &components, + Open::existing(DIR_WALK).share(share), + ) + .map_err(|e| intermediate_denial(e, p))? + } else { + // select_root grants a file root only to its own path. Its parent + // is the last of the root's ancestors, which walk_from_volume_root + // opened and keeps in `root.ancestors`; WriteParent::parent uses it. + Vec::new() + }; + let wp = WriteParent { + root, + below, + dir: parent.to_path_buf(), + leaf: leaf.to_owned(), + }; + wp.check_inside()?; + Ok(wp) +} + +#[cfg(windows)] +pub fn write_call( + path: &str, + roots: &[String], + text: &str, + call_key: &str, + ledger: Option<&dyn TempLedger>, +) -> Result { + check_write_size(text.len())?; + validate_raw_spelling(path)?; + let temp_name_os = temp_name(call_key)?; + let temp_str = temp_name_os + .to_str() + .ok_or_else(|| Denial::invalid("temporary file name is not valid UTF-8"))?; + + // `wp` holds every directory from the volume root down to the parent, + // opened without FILE_SHARE_DELETE, until write_call returns. Renaming + // or deleting a directory needs it opened for DELETE, which those + // handles refuse, so none can be moved out of the root before the + // rename below lands where the check said it would. + let wp = open_write_parent(path, roots, SHARE_NO_DELETE)?; + let target_path = wp.dir.join(&wp.leaf); + let temp_path = wp.dir.join(temp_str); + + // A replaced file keeps its DACL. A new file gets no explicit + // descriptor, so NTFS gives it what the folder's inheritable entries + // grant, marked inherited, and later changes to the folder reach it. + let target_dacl = existing_target_dacl(wp.parent(), &wp.leaf, &target_path)?; + + let lease = TempLease { + call_key: call_key.to_owned(), + dir: wp.dir.clone(), + target: OsString::from(&wp.leaf), + temp: temp_name_os.clone(), + roots: roots.to_vec(), + }; + // With a ledger, the record is durable before the file exists, so no + // crash can leave a temporary file that nothing has recorded. + let hold = match ledger { + Some(l) => Some(l.record(&lease).map_err(|e| { + Denial::new( + codes::IO, + format!( + "the temporary file for {} could not be recorded: {e}", + target_path.display() + ), + ) + })?), + None => None, + }; + let clear = |hold: Option>| { + if let Some(h) = hold { + h.clear(); + } + }; + + let create = Open { + access: ffi::FILE_GENERIC_WRITE | ffi::DELETE | ffi::SYNCHRONIZE, + share: SHARE_ALL, + disposition: ffi::FILE_CREATE, + options: ffi::FILE_NON_DIRECTORY_FILE, + security_descriptor: target_dacl.as_ref().map_or(std::ptr::null_mut(), |d| d.sd), + }; + // On a failed create the record stays: a file an earlier send left + // under this name may still be there, and cleanup checks for it. + let mut replaced = false; + let temp = loop { + match nt_open_relative(wp.parent().raw(), temp_str, create) { + Ok(file) => break file, + // The name belongs to this call alone, so a regular file already + // under it is what an earlier send of the same call left. It is + // replaced once; a directory or link there is an error. + Err(OpenError::Collision) + if !replaced + && matches!( + unlink_file(wp.parent().raw(), temp_str), + Ok(Unlinked::Removed) + ) => + { + replaced = true; + } + Err(e) => return Err(nt_io(&temp_path, "creating", e.status())), + } + }; + if let Some(l) = ledger { + l.created(&lease); + } + + // On a failure from here on, the temporary file is removed; the record + // is cleared only when that worked, otherwise cleanup tries again. + let abandon = |temp: OwnedHandle, hold: Option>| { + if delete_by_handle(&temp).is_ok() { + drop(temp); + clear(hold); + } + }; + + let bytes = text.as_bytes(); + let mut written = 0usize; + while written < bytes.len() { + let chunk = &bytes[written..]; + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let mut offset = written as i64; + // SAFETY: `chunk` is live for the call; its length is at most + // MAX_WRITE_BYTES, so it fits in a u32. + let status = unsafe { + ffi::NtWriteFile( + temp.raw(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut io_status, + chunk.as_ptr().cast(), + chunk.len() as u32, + &mut offset, + std::ptr::null_mut(), + ) + }; + if status < 0 || io_status.Information == 0 { + abandon(temp, hold); + return Err(nt_io(&temp_path, "writing", status)); + } + written += io_status.Information; + } + + // The data must be on disk before the rename makes it the target: the + // rename is journaled, the data is not, so renaming unflushed data can + // leave an empty or partial file after a crash. A failed flush stops + // the write, as a failed sync does on Unix. + #[cfg(test)] + let flush_status = if hooks::FAIL_TEMP_FLUSH.with(std::cell::Cell::get) { + ffi::STATUS_IO_DEVICE_ERROR + } else { + flush(&temp) + }; + #[cfg(not(test))] + let flush_status = flush(&temp); + if flush_status < 0 { + abandon(temp, hold); + return Err(nt_io( + &target_path, + "flushing the new contents", + flush_status, + )); + } + + // The parent is held, but where it is gets checked again before the + // rename so the write does not rely on the hold alone. + if let Err(d) = wp.check_inside() { + abandon(temp, hold); + return Err(d); + } + + // FileRenameInformationEx with POSIX semantics replaces the target even + // while it is open. If the volume refuses it, the write fails: there is + // no fallback to a non-POSIX rename or a copy. + // A read-only target is replaced, as renameat ignores a target's mode. + let wide_target: Vec = wp.leaf.encode_utf16().collect(); + let name_bytes = wide_target.len() * std::mem::size_of::(); + let name_offset = std::mem::offset_of!(ffi::FILE_RENAME_INFORMATION_EX, FileName); + let struct_size = + (name_offset + name_bytes).max(std::mem::size_of::()); + // u64 storage gives the 8-byte alignment the structure needs. + let mut rename_buf = vec![0u64; struct_size.div_ceil(std::mem::size_of::())]; + let rename_info = rename_buf + .as_mut_ptr() + .cast::(); + // SAFETY: the buffer is aligned for the structure and holds its header + // plus `name_bytes` of name; fields are written through the raw pointer + // (no reference is made), and the name is copied through a pointer + // derived from it, so it may run past the declared one-element array. + unsafe { + std::ptr::addr_of_mut!((*rename_info).Flags).write( + ffi::FILE_RENAME_REPLACE_IF_EXISTS + | ffi::FILE_RENAME_POSIX_SEMANTICS + | ffi::FILE_RENAME_IGNORE_READONLY_ATTRIBUTE, + ); + std::ptr::addr_of_mut!((*rename_info).RootDirectory).write(wp.parent().raw()); + std::ptr::addr_of_mut!((*rename_info).FileNameLength).write(name_bytes as u32); + std::ptr::copy_nonoverlapping( + wide_target.as_ptr(), + std::ptr::addr_of_mut!((*rename_info).FileName).cast::(), + wide_target.len(), + ); + } + + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let rename = |io_status: &mut ffi::IO_STATUS_BLOCK| { + // SAFETY: `rename_info` points into `rename_buf`, which is + // `struct_size` bytes or more and outlives the call. + unsafe { + ffi::NtSetInformationFile( + temp.raw(), + io_status, + rename_info.cast(), + struct_size as u32, + ffi::FileRenameInformationEx, + ) + } + }; + #[cfg(test)] + let rename_status = if hooks::REFUSE_POSIX_RENAME.with(std::cell::Cell::get) { + ffi::STATUS_NOT_SUPPORTED + } else { + rename(&mut io_status) + }; + #[cfg(not(test))] + let rename_status = rename(&mut io_status); + + if rename_status < 0 { + abandon(temp, hold); + if rename_status == ffi::STATUS_NOT_SUPPORTED + || rename_status == ffi::STATUS_INVALID_PARAMETER + { + return Err(Denial::denied(format!( + "volume does not support POSIX replace rename (status 0x{:08x})", + rename_status as u32 + ))); + } + return Err(nt_io( + &target_path, + "renaming the new contents into place", + rename_status, + )); + } + drop(temp); + + // Make the rename itself durable. Best effort, as on Unix: a failure + // here leaves the new file in place. Flushing a directory needs write + // access, which the held handle does not carry. + if let Ok(dir) = nt_reopen( + wp.parent(), + Open::existing(ffi::FILE_WRITE_DATA | ffi::SYNCHRONIZE).directory(), + ) { + let _ = flush(&dir); + } + + clear(hold); + Ok(json!({ "bytes": text.len() })) +} + +#[cfg(windows)] +pub fn remove_temp(lease: &TempLease) -> Result { + if !is_temp_name(&lease.temp) && !is_legacy_temp_name(&lease.temp) { + return Ok(TempRemoval::Refused(Denial::invalid( + "the record does not name a temporary file", + ))); + } + let Some(temp_str) = lease.temp.to_str() else { + return Ok(TempRemoval::Refused(Denial::invalid( + "the recorded temporary file name is not UTF-8", + ))); + }; + let joined = lease.dir.join(&lease.target); + let Some(path) = joined.to_str() else { + return Ok(TempRemoval::Refused(Denial::invalid( + "the recorded path is not UTF-8", + ))); + }; + + // The lease's directory is reached exactly as write_call reached it: + // the root chosen by the whole path, then a walk down to `lease.dir`. + let wp = match open_write_parent(path, &lease.roots, SHARE_ALL) { + Ok(wp) => wp, + Err(d) if d.code == codes::NOT_FOUND => return Ok(TempRemoval::Absent), + Err(d) if d.code == codes::IO => return Err(d), + Err(d) => return Ok(TempRemoval::Refused(d)), + }; + // The walk follows the spelling of `lease.dir` and refuses every reparse + // point, so it cannot end anywhere that spelling does not name. What + // this check catches is a `lease.target` that is not one plain name: one + // holding a separator makes the parent of `dir\target` a deeper + // directory than `lease.dir`. + if wp.dir != lease.dir || OsStr::new(&wp.leaf) != lease.target { + return Ok(TempRemoval::Refused(Denial::denied(format!( + "{} now resolves to {}", + joined.display(), + wp.dir.join(&wp.leaf).display() + )))); + } + + let temp_path = wp.dir.join(temp_str); + Ok(match unlink_file(wp.parent().raw(), temp_str) { + Ok(Unlinked::Removed) => TempRemoval::Removed, + Ok(Unlinked::Absent) => TempRemoval::Absent, + Ok(Unlinked::NotRegular(kind)) => TempRemoval::Refused(Denial::denied(format!( + "{} is a {kind}, not a temporary file", + temp_path.display() + ))), + Err(status) => return Err(nt_io(&temp_path, "removing", status)), + }) +} + +#[cfg(windows)] +pub fn remove_legacy_temps(path: &str, roots: &[String]) -> Result { + let wp = open_write_parent(path, roots, SHARE_ALL)?; + let lister = nt_reopen( + wp.parent(), + Open::existing(ffi::FILE_LIST_DIRECTORY | ffi::SYNCHRONIZE).directory(), + ) + .map_err(|e| nt_io(&wp.dir, "opening the directory to list", e.status()))?; + let mut names = Vec::new(); + scan_directory(lister.raw(), |name, _| { + if let Ok(name) = String::from_utf16(name) + && is_legacy_temp_name(OsStr::new(&name)) + { + names.push(name); + } + true + }) + .map_err(|s| nt_io(&wp.dir, "listing", s))?; + let mut removed = 0; + for name in names { + // Each name is examined again as it is removed: only a regular + // file goes, whatever the listing said it was. + match unlink_file(wp.parent().raw(), &name) { + Ok(Unlinked::Removed) => removed += 1, + Ok(_) => {} + Err(status) => return Err(nt_io(&wp.dir.join(&name), "removing", status)), + } + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spelling_accepts_ordinary_drive_paths() { + assert!(validate_raw_spelling(r"C:\").is_ok()); + assert!(validate_raw_spelling(r"C:\dir").is_ok()); + assert!(validate_raw_spelling(r"C:\dir\file.txt").is_ok()); + assert!(validate_raw_spelling(r"d:\nested\sub\folder\file").is_ok()); + } + + #[test] + fn spelling_refuses_unc_and_device_namespaces() { + assert!(validate_raw_spelling(r"\\server\share\file").is_err()); + assert!(validate_raw_spelling(r"//server/share/file").is_err()); + assert!(validate_raw_spelling(r"\\.\COM1").is_err()); + assert!(validate_raw_spelling(r"//./COM1").is_err()); + assert!(validate_raw_spelling(r"\\?\C:\file").is_err()); + assert!(validate_raw_spelling(r"//?/C:/file").is_err()); + assert!(validate_raw_spelling(r"\??\C:\file").is_err()); + assert!(validate_raw_spelling(r"/??/C:/file").is_err()); + } + + #[test] + fn spelling_refuses_drive_relative() { + assert!(validate_raw_spelling("C:").is_err()); + assert!(validate_raw_spelling("C:file").is_err()); + assert!(validate_raw_spelling(r"C:dir\file").is_err()); + assert!(validate_raw_spelling("C:/file").is_err()); + } + + #[test] + fn spelling_refuses_alternate_data_streams() { + assert!(validate_raw_spelling(r"C:\file:stream").is_err()); + assert!(validate_raw_spelling(r"C:\file::$DATA").is_err()); + assert!(validate_raw_spelling(r"C:\dir:ads\file").is_err()); + } + + #[test] + fn spelling_refuses_trailing_dots_and_spaces() { + assert!(validate_raw_spelling(r"C:\dir\file.").is_err()); + assert!(validate_raw_spelling(r"C:\dir\file ").is_err()); + assert!(validate_raw_spelling(r"C:\dir \file").is_err()); + assert!(validate_raw_spelling(r"C:\dir.\file").is_err()); + assert!(validate_raw_spelling(r"C:\dir\file.txt.").is_err()); + } + + #[test] + fn spelling_refuses_reserved_device_names() { + assert!(validate_raw_spelling(r"C:\CON").is_err()); + assert!(validate_raw_spelling(r"C:\con.txt").is_err()); + assert!(validate_raw_spelling(r"C:\dir\PRN").is_err()); + assert!(validate_raw_spelling(r"C:\dir\aux.dat").is_err()); + assert!(validate_raw_spelling(r"C:\NUL.tar.gz").is_err()); + assert!(validate_raw_spelling(r"C:\COM1").is_err()); + assert!(validate_raw_spelling(r"C:\com9.txt").is_err()); + assert!(validate_raw_spelling(r"C:\LPT1").is_err()); + assert!(validate_raw_spelling(r"C:\lpt8.log").is_err()); + assert!(validate_raw_spelling(r"C:\conin$").is_err()); + assert!(validate_raw_spelling(r"C:\conout$").is_err()); + assert!(validate_raw_spelling("C:\\COM\u{b9}").is_err()); + assert!(validate_raw_spelling("C:\\lpt\u{b3}.txt").is_err()); + assert!(validate_raw_spelling("C:\\COM\u{b4}").is_ok()); + + // Not reserved + assert!(validate_raw_spelling(r"C:\context.txt").is_ok()); + assert!(validate_raw_spelling(r"C:\connect").is_ok()); + assert!(validate_raw_spelling(r"C:\auxiliary.dat").is_ok()); + } + + #[test] + fn spelling_refuses_relative_and_empty_components() { + assert!(validate_raw_spelling(r"C:\dir\..\file").is_err()); + assert!(validate_raw_spelling(r"C:\dir\.\file").is_err()); + assert!(validate_raw_spelling(r"C:\dir\\file").is_err()); + assert!(validate_raw_spelling(r"C:\dir\").is_err()); + } + + #[test] + fn spelling_refuses_forward_slashes() { + assert!(validate_raw_spelling("C:/dir/file").is_err()); + assert!(validate_raw_spelling(r"C:\dir/file").is_err()); + } + + #[test] + fn guid_path_inside_component_wise_matches() { + let root = r"\\?\Volume{12345678-0000-0000-0000-000000000000}\Users\admin\root"; + let child = + r"\\?\Volume{12345678-0000-0000-0000-000000000000}\Users\admin\root\sub\file.txt"; + let sibling = + r"\\?\Volume{12345678-0000-0000-0000-000000000000}\Users\admin\rootx\file.txt"; + let case_diff = + r"\\?\Volume{12345678-0000-0000-0000-000000000000}\users\admin\root\file.txt"; + let other_vol = + r"\\?\Volume{87654321-0000-0000-0000-000000000000}\Users\admin\root\sub\file.txt"; + + // Directory root + assert!(guid_path_inside_component_wise(child, root, true)); + assert!(guid_path_inside_component_wise(root, root, true)); + assert!(!guid_path_inside_component_wise(sibling, root, true)); + assert!(!guid_path_inside_component_wise(case_diff, root, true)); + assert!(!guid_path_inside_component_wise(other_vol, root, true)); + + // File root + let file_root = + r"\\?\Volume{12345678-0000-0000-0000-000000000000}\Users\admin\root\file.txt"; + let file_child = + r"\\?\Volume{12345678-0000-0000-0000-000000000000}\Users\admin\root\file.txt\sub"; + let file_sibling = + r"\\?\Volume{12345678-0000-0000-0000-000000000000}\Users\admin\root\other.txt"; + + assert!(guid_path_inside_component_wise(file_root, file_root, false)); + assert!(!guid_path_inside_component_wise( + file_child, file_root, false + )); + assert!(!guid_path_inside_component_wise( + file_sibling, + file_root, + false + )); + } + + #[test] + fn path_under_matches_by_component_and_volume_roots_cover_their_drive() { + assert!(path_under(r"C:\root", r"C:\root")); + assert!(path_under(r"C:\root\a\b.txt", r"C:\root")); + assert!(!path_under(r"C:\rootx\a.txt", r"C:\root")); + assert!(!path_under(r"C:\other", r"C:\root")); + assert!(path_under(r"C:\", r"C:\")); + assert!(path_under(r"C:\x", r"C:\")); + assert!(path_under(r"C:\x\y.txt", r"C:\")); + assert!(!path_under(r"D:\x", r"C:\")); + } + + #[test] + fn components_below_handles_directory_and_volume_roots() { + assert!(components_below(r"C:\root", r"C:\root").is_empty()); + assert_eq!(components_below(r"C:\root\a\b", r"C:\root"), ["a", "b"]); + assert!(components_below(r"C:\", r"C:\").is_empty()); + assert_eq!(components_below(r"C:\x\y", r"C:\"), ["x", "y"]); + } + + #[cfg(windows)] + mod win_tests { + use super::*; + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use std::sync::Mutex; + + /// Win32 calls only the tests make. + #[allow(non_snake_case)] + mod test_ffi { + use std::ffi::c_void; + + pub const SDDL_REVISION_1: u32 = 1; + pub const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; + pub const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + pub const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + pub const ERROR_SHARING_VIOLATION: i32 = 32; + + #[link(name = "advapi32")] + unsafe extern "system" { + pub fn ConvertStringSecurityDescriptorToSecurityDescriptorW( + StringSecurityDescriptor: *const u16, + StringSDRevision: u32, + SecurityDescriptor: *mut *mut c_void, + SecurityDescriptorSize: *mut u32, + ) -> i32; + + pub fn ConvertSecurityDescriptorToStringSecurityDescriptorW( + SecurityDescriptor: *mut c_void, + RequestedStringSDRevision: u32, + SecurityInformation: u32, + StringSecurityDescriptor: *mut *mut u16, + StringSecurityDescriptorLen: *mut u32, + ) -> i32; + + pub fn SetFileSecurityW( + lpFileName: *const u16, + SecurityInformation: u32, + pSecurityDescriptor: *mut c_void, + ) -> i32; + } + + #[link(name = "kernel32")] + unsafe extern "system" { + pub fn GetVolumeNameForVolumeMountPointW( + lpszVolumeMountPoint: *const u16, + lpszVolumeName: *mut u16, + cchBufferLength: u32, + ) -> i32; + + pub fn SetVolumeMountPointW( + lpszVolumeMountPoint: *const u16, + lpszVolumeName: *const u16, + ) -> i32; + + pub fn DeleteVolumeMountPointW(lpszVolumeMountPoint: *const u16) -> i32; + } + } + + struct WinTree { + base: PathBuf, + root: PathBuf, + outside: PathBuf, + } + + impl WinTree { + fn new(tag: &str) -> Self { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let base = std::env::temp_dir().join(format!( + "basal-win-tree-{tag}-{}-{}", + std::process::id(), + SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let root = base.join("root"); + let outside = base.join("outside"); + std::fs::create_dir_all(root.join("sub")).expect("create root"); + std::fs::create_dir_all(&outside).expect("create outside"); + std::fs::write(root.join("a.txt"), "inside").expect("write a.txt"); + std::fs::write(root.join("sub\\b.txt"), "nested").expect("write b.txt"); + std::fs::write(outside.join("secret.txt"), "secret").expect("write secret"); + Self { + base, + root, + outside, + } + } + + fn roots(&self) -> Vec { + vec![self.root.display().to_string()] + } + + fn p(&self, rel: &str) -> String { + self.root.join(rel).display().to_string() + } + + /// The names in the root directory that are temporary files. + fn temps_in(&self, rel: &str) -> Vec { + std::fs::read_dir(self.root.join(rel)) + .expect("read dir") + .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with(".basal-")) + .collect() + } + } + + impl Drop for WinTree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.base); + } + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(Some(0)).collect() + } + + /// Fails the test unless `path` is a symlink, junction or mount point. + fn assert_link(path: &Path) { + let meta = std::fs::symlink_metadata(path) + .unwrap_or_else(|e| panic!("{} was not created: {e}", path.display())); + assert!( + meta.file_type().is_symlink(), + "{} is not a link", + path.display() + ); + } + + /// Creates a file symlink. Needs SeCreateSymbolicLinkPrivilege, which + /// an elevated administrator (as on GitHub's Windows runners) holds. + fn file_symlink(link: &Path, target: &Path) { + std::os::windows::fs::symlink_file(target, link).unwrap_or_else(|e| { + panic!( + "creating symlink {} failed (needs SeCreateSymbolicLinkPrivilege): {e}", + link.display() + ) + }); + assert_link(link); + } + + /// Creates a directory junction; needs no privilege. + fn junction(link: &Path, target: &Path) { + let status = std::process::Command::new("cmd") + .args([ + "/c", + "mklink", + "/J", + link.to_str().unwrap(), + target.to_str().unwrap(), + ]) + .status() + .expect("run mklink /J"); + assert!(status.success(), "mklink /J {} failed", link.display()); + assert_link(link); + } + + /// Sets the DACL of `path` from an SDDL string, including its + /// protection flag. + fn set_dacl(path: &Path, sddl: &str) { + let wide_sddl = wide(sddl); + let mut sd = std::ptr::null_mut(); + let ok = unsafe { + test_ffi::ConvertStringSecurityDescriptorToSecurityDescriptorW( + wide_sddl.as_ptr(), + test_ffi::SDDL_REVISION_1, + &mut sd, + std::ptr::null_mut(), + ) + }; + assert_ne!(ok, 0, "convert {sddl}: {}", std::io::Error::last_os_error()); + let mut info = ffi::DACL_SECURITY_INFORMATION; + if sddl.starts_with("D:P") { + info |= test_ffi::PROTECTED_DACL_SECURITY_INFORMATION; + } + let wide_path = wide(path.to_str().unwrap()); + let ok = unsafe { test_ffi::SetFileSecurityW(wide_path.as_ptr(), info, sd) }; + let err = std::io::Error::last_os_error(); + unsafe { ffi::LocalFree(sd) }; + assert_ne!(ok, 0, "set the DACL of {}: {err}", path.display()); + } + + /// The DACL of `path` as SDDL, read without following a link. + fn dacl_of(path: &Path) -> Result { + let file = std::fs::OpenOptions::new() + .access_mode(ffi::READ_CONTROL) + .custom_flags( + test_ffi::FILE_FLAG_BACKUP_SEMANTICS | test_ffi::FILE_FLAG_OPEN_REPARSE_POINT, + ) + .open(path) + .map_err(|e| format!("open {}: {e}", path.display()))?; + let mut sd = std::ptr::null_mut(); + let err = unsafe { + ffi::GetSecurityInfo( + file.as_raw_handle(), + ffi::SE_FILE_OBJECT, + ffi::DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut sd, + ) + }; + if err != 0 { + return Err(format!("GetSecurityInfo {}: {err}", path.display())); + } + let mut text = std::ptr::null_mut(); + let ok = unsafe { + test_ffi::ConvertSecurityDescriptorToStringSecurityDescriptorW( + sd, + test_ffi::SDDL_REVISION_1, + ffi::DACL_SECURITY_INFORMATION, + &mut text, + std::ptr::null_mut(), + ) + }; + let result = if ok == 0 || text.is_null() { + Err(format!("convert the DACL of {} to SDDL", path.display())) + } else { + let mut len = 0; + while unsafe { *text.add(len) } != 0 { + len += 1; + } + let units = unsafe { std::slice::from_raw_parts(text, len) }; + let sddl = String::from_utf16_lossy(units); + unsafe { ffi::LocalFree(text.cast()) }; + Ok(sddl) + }; + unsafe { ffi::LocalFree(sd) }; + result + } + + /// Splits `D:()()...` into its flags and ACEs. + fn dacl_parts(sddl: &str) -> (String, Vec) { + let rest = sddl + .strip_prefix("D:") + .unwrap_or_else(|| panic!("not a DACL: {sddl}")); + let (flags, aces) = rest.split_at(rest.find('(').unwrap_or(rest.len())); + let aces = aces + .split(')') + .filter(|a| !a.is_empty()) + .map(|a| a.trim_start_matches('(').to_owned()) + .collect(); + (flags.to_owned(), aces) + } + + /// A ledger that records the temporary file's DACL once it exists. + #[derive(Default)] + struct DaclObserver { + temp_dacl: Mutex>>, + } + + struct NoHold; + impl TempHold for NoHold { + fn clear(self: Box) {} + } + + impl TempLedger for DaclObserver { + fn record(&self, _lease: &TempLease) -> Result, String> { + Ok(Box::new(NoHold)) + } + + fn created(&self, lease: &TempLease) { + *self.temp_dacl.lock().unwrap() = Some(dacl_of(&lease.dir.join(&lease.temp))); + } + } + + impl DaclObserver { + fn temp_dacl(&self) -> String { + self.temp_dacl + .lock() + .unwrap() + .clone() + .expect("the ledger saw the temporary file") + .expect("the temporary file's DACL was read") + } + } + + #[test] + fn windows_read_and_stat_ordinary_drive_path() { + let t = WinTree::new("read-stat"); + let roots = t.roots(); + + let val = read(&t.p("a.txt"), &roots, 1024).expect("read a.txt"); + assert_eq!(val["text"], "inside"); + + let st = stat(&t.p("a.txt"), &roots).expect("stat a.txt"); + assert_eq!(st["exists"], true); + assert_eq!(st["kind"], "file"); + assert_eq!(st["size"], 6); + assert!(st["mtime_ms"].as_i64().unwrap_or(0) > 0); + + let st_missing = stat(&t.p("missing.txt"), &roots).expect("stat missing"); + assert_eq!(st_missing["exists"], false); + + let st_root = stat(&t.root.display().to_string(), &roots).expect("stat root"); + assert_eq!(st_root["kind"], "dir"); + + let outside_path = t.outside.join("secret.txt").display().to_string(); + let denial = stat(&outside_path, &roots).expect_err("outside"); + assert_eq!(denial.code, codes::DENIED); + } + + #[test] + fn windows_raw_spelling_refusals_before_open() { + let t = WinTree::new("spelling-refusals"); + let roots = t.roots(); + + let ads = format!("{}:stream", t.p("a.txt")); + let d1 = read(&ads, &roots, 1024).expect_err("ads"); + assert_eq!(d1.code, codes::INVALID_ARGUMENTS); + + let drive_rel = "C:a.txt"; + let d2 = read(drive_rel, &roots, 1024).expect_err("drive rel"); + assert_eq!(d2.code, codes::INVALID_ARGUMENTS); + + let con_path = t.p("con.txt"); + let d3 = read(&con_path, &roots, 1024).expect_err("device name"); + assert_eq!(d3.code, codes::INVALID_ARGUMENTS); + } + + #[test] + fn windows_symlink_escape_and_in_root_refused() { + let t = WinTree::new("symlink-refused"); + let roots = t.roots(); + + let out_link = t.root.join("symlink_out.txt"); + let in_link = t.root.join("symlink_in.txt"); + file_symlink(&out_link, &t.outside.join("secret.txt")); + file_symlink(&in_link, &t.root.join("a.txt")); + + let err_out = read(&out_link.display().to_string(), &roots, 1024) + .expect_err("symlink out denied"); + assert_eq!(err_out.code, codes::DENIED); + // Every reparse point is refused, in-root ones included. + let err_in = read(&in_link.display().to_string(), &roots, 1024) + .expect_err("in-root symlink denied"); + assert_eq!(err_in.code, codes::DENIED); + assert!( + err_in.message.contains("reparse point"), + "{}", + err_in.message + ); + + let err_write = + write(&out_link.display().to_string(), &roots, "x").expect_err("write link"); + assert_eq!(err_write.code, codes::DENIED); + assert_eq!( + std::fs::read_to_string(t.outside.join("secret.txt")).unwrap(), + "secret" + ); + } + + #[test] + fn windows_reparse_points_at_root_middle_leaf() { + let t = WinTree::new("reparse-points"); + let roots = t.roots(); + + // A junction as the last component. + let leaf_junc = t.root.join("leaf_junc"); + junction(&leaf_junc, &t.outside); + let st = stat(&leaf_junc.display().to_string(), &roots) + .expect_err("leaf reparse stat denial"); + assert_eq!(st.code, codes::DENIED); + assert!(st.message.contains("reparse point"), "{}", st.message); + + // A junction in the middle of the path. + let mid_junc = t.root.join("mid_junc"); + junction(&mid_junc, &t.outside); + let target = mid_junc.join("secret.txt").display().to_string(); + let err = read(&target, &roots, 1024).expect_err("mid reparse denial"); + assert_eq!(err.code, codes::DENIED); + assert!(err.message.contains("reparse point"), "{}", err.message); + + // The root itself is a junction: refused for being one, not + // for some other failure of the walk. + let root_junc = t.base.join("root_junc"); + junction(&root_junc, &t.root); + let junc_roots = vec![root_junc.display().to_string()]; + let target = root_junc.join("a.txt").display().to_string(); + let err = read(&target, &junc_roots, 1024).expect_err("root reparse denial"); + assert_eq!(err.code, codes::DENIED); + assert!(err.message.contains("reparse point"), "{}", err.message); + } + + /// Removes a volume mount point when the test ends, however it ends. + struct MountPoint(Vec); + + impl Drop for MountPoint { + fn drop(&mut self) { + unsafe { test_ffi::DeleteVolumeMountPointW(self.0.as_ptr()) }; + } + } + + #[test] + fn windows_mount_point_refused() { + let t = WinTree::new("mount-point"); + let roots = t.roots(); + let mnt = t.root.join("mnt"); + std::fs::create_dir(&mnt).expect("create mount directory"); + + // Mount the volume the tree is on at root\mnt. Needs an + // administrator, as on GitHub's Windows runners. + let drive = &t.root.to_str().unwrap()[..3]; + let mut volume = vec![0u16; 64]; + let ok = unsafe { + test_ffi::GetVolumeNameForVolumeMountPointW( + wide(drive).as_ptr(), + volume.as_mut_ptr(), + volume.len() as u32, + ) + }; + assert_ne!( + ok, + 0, + "volume name of {drive}: {}", + std::io::Error::last_os_error() + ); + let mount_at = wide(&format!("{}\\", mnt.display())); + let ok = unsafe { test_ffi::SetVolumeMountPointW(mount_at.as_ptr(), volume.as_ptr()) }; + assert_ne!( + ok, + 0, + "mount {drive} at {} (needs an administrator): {}", + mnt.display(), + std::io::Error::last_os_error() + ); + let _mounted = MountPoint(mount_at); + assert_link(&mnt); + + let st = stat(&mnt.display().to_string(), &roots).expect_err("stat mount point"); + assert_eq!(st.code, codes::DENIED); + assert!(st.message.contains("reparse point"), "{}", st.message); + + let through = mnt.join("Windows").display().to_string(); + let err = list(&through, &roots).expect_err("list through mount point"); + assert_eq!(err.code, codes::DENIED); + assert!(err.message.contains("reparse point"), "{}", err.message); + + let mnt_roots = vec![mnt.display().to_string()]; + let err = list(&mnt.display().to_string(), &mnt_roots).expect_err("mount point root"); + assert_eq!(err.code, codes::DENIED); + assert!(err.message.contains("reparse point"), "{}", err.message); + } + + #[test] + fn windows_dotdot_and_relative_escapes() { + let t = WinTree::new("dotdot-escapes"); + let roots = t.roots(); + + let p1 = t.root.join("..\\outside\\secret.txt").display().to_string(); + assert_eq!( + read(&p1, &roots, 1024).expect_err("dotdot").code, + codes::INVALID_ARGUMENTS + ); + + let p2 = t.root.join("sub\\..\\a.txt").display().to_string(); + assert_eq!( + read(&p2, &roots, 1024).expect_err("dotdot").code, + codes::INVALID_ARGUMENTS + ); + + let p3 = t.root.join(".\\a.txt").display().to_string(); + assert_eq!( + read(&p3, &roots, 1024).expect_err("dot").code, + codes::INVALID_ARGUMENTS + ); + } + + #[test] + fn windows_ads_spellings() { + let t = WinTree::new("ads-spellings"); + let roots = t.roots(); + + let p1 = format!("{}:stream", t.p("a.txt")); + assert_eq!( + read(&p1, &roots, 1024).expect_err("ads a:b").code, + codes::INVALID_ARGUMENTS + ); + + let p2 = format!("{}::$DATA", t.p("a.txt")); + assert_eq!( + read(&p2, &roots, 1024).expect_err("ads data").code, + codes::INVALID_ARGUMENTS + ); + + let p3 = format!("{}::$INDEX_ALLOCATION", t.p("sub")); + assert_eq!( + read(&p3, &roots, 1024).expect_err("ads index").code, + codes::INVALID_ARGUMENTS + ); + } + + #[test] + fn windows_trailing_dot_and_space() { + let t = WinTree::new("trailing-dot-space"); + let roots = t.roots(); + + let p1 = format!("{}.", t.p("a.txt")); + assert_eq!( + read(&p1, &roots, 1024).expect_err("trailing dot").code, + codes::INVALID_ARGUMENTS + ); + + let p2 = format!("{} ", t.p("a.txt")); + assert_eq!( + read(&p2, &roots, 1024).expect_err("trailing space").code, + codes::INVALID_ARGUMENTS + ); + + let p3 = t.root.join("sub.\\a.txt").display().to_string(); + assert_eq!( + read(&p3, &roots, 1024).expect_err("dir trailing dot").code, + codes::INVALID_ARGUMENTS + ); + + let p4 = t.root.join("sub \\a.txt").display().to_string(); + assert_eq!( + read(&p4, &roots, 1024) + .expect_err("dir trailing space") + .code, + codes::INVALID_ARGUMENTS + ); + } + + #[test] + fn windows_each_reserved_device_name() { + let t = WinTree::new("reserved-names"); + let roots = t.roots(); + + let names = [ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", + "COM8", "COM9", "COM0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", + "LPT8", "LPT9", "LPT0", "CONIN$", "CONOUT$", + ]; + + for name in names { + let bare = t.root.join(name).display().to_string(); + assert_eq!( + read(&bare, &roots, 1024).expect_err(name).code, + codes::INVALID_ARGUMENTS, + "bare {name}" + ); + + let with_ext = t.root.join(format!("{name}.txt")).display().to_string(); + assert_eq!( + read(&with_ext, &roots, 1024).expect_err(name).code, + codes::INVALID_ARGUMENTS, + "ext {name}.txt" + ); + } + } + + #[test] + fn windows_refused_prefixes_and_drive_relative() { + let t = WinTree::new("refused-prefixes"); + let roots = t.roots(); + + let prefixes = [ + r"\\server\share\file", + "//server/share/file", + r"\\.\COM1", + "//./COM1", + r"\\?\C:\file", + "//?/C:/file", + r"\??\C:\file", + "/??/C:/file", + "C:file", + r"C:dir\file", + "C:", + ]; + + for prefix in prefixes { + assert_eq!( + read(prefix, &roots, 1024).expect_err(prefix).code, + codes::INVALID_ARGUMENTS, + "prefix {prefix}" + ); + } + } + + #[test] + fn windows_case_and_8_3_aliases() { + let t = WinTree::new("aliases"); + let roots = t.roots(); + + // A case alias inside the root opens the same entry. + let case_alias_inside = t.root.join("SUB\\B.TXT").display().to_string(); + let val = read(&case_alias_inside, &roots, 1024).expect("read case alias inside"); + assert_eq!(val["text"], "nested"); + + // Outside the root: refused because the path's spelling does not + // lie under the root's, before anything is opened. + let case_outside = t.outside.join("SECRET.TXT").display().to_string(); + let err_case = read(&case_outside, &roots, 1024).expect_err("case outside denied"); + assert_eq!(err_case.code, codes::DENIED); + + let outside_83 = t.base.join("OUTSI~1\\secret.txt").display().to_string(); + let err_83 = read(&outside_83, &roots, 1024).expect_err("8.3 outside denied"); + assert_eq!(err_83.code, codes::DENIED); + } + + #[test] + fn windows_target_swapped_between_check_and_open() { + let t = WinTree::new("swap-hook"); + let roots = t.roots(); + let target_path = t.root.join("to_swap.txt"); + std::fs::write(&target_path, "before swap").expect("write target"); + + let target_clone = target_path.clone(); + let outside_clone = t.outside.join("secret.txt"); + // Runs on this thread only, after resolution and before the open. + hooks::BEFORE_READ_OPEN.with(|h| { + *h.borrow_mut() = Some(Box::new(move || { + std::fs::remove_file(&target_clone).expect("remove the checked file"); + file_symlink(&target_clone, &outside_clone); + })); + }); + let res = read(&target_path.display().to_string(), &roots, 1024); + hooks::BEFORE_READ_OPEN.with(|h| *h.borrow_mut() = None); + + let denial = res.expect_err("a symlink swapped in after the check is refused"); + assert_eq!(denial.code, codes::DENIED); + assert!( + denial.message.contains("became a symlink"), + "{}", + denial.message + ); + } + + #[test] + fn windows_posix_rename_refusal_simulated() { + // NTFS and ReFS on Windows 10 1709 and later support the POSIX + // rename, so a test hook (on this thread only) makes the rename + // fail as a refusing volume would, to show the write is refused + // with no fallback. + let t = WinTree::new("posix-refusal"); + let roots = t.roots(); + let target = t.p("refused_rename.txt"); + + hooks::REFUSE_POSIX_RENAME.with(|f| f.set(true)); + let res = write(&target, &roots, "data"); + hooks::REFUSE_POSIX_RENAME.with(|f| f.set(false)); + + let err = res.expect_err("POSIX rename refusal"); + assert_eq!(err.code, codes::DENIED); + assert!( + err.message + .contains("volume does not support POSIX replace rename"), + "{}", + err.message + ); + assert!( + !Path::new(&target).exists(), + "nothing was renamed into place" + ); + assert!(t.temps_in("").is_empty(), "leftover temp files"); + } + + #[test] + fn windows_failed_flush_stops_the_replace() { + let t = WinTree::new("flush-failure"); + let roots = t.roots(); + + hooks::FAIL_TEMP_FLUSH.with(|f| f.set(true)); + let res = write(&t.p("a.txt"), &roots, "unflushed"); + hooks::FAIL_TEMP_FLUSH.with(|f| f.set(false)); + + let err = res.expect_err("a failed flush refuses the rename"); + assert_eq!(err.code, codes::IO); + assert_eq!(std::fs::read_to_string(t.p("a.txt")).unwrap(), "inside"); + assert!(t.temps_in("").is_empty(), "leftover temp files"); + } + + #[test] + fn windows_file_root_grants_no_sibling() { + let t = WinTree::new("file-root"); + let file_path = t.p("a.txt"); + let roots = vec![file_path.clone()]; + + let res = read(&file_path, &roots, 1024).expect("read file root"); + assert_eq!(res["text"], "inside"); + let st = stat(&file_path, &roots).expect("stat file root"); + assert_eq!(st["kind"], "file"); + + let sibling = t.p("sub\\b.txt"); + let err = read(&sibling, &roots, 1024).expect_err("sibling denied"); + assert_eq!(err.code, codes::DENIED); + let err = list(&file_path, &roots).expect_err("a file root is not listed"); + assert_eq!(err.code, codes::DENIED); + + // Writing to the granted file's own path replaces its contents. + resolve(&file_path, &roots, Purpose::Write).expect("authorized"); + write(&file_path, &roots, "replaced").expect("replace file root"); + assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "replaced"); + + let err_write = + write(&sibling, &roots, "sibling data").expect_err("sibling write denied"); + assert_eq!(err_write.code, codes::DENIED); + let err_write = write(&t.p("new.txt"), &roots, "new").expect_err("new sibling denied"); + assert_eq!(err_write.code, codes::DENIED); + assert!(!Path::new(&t.p("new.txt")).exists()); + } + + #[test] + fn windows_file_root_is_read_without_execute_access() { + let t = WinTree::new("file-root-no-execute"); + let file_path = t.p("a.txt"); + // Read for everyone, and no execute right for anyone. + set_dacl(Path::new(&file_path), "D:P(A;;FR;;;WD)"); + let roots = vec![file_path.clone()]; + + let res = read(&file_path, &roots, 1024).expect("read file root"); + assert_eq!(res["text"], "inside"); + } + + #[test] + fn windows_write_uses_posix_replace() { + let t = WinTree::new("write-posix"); + let roots = t.roots(); + let target = t.p("out.txt"); + + write(&target, &roots, "version 1").expect("write v1"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "version 1"); + + write(&target, &roots, "version 2").expect("write v2"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "version 2"); + assert!(t.temps_in("").is_empty(), "leftover temp files"); + } + + #[test] + fn windows_write_creates_file_in_subdirectory() { + let t = WinTree::new("write-subdir"); + let roots = t.roots(); + let target = t.p("sub\\created.txt"); + + write(&target, &roots, "created").expect("create in subdirectory"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "created"); + assert!(t.temps_in("sub").is_empty(), "leftover temp files"); + + let missing_parent = t.p("nowhere\\x.txt"); + let err = write(&missing_parent, &roots, "x").expect_err("missing parent"); + assert_eq!(err.code, codes::DENIED); + + let dir_target = write(&t.p("sub"), &roots, "x").expect_err("directory target"); + assert_eq!(dir_target.code, codes::INVALID_ARGUMENTS); + } + + #[test] + fn windows_write_replaces_read_only_file() { + let t = WinTree::new("write-read-only"); + let roots = t.roots(); + let target = t.root.join("a.txt"); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms).expect("set read-only"); + + write(&target.display().to_string(), &roots, "replaced").expect("replace"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "replaced"); + } + + #[test] + fn windows_hardlink_write_replaces_link() { + let t = WinTree::new("hardlink-replace"); + let roots = t.roots(); + let orig = t.root.join("orig.txt"); + let link = t.root.join("link.txt"); + std::fs::write(&orig, "initial content").expect("write orig"); + + std::fs::hard_link(&orig, &link).expect("create hardlink"); + assert_eq!(std::fs::read_to_string(&link).unwrap(), "initial content"); + + write(&link.display().to_string(), &roots, "new link content").expect("write link"); + + // The name was replaced; the other link keeps the old contents. + assert_eq!(std::fs::read_to_string(&link).unwrap(), "new link content"); + assert_eq!(std::fs::read_to_string(&orig).unwrap(), "initial content"); + } + + #[test] + fn windows_replace_keeps_the_protected_dacl() { + let t = WinTree::new("dacl-replace"); + let roots = t.roots(); + let target = t.root.join("protected.txt"); + std::fs::write(&target, "secret").expect("write target"); + set_dacl(&target, "D:P(A;;FA;;;WD)"); + let (flags, aces) = dacl_parts(&dacl_of(&target).expect("target DACL")); + assert!(flags.contains('P'), "the DACL was applied: {flags}"); + assert_eq!(aces, ["A;;FA;;;WD"], "the DACL was applied"); + + let observer = DaclObserver::default(); + write_call( + &target.display().to_string(), + &roots, + "replacement text", + "call-dacl-1", + Some(&observer), + ) + .expect("write call with dacl"); + + for (what, sddl) in [ + ("temporary file", observer.temp_dacl()), + ("result", dacl_of(&target).expect("result DACL")), + ] { + let (flags, aces) = dacl_parts(&sddl); + assert!(flags.contains('P'), "the {what} is protected: {sddl}"); + assert_eq!(aces, ["A;;FA;;;WD"], "the {what} keeps the DACL: {sddl}"); + } + assert_eq!( + std::fs::read_to_string(&target).unwrap(), + "replacement text" + ); + } + + #[test] + fn windows_create_inherits_the_folder_dacl() { + let t = WinTree::new("dacl-create"); + let roots = t.roots(); + let sub = t.root.join("sub"); + set_dacl(&sub, "D:P(A;OICI;FA;;;WD)"); + let new_file = sub.join("new_file.txt"); + + let observer = DaclObserver::default(); + write_call( + &new_file.display().to_string(), + &roots, + "initial created text", + "call-dacl-create-1", + Some(&observer), + ) + .expect("write call create with dacl"); + + for (what, sddl) in [ + ("temporary file", observer.temp_dacl()), + ("result", dacl_of(&new_file).expect("result DACL")), + ] { + let (flags, aces) = dacl_parts(&sddl); + assert!(!flags.contains('P'), "the {what} is not protected: {sddl}"); + assert_eq!( + aces, + ["A;ID;FA;;;WD"], + "the {what} carries the folder's entry, inherited: {sddl}" + ); + } + assert_eq!( + std::fs::read_to_string(&new_file).unwrap(), + "initial created text" + ); + } + + /// A test `TempLedger` that, when the write records its temporary + /// file, tries to move a directory out of the root. + struct MoveDuringWrite { + from: PathBuf, + to: PathBuf, + result: Mutex>>, + } + + impl TempLedger for MoveDuringWrite { + fn record(&self, _lease: &TempLease) -> Result, String> { + // No file beneath `from` is open yet, so the write's own + // handle on that directory is the only thing that can stop + // the move. + *self.result.lock().unwrap() = Some(std::fs::rename(&self.from, &self.to)); + Ok(Box::new(NoHold)) + } + } + + #[test] + fn windows_write_parent_cannot_be_moved_out_during_the_write() { + let t = WinTree::new("parent-move"); + let roots = t.roots(); + let moved = t.outside.join("sub-moved"); + let ledger = MoveDuringWrite { + from: t.root.join("sub"), + to: moved.clone(), + result: Mutex::new(None), + }; + + write_call( + &t.p("sub\\new.txt"), + &roots, + "data", + "call-move-1", + Some(&ledger), + ) + .expect("write"); + + let attempt = ledger + .result + .lock() + .unwrap() + .take() + .expect("move attempted"); + let err = attempt.expect_err("the held parent cannot be moved"); + assert_eq!( + err.raw_os_error(), + Some(test_ffi::ERROR_SHARING_VIOLATION), + "{err}" + ); + assert!(!moved.exists(), "nothing was moved outside the root"); + assert_eq!( + std::fs::read_to_string(t.p("sub\\new.txt")).unwrap(), + "data" + ); + } + + #[test] + fn windows_list_directory_root_and_subdirectory() { + let t = WinTree::new("list"); + let roots = t.roots(); + + let root_list = list(&t.root.display().to_string(), &roots).expect("list root"); + assert_eq!( + root_list, + json!([ + { "name": "a.txt", "kind": "file" }, + { "name": "sub", "kind": "dir" }, + ]) + ); + let sub_list = list(&t.p("sub"), &roots).expect("list sub"); + assert_eq!(sub_list, json!([{ "name": "b.txt", "kind": "file" }])); + + let missing = list(&t.p("missing"), &roots).expect_err("missing"); + assert_eq!(missing.code, codes::NOT_FOUND); + let file = list(&t.p("a.txt"), &roots).expect_err("a file"); + assert_eq!(file.code, codes::IO); + } + + #[test] + fn windows_failed_directory_query_is_an_error() { + let t = WinTree::new("list-failure"); + // The walk's handle on a directory root has no FILE_LIST_DIRECTORY, + // so querying it fails on the first call. + let vr = + walk_from_volume_root(&t.root.display().to_string(), SHARE_ALL).expect("walk root"); + let mut seen = 0; + let res = scan_directory(vr.handle.raw(), |_, _| { + seen += 1; + true + }); + assert_eq!(res, Err(ffi::STATUS_ACCESS_DENIED)); + assert_eq!(seen, 0); + } + + fn lease(dir: &Path, target: &str, temp: &str, roots: Vec) -> TempLease { + TempLease { + call_key: "k".to_owned(), + dir: dir.to_path_buf(), + target: OsString::from(target), + temp: OsString::from(temp), + roots, + } + } + + #[test] + fn windows_remove_temp_acts_in_the_recorded_directory() { + let t = WinTree::new("remove-temp-subdir"); + let temp = ".basal-call-k1.tmp"; + std::fs::write(t.root.join("sub").join(temp), "partial").unwrap(); + // The same name in the root is not the recorded file. + std::fs::write(t.root.join(temp), "decoy").unwrap(); + let l = lease(&t.root.join("sub"), "x.txt", temp, t.roots()); + + assert_eq!(remove_temp(&l), Ok(TempRemoval::Removed)); + assert!(!t.root.join("sub").join(temp).exists()); + assert!(t.root.join(temp).exists(), "the root's file is untouched"); + assert_eq!(remove_temp(&l), Ok(TempRemoval::Absent)); + } + + #[test] + fn windows_remove_temp_under_a_file_root() { + let t = WinTree::new("remove-temp-file-root"); + let temp = ".basal-call-k2.tmp"; + std::fs::write(t.root.join(temp), "partial").unwrap(); + let l = lease(&t.root, "a.txt", temp, vec![t.p("a.txt")]); + + assert_eq!(remove_temp(&l), Ok(TempRemoval::Removed)); + assert!(!t.root.join(temp).exists()); + } + + #[test] + fn windows_remove_temp_refuses_anything_but_a_regular_file() { + let t = WinTree::new("remove-temp-dir"); + let temp = ".basal-call-k3.tmp"; + std::fs::create_dir(t.root.join(temp)).unwrap(); + let l = lease(&t.root, "x.txt", temp, t.roots()); + + let Ok(TempRemoval::Refused(why)) = remove_temp(&l) else { + panic!("a directory under the temporary name is refused"); + }; + assert_eq!(why.code, codes::DENIED); + assert!(t.root.join(temp).is_dir(), "the directory is untouched"); + } + + #[test] + fn windows_remove_temp_with_a_missing_root_is_absent() { + let t = WinTree::new("remove-temp-gone"); + let gone = t.base.join("gone"); + let l = lease( + &gone, + "x.txt", + ".basal-call-k4.tmp", + vec![gone.display().to_string()], + ); + assert_eq!(remove_temp(&l), Ok(TempRemoval::Absent)); + } + + #[test] + fn windows_remove_legacy_temps_removes_only_regular_files() { + let t = WinTree::new("legacy-temps"); + std::fs::write(t.root.join(".basal-12-3.tmp"), "old").unwrap(); + std::fs::create_dir(t.root.join(".basal-45-6.tmp")).unwrap(); + std::fs::write(t.root.join(".basal-x-1.tmp"), "not legacy").unwrap(); + + assert_eq!(remove_legacy_temps(&t.p("a.txt"), &t.roots()), Ok(1)); + assert!(!t.root.join(".basal-12-3.tmp").exists()); + assert!(t.root.join(".basal-45-6.tmp").is_dir()); + assert!(t.root.join(".basal-x-1.tmp").exists()); + + // Also beside a file root: the directory a write to that file + // root acts in. + std::fs::write(t.root.join(".basal-7-8.tmp"), "old").unwrap(); + assert_eq!(remove_legacy_temps(&t.p("a.txt"), &[t.p("a.txt")]), Ok(1)); + assert!(!t.root.join(".basal-7-8.tmp").exists()); + } + + #[test] + fn windows_temp_name_holding_a_directory_is_not_removed() { + let t = WinTree::new("temp-collision-dir"); + let roots = t.roots(); + let temp_dir = t.root.join(".basal-call-collide.tmp"); + std::fs::create_dir(&temp_dir).unwrap(); + + let err = write_call(&t.p("x.txt"), &roots, "data", "collide", None) + .expect_err("a directory under the temporary name stops the write"); + assert_eq!(err.code, codes::IO); + assert!(temp_dir.is_dir(), "the directory is untouched"); + assert!(!Path::new(&t.p("x.txt")).exists()); + } + + #[test] + fn windows_temp_name_holding_a_regular_file_is_replaced() { + let t = WinTree::new("temp-collision-file"); + let roots = t.roots(); + std::fs::write(t.root.join(".basal-call-again.tmp"), "earlier send").unwrap(); + + write_call(&t.p("x.txt"), &roots, "data", "again", None).expect("write"); + assert_eq!(std::fs::read_to_string(t.p("x.txt")).unwrap(), "data"); + assert!(t.temps_in("").is_empty(), "leftover temp files"); + } + + #[test] + fn windows_volume_root_grants_its_children() { + let t = WinTree::new("volume-root"); + let drive = t.root.to_str().unwrap()[..3].to_owned(); + let roots = vec![drive]; + + resolve(&t.p("a.txt"), &roots, Purpose::Read).expect("resolve"); + let val = read(&t.p("a.txt"), &roots, 1024).expect("read under a volume root"); + assert_eq!(val["text"], "inside"); + let st = stat(&t.p("sub\\b.txt"), &roots).expect("stat under a volume root"); + assert_eq!(st["size"], 6); + write(&t.p("v.txt"), &roots, "volume").expect("write under a volume root"); + assert_eq!(std::fs::read_to_string(t.p("v.txt")).unwrap(), "volume"); + } + } +} diff --git a/crates/basal-host/src/builtins/git.rs b/crates/basal-host/src/builtins/git.rs index c718e66..d17af8e 100644 --- a/crates/basal-host/src/builtins/git.rs +++ b/crates/basal-host/src/builtins/git.rs @@ -15,14 +15,29 @@ #[cfg(test)] mod tests; +#[cfg(windows)] +pub mod windows; + +#[cfg(not(windows))] use std::io::Read; +#[cfg(not(windows))] use std::os::fd::AsRawFd; +#[cfg(not(windows))] use std::os::unix::process::CommandExt; -use std::path::{Component, Path, PathBuf}; -use std::process::{Child, Command, ExitStatus, Stdio}; +#[cfg(not(windows))] +use std::path::PathBuf; +use std::path::{Component, Path}; +#[cfg(not(windows))] +use std::process::Command; +#[cfg(not(windows))] +use std::process::{Child, ExitStatus, Stdio}; +#[cfg(not(windows))] use std::sync::mpsc::{self, Sender}; +#[cfg(not(windows))] use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant}; +use std::time::Duration; +#[cfg(not(windows))] +use std::time::Instant; use basal_proto::Primitive; use serde_json::{Value, json}; @@ -31,7 +46,7 @@ use super::{Denial, codes, expand_home, options, string_arg}; /// How long one git command may run. pub const TIMEOUT: Duration = Duration::from_secs(20); -const STDERR_BYTES: usize = 4096; +pub(crate) const STDERR_BYTES: usize = 4096; /// The most output one git command may produce: a log, a blob or a diff. pub const MAX_OUTPUT_BYTES: usize = super::MAX_TEXT_RESULT_BYTES; /// `git.log`'s default and largest entry counts. @@ -210,6 +225,7 @@ pub fn parse(primitive: Primitive, args: &Value) -> Result { /// The repository's real path, which must be one of the approved /// repositories (each resolved the same way). A subdirectory of an approved /// repository is not itself approved. +#[cfg(not(windows))] pub fn repo(repo: &str, repos: &[String]) -> Result { let path = expand_home(repo) .ok_or_else(|| Denial::invalid(format!("{repo:?} is not an absolute path")))?; @@ -223,6 +239,29 @@ pub fn repo(repo: &str, repos: &[String]) -> Result { if approved { Ok(real) } else { Err(refused()) } } +#[cfg(windows)] +pub(crate) fn repo( + repo: &str, + repos: &[String], +) -> Result { + use super::fs::windows::pin_directory; + let path = expand_home(repo).ok_or_else(|| Denial::invalid("repository must be absolute"))?; + let path = path + .to_str() + .ok_or_else(|| Denial::invalid("repository is not Unicode"))?; + let refused = || Denial::denied("repository is outside the manifest's repositories or missing"); + let pinned = pin_directory(path).map_err(|_| refused())?; + for approved in repos.iter().filter_map(|r| expand_home(r)) { + if let Some(approved) = approved.to_str() + && let Ok(root) = pin_directory(approved) + && pinned.same_identity(&root) + { + return Ok(pinned); + } + } + Err(refused()) +} + /// The only way the built-ins run git: a `git -C ` command that can /// read the repository and run nothing else. /// @@ -246,6 +285,7 @@ pub fn repo(repo: &str, repos: &[String]) -> Result { /// /// Repositories must belong to the service uid. Global `safe.directory` /// exceptions are deliberately ignored, rather than trusting foreign config. +#[cfg(not(windows))] pub fn hardened_command(repo: &Path) -> Command { let mut command = Command::new("git"); command.env_clear(); @@ -287,6 +327,9 @@ pub fn hardened_command(repo: &Path) -> Command { command } +#[cfg(windows)] +pub use windows::hardened_command; + /// How a git command ended. pub struct Ran { pub success: bool, @@ -297,10 +340,15 @@ pub struct Ran { /// Runs a git command under [`TIMEOUT`], refusing output over /// [`MAX_OUTPUT_BYTES`]. +#[cfg(not(windows))] pub fn run_command(command: Command) -> Result { run_command_until(command, None) } +#[cfg(windows)] +pub use windows::run_command; + +#[cfg(not(windows))] fn run_command_until(mut command: Command, max_lines: Option) -> Result { command.process_group(0); let child = command @@ -383,6 +431,7 @@ fn run_command_until(mut command: Command, max_lines: Option) -> Result Denial { Denial::new( codes::TIMEOUT, @@ -390,17 +439,20 @@ fn timeout_denial() -> Denial { ) } +#[cfg(not(windows))] enum Completion { Exit(std::io::Result), Stdout(Result), Stderr(Result), } +#[cfg(not(windows))] struct ProcessGroup { pid: u32, child: Option, waiter: Option>, } +#[cfg(not(windows))] impl ProcessGroup { fn new(child: Child) -> Self { Self { @@ -425,6 +477,7 @@ impl ProcessGroup { } } } +#[cfg(not(windows))] impl Drop for ProcessGroup { fn drop(&mut self) { self.kill(); @@ -437,11 +490,13 @@ impl Drop for ProcessGroup { } } +#[cfg(not(windows))] struct PipeOutput { bytes: Vec, limit_reached: bool, } +#[cfg(not(windows))] fn drain_pipe( mut pipe: Option, cap: usize, @@ -517,15 +572,22 @@ fn drain_pipe( } fn git(repo: &Path, args: &[&str]) -> Result { + #[cfg(not(windows))] let mut command = hardened_command(repo); + #[cfg(windows)] + let mut command = hardened_command(repo)?; command.args(args); run_command(command) } +#[cfg(not(windows))] fn run_tags_command(command: Command) -> Result { run_command_until(command, Some(MAX_TAGS)) } +#[cfg(windows)] +use windows::run_tags_command; + fn failed(ran: &Ran) -> Denial { Denial::new(codes::GIT, ran.stderr.trim().to_owned()) } @@ -655,7 +717,10 @@ pub fn run(repo_arg: &str, repos: &[String], op: &Op) -> Result { if let Some(p) = pattern { list.push(p); } + #[cfg(not(windows))] let mut command = hardened_command(&dir); + #[cfg(windows)] + let mut command = hardened_command(&dir)?; command.args(&list); let ran = run_tags_command(command)?; if !ran.success { diff --git a/crates/basal-host/src/builtins/git/fixtures/windows_child.rs b/crates/basal-host/src/builtins/git/fixtures/windows_child.rs new file mode 100644 index 0000000..5f3a830 --- /dev/null +++ b/crates/basal-host/src/builtins/git/fixtures/windows_child.rs @@ -0,0 +1,80 @@ +// Compiled natively by the Windows tests. This child uses Rust's real Windows +// argv parser, independently of the encoder in the parent. +use std::io::{Read, Write}; +use std::os::windows::{ffi::OsStrExt, process::CommandExt}; +use std::process::{Command, Stdio}; +use std::time::Duration; + +fn main() { + let mut arguments = std::env::args_os().skip(1); + let mode = arguments.next().unwrap(); + match mode.to_str().unwrap() { + "argv" => { + for argument in arguments { + for unit in argument.encode_wide() { + print!("{unit:04x}"); + } + println!(); + } + } + "mark" => std::fs::write(arguments.next().unwrap(), "worked").unwrap(), + "stdin" => { + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes).unwrap(); + assert!(bytes.is_empty()); + println!("inert"); + } + "hold" => { + std::fs::write(arguments.next().unwrap(), std::process::id().to_string()).unwrap(); + std::thread::sleep(Duration::from_secs(300)); + } + "spawn" | "post-exit" => { + let pid = arguments.next().unwrap(); + let ready = arguments.next().unwrap(); + let mut child = Command::new(std::env::current_exe().unwrap()) + .arg("hold") + .arg(&pid) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + while !std::path::Path::new(&pid).exists() { + std::thread::sleep(Duration::from_millis(2)); + } + std::fs::write(&ready, "ready").unwrap(); + if mode == "post-exit" { + while !std::path::Path::new(&ready) + .with_extension("release") + .exists() + { + std::thread::sleep(Duration::from_millis(2)); + } + } else { + let _ = child.wait(); + } + } + "lines" => { + let width: usize = arguments.next().unwrap().to_str().unwrap().parse().unwrap(); + let mut stdout = std::io::stdout().lock(); + // More bytes than the cap even with only 199 completed lines when + // width is large. An unbounded collector never reaches process exit. + for _ in 0..4000 { + writeln!(stdout, "{}", "x".repeat(width)).unwrap(); + } + stdout.flush().unwrap(); + std::thread::sleep(Duration::from_secs(300)); + } + "breakaway" => { + let marker = arguments.next().unwrap(); + let flags: u32 = arguments.next().unwrap().to_str().unwrap().parse().unwrap(); + let status = Command::new(std::env::current_exe().unwrap()) + .arg("mark") + .arg(marker) + .creation_flags(flags) + .status(); + assert!(status.is_err(), "breakaway child was allowed"); + println!("breakaway refused"); + } + other => panic!("unknown mode {other}"), + } +} diff --git a/crates/basal-host/src/builtins/git/tests.rs b/crates/basal-host/src/builtins/git/tests.rs index 53862f9..08d1147 100644 --- a/crates/basal-host/src/builtins/git/tests.rs +++ b/crates/basal-host/src/builtins/git/tests.rs @@ -1,4 +1,6 @@ use super::*; +#[cfg(unix)] +use std::process::Stdio; #[test] fn overflowing_or_malformed_log_timestamps_are_refused_not_dropped() { @@ -22,6 +24,7 @@ fn show_distinguishes_missing_blob_from_broken_repository() { } #[test] +#[cfg(unix)] fn tag_output_is_bounded_while_reading_not_after_completion() { let mut command = Command::new("/bin/sh"); command @@ -41,6 +44,7 @@ fn tag_output_is_bounded_while_reading_not_after_completion() { } #[test] +#[cfg(unix)] fn git_waiter_timeout_kills_the_process_group_and_reaps_the_child() { let directory = std::env::temp_dir().join(format!("basal-git-timeout-{}", std::process::id())); std::fs::create_dir_all(&directory).unwrap(); diff --git a/crates/basal-host/src/builtins/git/windows.rs b/crates/basal-host/src/builtins/git/windows.rs new file mode 100644 index 0000000..c08319e --- /dev/null +++ b/crates/basal-host/src/builtins/git/windows.rs @@ -0,0 +1,939 @@ +//! Windows git reads use a private empty global config, explicit executable +//! paths, and a non-breakaway kill-on-close job. The child remains suspended +//! until membership in that exact job is verified. + +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::os::windows::ffi::{OsStrExt, OsStringExt}; +use std::path::{Component, Path, PathBuf, Prefix}; +use std::process::Command; +use std::sync::{ + OnceLock, + atomic::{AtomicBool, Ordering}, + mpsc, +}; +use std::thread; +use std::time::{Duration, Instant}; + +use windows_sys::Win32::{ + Foundation::{ + CloseHandle, ERROR_BROKEN_PIPE, HANDLE, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, + LocalFree, SetHandleInformation, WAIT_OBJECT_0, WAIT_TIMEOUT, + }, + Security::{ + Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW, + Cryptography::{BCRYPT_USE_SYSTEM_PREFERRED_RNG, BCryptGenRandom}, + SECURITY_ATTRIBUTES, + }, + Storage::FileSystem::{ + CREATE_NEW, CreateDirectoryW, CreateFileW, FILE_ATTRIBUTE_NORMAL, + FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_SHARE_READ, FILE_SHARE_WRITE, + OPEN_EXISTING, ReadFile, + }, + System::{ + JobObjects::{ + CreateJobObjectW, IsProcessInJob, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, + }, + Pipes::{CreatePipe, PeekNamedPipe}, + Threading::{ + CREATE_NO_WINDOW, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, + DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess, + InitializeProcThreadAttributeList, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + PROC_THREAD_ATTRIBUTE_JOB_LIST, PROCESS_INFORMATION, ResumeThread, + STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute, + WaitForSingleObject, + }, + }, +}; + +use super::{MAX_OUTPUT_BYTES, MAX_TAGS, Ran, STDERR_BYTES, TIMEOUT, codes}; +use crate::builtins::{ + Denial, + fs::windows::{PinnedDirectory, pin_directory}, +}; + +fn native_error(what: &str) -> Denial { + Denial::new( + codes::GIT, + format!("{what}: {}", std::io::Error::last_os_error()), + ) +} + +fn wide(s: impl AsRef) -> Result, Denial> { + let mut value: Vec = s.as_ref().encode_wide().collect(); + if value.contains(&0) { + return Err(Denial::invalid("NUL in Windows process input")); + } + value.push(0); + Ok(value) +} + +/// Only a verbatim drive path can be converted to an ordinary drive path. +/// UNC, device, volume and drive-relative names are not supported by the fs policy. +fn drive_path(path: &Path) -> Result { + let mut components = path.components(); + match (components.next(), components.next()) { + (Some(Component::Prefix(prefix)), Some(Component::RootDir)) => match prefix.kind() { + Prefix::Disk(_) => Ok(path.to_path_buf()), + Prefix::VerbatimDisk(_) => { + let units: Vec = path.as_os_str().encode_wide().skip(4).collect(); + Ok(PathBuf::from(OsString::from_wide(&units))) + } + _ => Err(Denial::invalid("only absolute drive paths are supported")), + }, + _ => Err(Denial::invalid("only absolute drive paths are supported")), + } +} + +fn canonical_image(path: &Path) -> Result { + let path = drive_path(path)?; + if !path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) + || !path.is_file() + { + return Err(Denial::new( + codes::GIT, + "the image must be an existing .exe", + )); + } + drive_path(&std::fs::canonicalize(path).map_err(|e| Denial::new(codes::GIT, e.to_string()))?) +} + +/// PATH is operator-controlled; only absolute directories participate. This +/// establishes a stable image path, not a signature/trust check of the binary. +fn resolve_git_binary(program: &OsStr, path_env: Option<&OsStr>) -> Result { + let p = Path::new(program); + if p.extension() + .is_some_and(|e| e.eq_ignore_ascii_case("bat") || e.eq_ignore_ascii_case("cmd")) + { + return Err(Denial::new(codes::GIT, "git scripts are refused")); + } + if program != "git" && program != "git.exe" { + return canonical_image(p); + } + if let Some(path_env) = path_env { + for directory in std::env::split_paths(path_env) { + let Ok(directory) = drive_path(&directory) else { + continue; + }; + let image = directory.join("git.exe"); + if image.is_file() { + return canonical_image(&image); + } + if directory.join("git.cmd").is_file() || directory.join("git.bat").is_file() { + return Err(Denial::new(codes::GIT, "git PATH entry is a script")); + } + } + } + Err(Denial::new( + codes::GIT, + "git.exe was not found in absolute PATH directories", + )) +} + +fn resolve_git() -> Result<&'static Path, Denial> { + static IMAGE: OnceLock> = OnceLock::new(); + match IMAGE + .get_or_init(|| resolve_git_binary(OsStr::new("git"), std::env::var_os("PATH").as_deref())) + { + Ok(image) => Ok(image), + Err(error) => Err(error.clone()), + } +} + +struct OwnedHandle(HANDLE); +// SAFETY: kernel handles may move across threads. Each owner closes once. +unsafe impl Send for OwnedHandle {} +impl Drop for OwnedHandle { + fn drop(&mut self) { + // SAFETY: all constructors check for invalid handles before taking ownership. + unsafe { + CloseHandle(self.0); + } + } +} + +struct SecurityDescriptor(*mut std::ffi::c_void); +impl SecurityDescriptor { + fn private() -> Result { + let sddl = wide("D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;OW)")?; + let mut descriptor = std::ptr::null_mut(); + // SAFETY: the null-terminated SDDL and output pointer live through the call. + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + 1, + &mut descriptor, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(native_error("private DACL")); + } + Ok(Self(descriptor)) + } + fn attributes(&self) -> SECURITY_ATTRIBUTES { + SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: self.0, + bInheritHandle: 0, + } + } +} +impl Drop for SecurityDescriptor { + fn drop(&mut self) { + // SAFETY: the conversion API allocated this descriptor with LocalAlloc. + unsafe { + LocalFree(self.0); + } + } +} + +/// Guards precede cleanup: the directory cannot be replaced and the zero-byte +/// file cannot be written/deleted while git reads it. Each call gets a new DACL +/// protected directory, created exclusively using a cryptographically random name. +struct Isolation { + directory: PathBuf, + pins: Vec, + config: Option, +} +impl Isolation { + fn new() -> Result { + Self::in_base(&std::env::temp_dir()) + } + + fn in_base(base: &Path) -> Result { + let base = drive_path(base)?; + // GetTempPathW normally includes a trailing separator. Remove only that + // separator from this host-selected base, not from repository inputs; + // the fs walk still rejects ambiguous components and reparse points. + let mut units: Vec = base.as_os_str().encode_wide().collect(); + while units.len() > 3 && units.last() == Some(&(b'\\' as u16)) { + units.pop(); + } + let base = PathBuf::from(OsString::from_wide(&units)); + let base = base + .to_str() + .ok_or_else(|| Denial::invalid("temp directory is not Unicode"))?; + let base_pin = pin_directory(base)?; + let descriptor = SecurityDescriptor::private()?; + let attributes = descriptor.attributes(); + let mut random = [0u8; 16]; + // SAFETY: random is writable for exactly the requested byte count. + if unsafe { + BCryptGenRandom( + std::ptr::null_mut(), + random.as_mut_ptr(), + random.len() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + } < 0 + { + return Err(Denial::new( + codes::GIT, + "private directory randomness failed", + )); + } + let name: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let directory = base_pin.path().join(format!("basal-git-{name}")); + let path = wide(&directory)?; + // SAFETY: path and private security descriptor live through the call. + // CreateDirectory refuses an existing name; no existing contents are trusted. + if unsafe { CreateDirectoryW(path.as_ptr(), &attributes) } == 0 { + return Err(native_error("create private git directory")); + } + let mut isolation = Self { + directory, + pins: vec![base_pin], + config: None, + }; + isolation.pins.push(pin_directory( + isolation + .directory + .to_str() + .ok_or_else(|| Denial::invalid("private directory is not Unicode"))?, + )?); + let hooks = isolation.directory.join("hooks"); + if unsafe { CreateDirectoryW(wide(&hooks)?.as_ptr(), &attributes) } == 0 { + return Err(native_error("create private hooks directory")); + } + isolation.pins.push(pin_directory( + hooks + .to_str() + .ok_or_else(|| Denial::invalid("hooks directory is not Unicode"))?, + )?); + let config = wide(isolation.directory.join("global.config"))?; + // SAFETY: CREATE_NEW is exclusive, OPEN_REPARSE_POINT never follows a link, + // and read sharing only prevents both writes and deletion for the call. + let handle = unsafe { + CreateFileW( + config.as_ptr(), + FILE_GENERIC_READ, + FILE_SHARE_READ, + &attributes, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(native_error("create empty global config")); + } + isolation.config = Some(OwnedHandle(handle)); + Ok(isolation) + } +} +impl Drop for Isolation { + fn drop(&mut self) { + self.config.take(); + self.pins.clear(); + let _ = std::fs::remove_dir_all(&self.directory); + } +} + +pub struct HardenedCommand { + command: Command, + _isolation: Isolation, +} +impl std::ops::Deref for HardenedCommand { + type Target = Command; + fn deref(&self) -> &Command { + &self.command + } +} +impl std::ops::DerefMut for HardenedCommand { + fn deref_mut(&mut self) -> &mut Command { + &mut self.command + } +} + +/// The resources selected by this recipe are owned until the child tree exits. +pub fn hardened_command(repo: &Path) -> Result { + let isolation = Isolation::new()?; + let repo = drive_path(repo)?; + let mut command = Command::new(resolve_git()?); + command.env_clear(); + for key in ["PATH", "SystemRoot", "USERPROFILE"] { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); + } + } + command + .env("GIT_CONFIG_NOSYSTEM", "1") + .env( + "GIT_CONFIG_GLOBAL", + isolation.directory.join("global.config"), + ) + .env("LC_ALL", "C") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_NO_LAZY_FETCH", "1"); + if let Some(parent) = repo.parent() { + command.env("GIT_CEILING_DIRECTORIES", parent); + } + let mut hooks = OsString::from("core.hooksPath="); + hooks.push(isolation.directory.join("hooks")); + command + .arg("-C") + .arg(repo) + .args([ + "--no-optional-locks", + "--no-pager", + "--literal-pathspecs", + "-c", + "core.fsmonitor=false", + "-c", + ]) + .arg(hooks) + .args([ + "-c", + "core.pager=cat", + "-c", + "log.showSignature=false", + "-c", + "protocol.allow=never", + ]); + Ok(HardenedCommand { + command, + _isolation: isolation, + }) +} + +/// Microsoft CRT encoding over UTF-16 units (including unpaired surrogates). +/// Backslashes before quotes and before the closing quote must be doubled. +fn append_windows_arg(command: &mut Vec, argument: &OsStr) -> Result<(), Denial> { + let units: Vec = argument.encode_wide().collect(); + if units.contains(&0) { + return Err(Denial::invalid("NUL in process argument")); + } + command.push(b'"' as u16); + let mut slashes = 0; + for unit in units { + if unit == b'\\' as u16 { + slashes += 1; + continue; + } + let count = if unit == b'"' as u16 { + 2 * slashes + 1 + } else { + slashes + }; + command.extend(std::iter::repeat_n(b'\\' as u16, count)); + command.push(unit); + slashes = 0; + } + command.extend(std::iter::repeat_n(b'\\' as u16, 2 * slashes)); + command.push(b'"' as u16); + Ok(()) +} + +struct AttributeList { + buffer: Vec, + jobs: Box<[HANDLE; 1]>, + handles: Box<[HANDLE; 3]>, +} +impl AttributeList { + fn new(job: HANDLE, handles: [HANDLE; 3]) -> Result { + let mut size = 0; + // SAFETY: the first call queries storage size; no attribute list exists yet. + unsafe { + InitializeProcThreadAttributeList(std::ptr::null_mut(), 2, 0, &mut size); + } + if size == 0 { + return Err(native_error("attribute list size")); + } + let mut buffer = vec![0; size.div_ceil(std::mem::size_of::())]; + if unsafe { InitializeProcThreadAttributeList(buffer.as_mut_ptr().cast(), 2, 0, &mut size) } + == 0 + { + return Err(native_error("initialize attribute list")); + } + let mut list = Self { + buffer, + jobs: Box::new([job]), + handles: Box::new(handles), + }; + // SAFETY: both payloads have stable boxed addresses. Drop deletes the + // attribute list before Rust drops either payload or its backing storage. + for (attribute, payload, length) in [ + ( + PROC_THREAD_ATTRIBUTE_JOB_LIST as usize, + list.jobs.as_mut_ptr(), + std::mem::size_of_val(list.jobs.as_ref()), + ), + ( + PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, + list.handles.as_mut_ptr(), + std::mem::size_of_val(list.handles.as_ref()), + ), + ] { + if unsafe { + UpdateProcThreadAttribute( + list.pointer(), + 0, + attribute, + payload.cast(), + length, + std::ptr::null_mut(), + std::ptr::null(), + ) + } == 0 + { + return Err(native_error("set process attribute")); + } + } + Ok(list) + } + fn pointer(&mut self) -> *mut std::ffi::c_void { + self.buffer.as_mut_ptr().cast() + } +} +impl Drop for AttributeList { + fn drop(&mut self) { + // SAFETY: initialization succeeded and backing/payload storage is still live. + unsafe { + DeleteProcThreadAttributeList(self.pointer()); + } + } +} + +struct ProcessJob { + process: OwnedHandle, + job: Option, + terminated: bool, + #[cfg(test)] + fail_termination_once: bool, +} +impl ProcessJob { + fn terminate(&mut self) -> Result<(), Denial> { + if self.terminated { + return Ok(()); + } + #[cfg(test)] + if std::mem::take(&mut self.fail_termination_once) { + return Err(Denial::new(codes::GIT, "injected job termination failure")); + } + if let Some(job) = &self.job { + // SAFETY: the job is exclusively owned and cannot close during this call. + if unsafe { TerminateJobObject(job.0, 1) } == 0 { + return Err(native_error("terminate git job")); + } + } + self.terminated = true; + Ok(()) + } + fn stop_tree(&mut self) { + if self.terminate().is_err() { + let _ = self.terminate(); + } + // Last-handle close is the kill-on-close fallback, before any joins. + self.job.take(); + } +} +impl Drop for ProcessJob { + fn drop(&mut self) { + self.stop_tree(); + } +} + +fn pipe(attributes: &SECURITY_ATTRIBUTES) -> Result<(OwnedHandle, OwnedHandle), Denial> { + let mut read = std::ptr::null_mut(); + let mut write = std::ptr::null_mut(); + // SAFETY: output pointers and security attributes are valid for this call. + if unsafe { CreatePipe(&mut read, &mut write, attributes, 0) } == 0 { + return Err(native_error("create git pipe")); + } + let read = OwnedHandle(read); + let write = OwnedHandle(write); + if unsafe { SetHandleInformation(read.0, HANDLE_FLAG_INHERIT, 0) } == 0 { + return Err(native_error("clear pipe inheritance")); + } + Ok((read, write)) +} + +pub struct CommandInput { + command: Command, + _isolation: Option, +} +impl From for CommandInput { + fn from(value: HardenedCommand) -> Self { + Self { + command: value.command, + _isolation: Some(value._isolation), + } + } +} +impl From for CommandInput { + fn from(command: Command) -> Self { + Self { + command, + _isolation: None, + } + } +} + +fn environment(command: &Command, hardened: bool) -> Result, Denial> { + fn key(value: &OsStr) -> Vec { + value + .encode_wide() + .map(|c| { + if (b'a' as u16..=b'z' as u16).contains(&c) { + c - 32 + } else { + c + } + }) + .collect() + } + let mut entries = BTreeMap::new(); + if !hardened { + for (name, value) in std::env::vars_os() { + entries.insert(key(&name), (name, value)); + } + } + for (name, value) in command.get_envs() { + if let Some(value) = value { + entries.insert(key(name), (name.to_owned(), value.to_owned())); + } else { + entries.remove(&key(name)); + } + } + let mut block = Vec::new(); + for (_, (name, value)) in entries { + let name = wide(name)?; + block.extend_from_slice(&name[..name.len() - 1]); + block.push(b'=' as u16); + block.extend(wide(value)?); + } + if block.is_empty() { + block.push(0); + } + block.push(0); + Ok(block) +} + +/// The optional observer runs while the primary thread is still suspended. It +/// is used by native tests to inspect the exact job, not just any inherited job. +fn spawn_job_command( + input: &CommandInput, + observer: impl FnOnce(&ProcessJob) -> Result<(), Denial>, +) -> Result<(ProcessJob, OwnedHandle, OwnedHandle), Denial> { + spawn_with_argv0(input, observer, None) +} + +fn spawn_with_argv0( + input: &CommandInput, + observer: impl FnOnce(&ProcessJob) -> Result<(), Denial>, + argv0: Option<&OsStr>, +) -> Result<(ProcessJob, OwnedHandle, OwnedHandle), Denial> { + let command = &input.command; + let program = command.get_program(); + let image = if program == "git" || program == "git.exe" { + resolve_git()?.to_path_buf() + } else { + canonical_image(Path::new(program))? + }; + let application = wide(&image)?; + let mut command_line = Vec::new(); + append_windows_arg(&mut command_line, argv0.unwrap_or(image.as_os_str()))?; + for argument in command.get_args() { + command_line.push(b' ' as u16); + append_windows_arg(&mut command_line, argument)?; + } + command_line.push(0); + if command_line.len() > 32767 { + return Err(Denial::invalid("Windows command line is too long")); + } + let env = environment(command, input._isolation.is_some())?; + let cwd = command + .get_current_dir() + .map(|p| drive_path(p).and_then(wide)) + .transpose()?; + let cwd_pointer = cwd.as_ref().map_or(std::ptr::null(), |p| p.as_ptr()); + let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if job.is_null() { + return Err(native_error("create git job")); + } + let job = OwnedHandle(job); + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if unsafe { + SetInformationJobObject( + job.0, + JobObjectExtendedLimitInformation, + (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + std::mem::size_of_val(&limits) as u32, + ) + } == 0 + { + return Err(native_error("git job limits")); + } + let inherit = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: std::ptr::null_mut(), + bInheritHandle: 1, + }; + // NUL is a device, never a reopen of the trusted global configuration file. + let nul = wide("NUL")?; + let stdin = unsafe { + CreateFileW( + nul.as_ptr(), + FILE_GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, + &inherit, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ) + }; + if stdin == INVALID_HANDLE_VALUE { + return Err(native_error("open inert stdin")); + } + let stdin = OwnedHandle(stdin); + let (stdout, stdout_writer) = pipe(&inherit)?; + let (stderr, stderr_writer) = pipe(&inherit)?; + let mut attributes = AttributeList::new(job.0, [stdin.0, stdout_writer.0, stderr_writer.0])?; + let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() }; + startup.StartupInfo.cb = std::mem::size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = stdin.0; + startup.StartupInfo.hStdOutput = stdout_writer.0; + startup.StartupInfo.hStdError = stderr_writer.0; + startup.lpAttributeList = attributes.pointer(); + let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + // SAFETY: buffers, owned attribute payloads, handles and startup structure + // remain live through creation; lpApplicationName is the canonical image. + if unsafe { + CreateProcessW( + application.as_ptr(), + command_line.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 1, + EXTENDED_STARTUPINFO_PRESENT + | CREATE_NO_WINDOW + | CREATE_UNICODE_ENVIRONMENT + | CREATE_SUSPENDED, + env.as_ptr().cast(), + cwd_pointer, + &startup.StartupInfo, + &mut process, + ) + } == 0 + { + return Err(native_error("start git")); + } + let primary_thread = OwnedHandle(process.hThread); + let guard = ProcessJob { + process: OwnedHandle(process.hProcess), + job: Some(job), + terminated: false, + #[cfg(test)] + fail_termination_once: false, + }; + let mut member = 0; + // Verify the exact job before the child can perform any work. A failed + // verification drops the job and kills the still-suspended process. + if unsafe { IsProcessInJob(guard.process.0, guard.job.as_ref().unwrap().0, &mut member) } == 0 + || member == 0 + { + // If assignment ever fails, job closure cannot kill an unassigned child. + // It is still suspended and its primary thread has never done work. + unsafe { + TerminateProcess(guard.process.0, 1); + } + return Err(Denial::new( + codes::GIT, + "git did not join its containment job", + )); + } + observer(&guard)?; + if unsafe { ResumeThread(primary_thread.0) } != 1 { + return Err(Denial::new( + codes::GIT, + "git primary thread was not suspended exactly once", + )); + } + Ok((guard, stdout, stderr)) +} + +struct PipeOutput { + bytes: Vec, + limit_reached: bool, +} + +fn drain_pipe( + handle: HANDLE, + cap: usize, + lines: Option, + cancelled: &AtomicBool, +) -> Result { + let mut output = PipeOutput { + bytes: Vec::new(), + limit_reached: false, + }; + let mut count = 0; + loop { + if cancelled.load(Ordering::Acquire) { + return Ok(output); + } + let mut available = 0; + // Peek avoids an uncancellable synchronous read when exceptional cleanup + // cannot terminate a process. Only this thread reads this pipe. + if unsafe { + PeekNamedPipe( + handle, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut available, + std::ptr::null_mut(), + ) + } == 0 + { + if std::io::Error::last_os_error().raw_os_error() == Some(ERROR_BROKEN_PIPE as i32) { + return Ok(output); + } + return Err(native_error("peek git pipe")); + } + if available == 0 { + thread::sleep(Duration::from_millis(2)); + continue; + } + let mut chunk = [0u8; STDERR_BYTES]; + let mut read = 0; + if unsafe { + ReadFile( + handle, + chunk.as_mut_ptr(), + available.min(chunk.len() as u32), + &mut read, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(native_error("read git pipe")); + } + for byte in &chunk[..read as usize] { + if output.bytes.len() < cap { + output.bytes.push(*byte); + } + if cap > STDERR_BYTES && output.bytes.len() > MAX_OUTPUT_BYTES { + return Err(Denial::new( + codes::TOO_LARGE, + "git output exceeds the byte cap", + )); + } + if *byte == b'\n' { + count += 1; + if lines.is_some_and(|limit| count >= limit) { + output.limit_reached = true; + return Ok(output); + } + } + } + } +} + +struct ScopeCleanup<'a> { + guard: &'a mut ProcessJob, + cancelled: &'a AtomicBool, +} +impl Drop for ScopeCleanup<'_> { + fn drop(&mut self) { + self.guard.stop_tree(); + self.cancelled.store(true, Ordering::Release); + } +} + +enum Completion { + Exit(Result), + Stdout(Result), + Stderr(Result), +} +#[derive(Default)] +struct Faults { + #[cfg(test)] + fail_termination_once: bool, + #[cfg(test)] + panic_after_waiter: bool, +} + +fn run_until( + input: CommandInput, + lines: Option, + timeout: Duration, + _faults: Faults, +) -> Result { + let (mut guard, stdout, stderr) = spawn_job_command(&input, |_| Ok(()))?; + #[cfg(test)] + { + guard.fail_termination_once = _faults.fail_termination_once; + } + let out = stdout.0 as usize; + let err = stderr.0 as usize; + let process = guard.process.0 as usize; + let deadline = Instant::now() + timeout; + let cancelled = AtomicBool::new(false); + let (sender, receiver) = mpsc::channel(); + thread::scope(|scope| { + // This guard drops on an early return or a panic during thread startup, + // BEFORE scope joins workers. The outer process/pipe owners outlive joins. + let cleanup = ScopeCleanup { + guard: &mut guard, + cancelled: &cancelled, + }; + let exit_sender = sender.clone(); + let cancel = &cancelled; + scope.spawn(move || { + let result = loop { + let wait = unsafe { WaitForSingleObject(process as HANDLE, 10) }; + if wait == WAIT_OBJECT_0 { + let mut code = 0; + break if unsafe { GetExitCodeProcess(process as HANDLE, &mut code) } == 0 { + Err(native_error("git exit code")) + } else { + Ok(code) + }; + } + if wait != WAIT_TIMEOUT { + break Err(native_error("wait for git")); + } + if cancel.load(Ordering::Acquire) { + break Err(Denial::new(codes::GIT, "git wait cancelled")); + } + }; + let _ = exit_sender.send(Completion::Exit(result)); + }); + #[cfg(test)] + if _faults.panic_after_waiter { + panic!("injected thread-start failure"); + } + let out_sender = sender.clone(); + scope.spawn(move || { + let _ = out_sender.send(Completion::Stdout(drain_pipe( + out as HANDLE, + MAX_OUTPUT_BYTES + 1, + lines, + cancel, + ))); + }); + scope.spawn(move || { + let _ = sender.send(Completion::Stderr(drain_pipe( + err as HANDLE, + STDERR_BYTES, + None, + cancel, + ))); + }); + let mut code = None; + let mut output = None; + let mut errors = None; + let mut limited = false; + while code.is_none() || output.is_none() || errors.is_none() { + let event = receiver + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .map_err(|_| { + Denial::new( + codes::TIMEOUT, + format!("git ran longer than {} s", timeout.as_secs()), + ) + })?; + match event { + Completion::Exit(result) => { + code = Some(result?); + cleanup.guard.stop_tree(); + } + Completion::Stdout(result) => { + let result = result?; + limited = result.limit_reached; + if limited { + cleanup.guard.stop_tree(); + } + output = Some(result.bytes); + } + Completion::Stderr(result) => { + errors = Some(result?.bytes); + } + } + } + Ok(Ran { + success: limited || code == Some(0), + code: Some(if limited { 0 } else { code.unwrap() as i32 }), + stdout: output.unwrap(), + stderr: String::from_utf8_lossy(&errors.unwrap()).into_owned(), + }) + }) +} + +pub fn run_command(command: impl Into) -> Result { + run_until(command.into(), None, TIMEOUT, Faults::default()) +} +pub fn run_tags_command(command: impl Into) -> Result { + run_until(command.into(), Some(MAX_TAGS), TIMEOUT, Faults::default()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/basal-host/src/builtins/git/windows/tests.rs b/crates/basal-host/src/builtins/git/windows/tests.rs new file mode 100644 index 0000000..0f6d6b3 --- /dev/null +++ b/crates/basal-host/src/builtins/git/windows/tests.rs @@ -0,0 +1,1004 @@ +use super::*; +use std::sync::Arc; +use windows_sys::Win32::{ + Foundation::{ + CompareObjectHandles, DUPLICATE_SAME_ACCESS, DuplicateHandle, ERROR_INVALID_HANDLE, + STILL_ACTIVE, + }, + System::{ + JobObjects::QueryInformationJobObject, + Threading::{ + CREATE_BREAKAWAY_FROM_JOB, GetCurrentProcess, OpenProcess, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, + }, + }, +}; + +struct Tree(PathBuf); +impl Tree { + fn new() -> Self { + let mut random = [0u8; 16]; + assert!( + unsafe { + BCryptGenRandom( + std::ptr::null_mut(), + random.as_mut_ptr(), + random.len() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + } >= 0 + ); + let name: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let path = std::env::temp_dir().join(format!("basal-git-test-{name}")); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + fn p(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} +impl Drop for Tree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn child_image() -> &'static Path { + static IMAGE: OnceLock = OnceLock::new(); + IMAGE.get_or_init(|| { + let tree = Tree::new(); + let source = tree.p("child.rs"); + std::fs::write(&source, include_str!("../fixtures/windows_child.rs")).unwrap(); + let image = tree.p("argv-echo.exe"); + let output = Command::new("rustc") + .args(["--edition=2024", "-Dwarnings"]) + .arg(&source) + .arg("-o") + .arg(&image) + .output() + .expect("native rustc is required"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + // Keep the compiled child executable available to concurrent tests. + std::mem::forget(tree); + image + }) +} +fn child(mode: &str) -> Command { + let mut command = Command::new(child_image()); + command.arg(mode); + command +} +fn succeeded(result: Ran) -> Ran { + assert!(result.success, "{}", result.stderr); + result +} +fn runner(command: Command, lines: Option, timeout: Duration) -> Result { + run_until(command.into(), lines, timeout, Faults::default()) +} + +#[test] +fn windows_crt_argv_round_trip_preserves_quotes_and_utf16() { + let arguments = [ + OsString::from(""), + OsString::from("HEAD:a\"b"), + OsString::from("a\\\"b"), + OsString::from("a\\\\\"b"), + OsString::from("with space\\"), + OsString::from("\t\n"), + OsString::from("C:\\résumé\\日本語"), + OsString::from_wide(&[0xd800, b'"' as u16, b'\\' as u16]), + ]; + let mut command = child("argv"); + command.args(&arguments); + let result = succeeded(run_command(command).unwrap()); + let actual: Vec> = String::from_utf8(result.stdout) + .unwrap() + .lines() + .map(|line| { + line.as_bytes() + .chunks(4) + .map(|unit| u16::from_str_radix(std::str::from_utf8(unit).unwrap(), 16).unwrap()) + .collect() + }) + .collect(); + let expected: Vec> = arguments + .iter() + .map(|arg| arg.encode_wide().collect()) + .collect(); + assert_eq!(actual, expected); +} + +#[test] +fn windows_drive_paths_refuse_unc_and_preserve_utf16() { + for path in [ + r"\\?\UNC\server\share\repo", + r"\\?\Volume{abc}\repo", + r"\\.\C:\repo", + r"\\server\share\repo", + r"C:repo", + r"\repo", + ] { + assert!(drive_path(Path::new(path)).is_err(), "accepted {path}"); + } + let input = OsString::from_wide(&[92, 92, 63, 92, 67, 58, 92, 0xd800]); + assert_eq!( + drive_path(Path::new(&input)) + .unwrap() + .as_os_str() + .encode_wide() + .collect::>(), + [67, 58, 92, 0xd800] + ); +} + +#[test] +fn windows_git_resolution_ignores_relative_path_and_cwd_images() { + let tree = Tree::new(); + std::fs::write(tree.p("git.exe"), b"MZ fixture, never executed").unwrap(); + let paths = std::env::join_paths([ + Path::new(""), + Path::new("relative-bin"), + Path::new(r"C:bin"), + Path::new(r"\bin"), + &tree.0, + ]) + .unwrap(); + let resolved = resolve_git_binary(OsStr::new("git"), Some(&paths)).unwrap(); + assert!(resolved.is_absolute()); + assert_eq!( + resolved, + drive_path(&std::fs::canonicalize(tree.p("git.exe")).unwrap()).unwrap() + ); + let only_relative = OsStr::new(";relative-bin;C:bin;\\bin"); + assert!(resolve_git_binary(OsStr::new("git"), Some(only_relative)).is_err()); + assert!(resolve_git_binary(OsStr::new("relative-bin\\git.exe"), None).is_err()); + for extension in ["bat", "CMD"] { + assert!(resolve_git_binary(tree.p(&format!("git.{extension}")).as_os_str(), None).is_err()); + } + let image = tree.p("git.exe"); + std::fs::remove_file(&image).unwrap(); + std::fs::write(tree.p("git.cmd"), "exit /b 0").unwrap(); + assert!(resolve_git_binary(OsStr::new("git"), Some(&paths)).is_err()); +} + +#[test] +fn windows_explicit_application_is_independent_of_current_directory() { + let tree = Tree::new(); + // A script and a fake executable in the CWD must not shadow the resolved image. + std::fs::write(tree.p("argv-echo.exe"), "not an executable").unwrap(); + std::fs::write(tree.p("git.exe"), "not an executable").unwrap(); + let mut command = child("argv"); + command.current_dir(&tree.0).arg("stable image"); + assert_eq!( + succeeded(run_command(command).unwrap()).stdout, + b"0073007400610062006c006500200069006d006100670065\n" + ); + let marker = tree.p("application-marker"); + let mut command = child("mark"); + command.arg(&marker).current_dir(&tree.0); + let input = command.into(); + // Deliberately misleading argv[0] distinguishes explicit application selection + // from CreateProcess's fallback command-line executable search. + let wrong_image = tree.p("git.exe"); + let (guard, _, _) = + spawn_with_argv0(&input, |_| Ok(()), Some(wrong_image.as_os_str())).unwrap(); + assert_eq!( + unsafe { WaitForSingleObject(guard.process.0, 5000) }, + WAIT_OBJECT_0 + ); + assert!(marker.exists()); +} + +#[test] +fn windows_private_isolation_is_fresh_exclusive_and_pinned() { + let first = Isolation::new().unwrap(); + let second = Isolation::new().unwrap(); + assert_ne!(first.directory, second.directory); + let config = first.directory.join("global.config"); + assert_eq!(std::fs::metadata(&config).unwrap().len(), 0); + assert!(std::fs::write(&config, "[alias]\npwn = !whoami\n").is_err()); + assert!(std::fs::rename(&config, first.directory.join("swapped.config")).is_err()); + assert!(std::fs::rename(&first.directory, first.directory.with_extension("swap")).is_err()); + let hooks = first.directory.join("hooks"); + let config_path = config.clone(); + let hooks_path = hooks.clone(); + thread::spawn(move || { + for _ in 0..20 { + assert!(std::fs::write(&config_path, "[alias]\npwn = !whoami\n").is_err()); + assert!(std::fs::rename(&hooks_path, hooks_path.with_extension("swap")).is_err()); + } + }) + .join() + .unwrap(); + let old_predictable = + std::env::temp_dir().join(format!("basal-git-isolation-{}", std::process::id())); + // The obsolete name is untrusted; no production helper consults it. + std::fs::create_dir_all(&old_predictable).unwrap(); + std::fs::write( + old_predictable.join("empty.config"), + "[alias]\npwn = !whoami\n", + ) + .unwrap(); + let third = Isolation::new().unwrap(); + assert_ne!(third.directory, old_predictable); + assert_eq!( + std::fs::metadata(third.directory.join("global.config")) + .unwrap() + .len(), + 0 + ); + std::fs::remove_dir_all(old_predictable).unwrap(); + let directory = first.directory.clone(); + drop(first); + assert!(!directory.exists()); + assert_eq!( + succeeded(run_command(child("stdin")).unwrap()).stdout, + b"inert\n" + ); +} + +fn private_dacl(path: &Path) -> String { + use windows_sys::Win32::Security::{ + Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, GetNamedSecurityInfoW, + SE_FILE_OBJECT, + }, + DACL_SECURITY_INFORMATION, + }; + let mut descriptor = std::ptr::null_mut(); + assert_eq!( + unsafe { + GetNamedSecurityInfoW( + wide(path).unwrap().as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut descriptor, + ) + }, + 0 + ); + let descriptor = SecurityDescriptor(descriptor); + let mut sddl = std::ptr::null_mut(); + let mut length = 0; + assert_ne!( + unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor.0, + 1, + DACL_SECURITY_INFORMATION, + &mut sddl, + &mut length, + ) + }, + 0 + ); + let text = unsafe { + String::from_utf16(std::slice::from_raw_parts(sddl, length as usize - 1)).unwrap() + }; + unsafe { + LocalFree(sddl.cast()); + } + text +} + +#[test] +fn windows_isolation_trims_host_temp_separator_and_has_private_dacl() { + let tree = Tree::new(); + let mut base = tree.0.as_os_str().to_owned(); + base.push("\\"); + let isolation = Isolation::in_base(Path::new(&base)).unwrap(); + assert_eq!(isolation.directory.parent(), Some(tree.0.as_path())); + for path in [ + &isolation.directory, + &isolation.directory.join("hooks"), + &isolation.directory.join("global.config"), + ] { + let sddl = private_dacl(path); + // Both explicit and inherited ACLs must grant only SYSTEM and the owner. + assert!(sddl.starts_with("D:P"), "DACL is not protected: {sddl}"); + let trustees: Vec<&str> = sddl + .split(";;;") + .skip(1) + .map(|ace| ace.split(')').next().unwrap()) + .collect(); + assert_eq!(trustees.len(), 2, "unexpected ACL entries: {sddl}"); + assert!( + trustees.contains(&"SY") && trustees.contains(&"OW"), + "not private: {sddl}" + ); + } + let link = tree.p("temp-junction"); + junction(&link, &tree.0); + assert!( + Isolation::in_base(&link).is_err(), + "temp junction was accepted" + ); + std::fs::remove_dir(link).unwrap(); +} + +#[test] +fn windows_attribute_payloads_and_job_membership_before_work() { + let tree = Tree::new(); + let marker = tree.p("work"); + let mut command = child("mark"); + command.arg(&marker); + let input: CommandInput = command.into(); + let (mut guard, _, _) = spawn_job_command(&input, |guard| { + assert!(!marker.exists(), "child ran before membership verification"); + let job = guard.job.as_ref().unwrap().0; + let mut in_job = 0; + assert_ne!( + unsafe { IsProcessInJob(guard.process.0, job, &mut in_job) }, + 0 + ); + assert_ne!(in_job, 0); + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + assert_ne!( + unsafe { + QueryInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (&mut limits as *mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + std::mem::size_of_val(&limits) as u32, + std::ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!( + limits.BasicLimitInformation.LimitFlags, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + ); + // Moving an attribute owner must not move either retained payload. + let inherit = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: std::ptr::null_mut(), + bInheritHandle: 1, + }; + let (_read_a, write_a) = pipe(&inherit).unwrap(); + let (_read_b, write_b) = pipe(&inherit).unwrap(); + let (_read_c, write_c) = pipe(&inherit).unwrap(); + let attributes = AttributeList::new(job, [write_a.0, write_b.0, write_c.0]).unwrap(); + let jobs = attributes.jobs.as_ptr(); + let handles = attributes.handles.as_ptr(); + let moved = Box::new(attributes); + assert_eq!(moved.jobs.as_ptr(), jobs); + assert_eq!(moved.handles.as_ptr(), handles); + Ok(()) + }) + .unwrap(); + assert_eq!( + unsafe { WaitForSingleObject(guard.process.0, 5000) }, + WAIT_OBJECT_0 + ); + assert!(marker.exists()); + guard.stop_tree(); + let mut command = child("breakaway"); + command + .arg(tree.p("escaped")) + .arg(CREATE_BREAKAWAY_FROM_JOB.to_string()); + succeeded(run_command(command).unwrap()); + assert!(!tree.p("escaped").exists()); +} + +fn await_file(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(10); + while !path.exists() { + assert!( + Instant::now() < deadline, + "child did not create {}", + path.display() + ); + thread::sleep(Duration::from_millis(5)); + } +} +fn descendant(pid_path: &Path) -> OwnedHandle { + await_file(pid_path); + let pid: u32 = std::fs::read_to_string(pid_path).unwrap().parse().unwrap(); + let handle = unsafe { + OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, + 0, + pid, + ) + }; + assert!( + !handle.is_null(), + "cannot retain live descendant: {}", + std::io::Error::last_os_error() + ); + let handle = OwnedHandle(handle); + assert_eq!(unsafe { WaitForSingleObject(handle.0, 0) }, WAIT_TIMEOUT); + let mut code = 0; + assert_ne!(unsafe { GetExitCodeProcess(handle.0, &mut code) }, 0); + assert_eq!(code, STILL_ACTIVE as u32); + handle +} +fn require_dead(handle: &OwnedHandle) { + assert_eq!( + unsafe { WaitForSingleObject(handle.0, 5000) }, + WAIT_OBJECT_0, + "descendant survived cleanup" + ); + let mut code = 0; + assert_ne!(unsafe { GetExitCodeProcess(handle.0, &mut code) }, 0); + assert_ne!(code, STILL_ACTIVE as u32); +} + +#[test] +fn windows_timeout_proves_live_descendant_died() { + let tree = Arc::new(Tree::new()); + let worker_tree = tree.clone(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut command = child("spawn"); + command + .arg(worker_tree.p("pid")) + .arg(worker_tree.p("ready")); + tx.send(runner(command, None, Duration::from_secs(3)).map(|_| ())) + .unwrap(); + }); + let handle = descendant(&tree.p("pid")); + let error = rx + .recv_timeout(Duration::from_secs(10)) + .unwrap() + .unwrap_err(); + assert_eq!(error.code, codes::TIMEOUT); + require_dead(&handle); +} + +#[test] +fn windows_post_exit_kills_descendant_holding_both_pipes() { + let tree = Arc::new(Tree::new()); + let worker_tree = tree.clone(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut command = child("post-exit"); + command + .arg(worker_tree.p("pid")) + .arg(worker_tree.p("ready")); + tx.send(run_command(command).map(|_| ())).unwrap(); + }); + let handle = descendant(&tree.p("pid")); + await_file(&tree.p("ready")); + std::fs::write(tree.p("ready.release"), "release").unwrap(); + rx.recv_timeout(Duration::from_secs(10)).unwrap().unwrap(); + require_dead(&handle); +} + +#[test] +fn windows_tags_stop_while_reading_and_enforce_byte_cap() { + let mut command = child("lines"); + command.arg("80"); + let result = succeeded(runner(command, Some(MAX_TAGS), Duration::from_secs(3)).unwrap()); + assert_eq!( + result.stdout.iter().filter(|b| **b == b'\n').count(), + MAX_TAGS + ); + let mut command = child("lines"); + command.arg((MAX_OUTPUT_BYTES / (MAX_TAGS - 1) + 1).to_string()); + assert_eq!( + runner(command, Some(MAX_TAGS), Duration::from_secs(3)) + .err() + .unwrap() + .code, + codes::TOO_LARGE + ); +} + +#[test] +fn windows_failed_termination_is_retryable_and_scope_unwind_is_bounded() { + let tree = Arc::new(Tree::new()); + let worker_tree = tree.clone(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut command = child("hold"); + command.arg(worker_tree.p("pid")); + let input = command.into(); + let (mut guard, _, _) = spawn_job_command(&input, |_| Ok(())).unwrap(); + guard.fail_termination_once = true; + assert!(guard.terminate().is_err()); + assert!(!guard.terminated); + guard.terminate().unwrap(); + assert!(guard.terminated); + assert_eq!( + unsafe { WaitForSingleObject(guard.process.0, 5000) }, + WAIT_OBJECT_0 + ); + let mut command = child("hold"); + command.arg(worker_tree.p("panic-pid")); + let panicked = std::panic::catch_unwind(|| { + run_until( + command.into(), + None, + Duration::from_secs(2), + Faults { + fail_termination_once: true, + panic_after_waiter: true, + }, + ) + }); + assert!(panicked.is_err()); + tx.send(()).unwrap(); + }); + rx.recv_timeout(Duration::from_secs(10)) + .expect("scope cleanup hung after startup failure"); +} + +#[test] +fn windows_only_stdio_handles_are_inherited() { + // Compile before publishing any inheritable test handle to other processes. + let image = wide(child_image()).unwrap(); + let inherit = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: std::ptr::null_mut(), + bInheritHandle: 1, + }; + let (_read, write) = pipe(&inherit).unwrap(); + // Positive control: CreateProcess(TRUE) without a handle list inherits this + // exact pipe writer. The child remains suspended and is killed by the guard. + let mut startup: windows_sys::Win32::System::Threading::STARTUPINFOW = + unsafe { std::mem::zeroed() }; + startup.cb = std::mem::size_of_val(&startup) as u32; + let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + let mut argv = Vec::new(); + append_windows_arg(&mut argv, child_image().as_os_str()).unwrap(); + argv.push(0); + assert_ne!( + unsafe { + CreateProcessW( + image.as_ptr(), + argv.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 1, + CREATE_SUSPENDED | CREATE_NO_WINDOW, + std::ptr::null(), + std::ptr::null(), + &startup, + &mut process, + ) + }, + 0 + ); + let control = SuspendedControl(OwnedHandle(process.hProcess)); + let _primary_thread = OwnedHandle(process.hThread); + assert!( + process_has_handle(control.0.0, write.0), + "positive control did not inherit the writer" + ); + drop(control); + let tree = Tree::new(); + let mut command = child("hold"); + command.arg(tree.p("pid")); + let input = command.into(); + let (mut guard, _, _) = spawn_job_command(&input, |guard| { + assert!( + !process_has_handle(guard.process.0, write.0), + "contained child inherited an unrelated writer before resume" + ); + Ok(()) + }) + .unwrap(); + await_file(&tree.p("pid")); + // Pipe EOF is not process-specific: another concurrent, unrestricted spawn + // can inherit the writer. Duplicate from the tested child's handle table + // instead; object comparison also distinguishes reuse of the numeric slot. + assert!( + !process_has_handle(guard.process.0, write.0), + "contained child owns an unrelated writer after resume" + ); + guard.stop_tree(); +} + +struct SuspendedControl(OwnedHandle); +impl Drop for SuspendedControl { + fn drop(&mut self) { + unsafe { + TerminateProcess(self.0.0, 1); + WaitForSingleObject(self.0.0, 5000); + } + } +} + +fn process_has_handle(process: HANDLE, original: HANDLE) -> bool { + let mut duplicate = std::ptr::null_mut(); + // Inherited handles have the same numeric value in both processes. Keep + // the parent's original live and compare objects, not just handle numbers. + if unsafe { + DuplicateHandle( + process, + original, + GetCurrentProcess(), + &mut duplicate, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) + } == 0 + { + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(ERROR_INVALID_HANDLE as i32), + "could not inspect the child's handle table" + ); + return false; + } + let duplicate = OwnedHandle(duplicate); + unsafe { CompareObjectHandles(original, duplicate.0) != 0 } +} + +fn repository(tree: &Tree) -> PathBuf { + let repo = tree.p("repo"); + std::fs::create_dir(&repo).unwrap(); + setup_git(&repo, &["init", "-q"]); + std::fs::write(repo.join("file.txt"), "one\n").unwrap(); + setup_git(&repo, &["add", "."]); + setup_git( + &repo, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "commit", + "-qm", + "first", + ], + ); + std::fs::write(repo.join("file.txt"), "two\n").unwrap(); + setup_git(&repo, &["add", "."]); + setup_git( + &repo, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "commit", + "-qm", + "second", + ], + ); + repo +} +fn plain_git(repo: &Path) -> Command { + let mut command = Command::new( + resolve_git().expect("native git.exe is required for Windows confinement tests"), + ); + command + .arg("-C") + .arg(repo) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "NUL"); + command +} +fn setup_git(repo: &Path, args: &[&str]) { + let output = plain_git(repo).args(args).output().unwrap(); + assert!( + output.status.success(), + "setup git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} +fn junction(link: &Path, target: &Path) { + let output = Command::new("cmd.exe") + .args(["/d", "/c", "mklink", "/J"]) + .arg(link) + .arg(target) + .output() + .unwrap(); + assert!( + output.status.success(), + "junction creation failed: {}", + String::from_utf8_lossy(&output.stdout) + ); +} + +#[test] +fn windows_repository_walk_refuses_junctions_and_pins_all_ancestors() { + let tree = Tree::new(); + let parent = tree.p("parent"); + std::fs::create_dir(&parent).unwrap(); + let repo = parent.join("repo"); + std::fs::create_dir(&repo).unwrap(); + let approved = [repo.to_str().unwrap().to_owned()]; + // Positive control: the same names really can be replaced without a pin. + let moved = tree.p("moved"); + std::fs::rename(&parent, &moved).unwrap(); + std::fs::rename(&moved, &parent).unwrap(); + let pin = super::super::repo(repo.to_str().unwrap(), &approved).unwrap(); + assert!( + std::fs::rename(&parent, &moved).is_err(), + "ancestor replaced while git is authorized" + ); + assert!( + std::fs::rename(&repo, parent.join("old-repo")).is_err(), + "repo replaced while git is authorized" + ); + let mut command = child("argv"); + command.current_dir(pin.path()).arg("pinned"); + succeeded(run_command(command).unwrap()); + drop(pin); + std::fs::rename(&parent, &moved).unwrap(); + junction(&parent, &moved); + assert!(super::super::repo(repo.to_str().unwrap(), &approved).is_err()); + std::fs::remove_dir(&parent).unwrap(); + junction(&tree.p("root-link"), &moved.join("repo")); + let link = tree.p("root-link").to_str().unwrap().to_owned(); + assert!(super::super::repo(&link, std::slice::from_ref(&link)).is_err()); + std::fs::remove_dir(tree.p("root-link")).unwrap(); + let sub = moved.join("repo").join("sub"); + std::fs::create_dir(&sub).unwrap(); + assert!( + super::super::repo( + sub.to_str().unwrap(), + &[moved.join("repo").to_str().unwrap().to_owned()] + ) + .is_err() + ); +} + +#[test] +fn windows_repository_refusals_do_not_reveal_unapproved_existence() { + let tree = Tree::new(); + let approved = tree.p("approved"); + std::fs::create_dir(&approved).unwrap(); + let roots = [approved.to_str().unwrap().to_owned()]; + let outside = tree.p("outside"); + let missing = tree.p("missing"); + std::fs::create_dir(&outside).unwrap(); + for path in [&outside, &missing] { + assert_eq!( + super::super::repo(path.to_str().unwrap(), &roots) + .err() + .unwrap() + .code, + codes::DENIED + ); + } +} + +fn marker_script(tree: &Tree, name: &str) -> (PathBuf, PathBuf) { + let marker = tree.p(&format!("{name}.marker")); + let script = tree.p(&format!("{name}.sh")); + let marker_shell = marker + .to_str() + .unwrap() + .replace('\\', "/") + .replace('\'', "'\\''"); + std::fs::write( + &script, + format!("#!/bin/sh\nprintf ran >> '{marker_shell}'\nprintf 'converted\\n'\n"), + ) + .unwrap(); + (script, marker) +} +fn helper_value(script: &Path) -> String { + format!( + "sh '{}'", + script + .to_str() + .unwrap() + .replace('\\', "/") + .replace('\'', "'\\''") + ) +} +fn hard_git(repo: &Path, args: &[&str]) -> Ran { + let mut command = hardened_command(repo).unwrap(); + command.args(args); + run_command(command).unwrap() +} +fn positive_marker(repo: &Path, args: &[&str], marker: &Path) { + let mut command = plain_git(repo); + command.args(args); + succeeded(run_command(command).unwrap()); + assert!( + marker.exists(), + "positive control did not execute the planted helper: {args:?}" + ); + std::fs::remove_file(marker).unwrap(); +} + +#[test] +fn windows_hook_positive_control_and_hardened_suppression() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "hook"); + // Hooks have no .bat suffix: Git for Windows executes the shebang file. + std::fs::copy( + script, + repo.join(".git").join("hooks").join("post-checkout"), + ) + .unwrap(); + positive_marker(&repo, &["checkout", "--detach", "HEAD~"], &marker); + succeeded(hard_git(&repo, &["checkout", "--detach", "HEAD"])); + assert!(!marker.exists(), "hook ran through hardened hooksPath"); +} + +#[test] +fn windows_fsmonitor_positive_control_and_hardened_suppression() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "fsmonitor"); + setup_git(&repo, &["config", "core.fsmonitor", &helper_value(&script)]); + positive_marker(&repo, &["status", "--porcelain"], &marker); + succeeded(hard_git(&repo, &["status", "--porcelain"])); + assert!(!marker.exists(), "fsmonitor ran through hardened command"); +} + +#[test] +fn windows_external_diff_positive_control_and_builtin_suppression() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "external-diff"); + setup_git(&repo, &["config", "diff.external", &helper_value(&script)]); + positive_marker(&repo, &["diff", "HEAD~", "HEAD", "--", "file.txt"], &marker); + let path = repo.to_str().unwrap().to_owned(); + let op = super::super::Op::Diff { + from: "HEAD~".into(), + to: "HEAD".into(), + path: Some("file.txt".into()), + }; + super::super::run(&path, std::slice::from_ref(&path), &op).unwrap(); + assert!(!marker.exists(), "external diff ran through builtin diff"); +} + +#[test] +fn windows_textconv_positive_control_and_builtin_suppression() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "textconv"); + std::fs::write(repo.join(".gitattributes"), "file.txt diff=marker\n").unwrap(); + setup_git( + &repo, + &["config", "diff.marker.textconv", &helper_value(&script)], + ); + positive_marker(&repo, &["diff", "HEAD~", "HEAD", "--", "file.txt"], &marker); + let path = repo.to_str().unwrap().to_owned(); + let op = super::super::Op::Diff { + from: "HEAD~".into(), + to: "HEAD".into(), + path: Some("file.txt".into()), + }; + super::super::run(&path, std::slice::from_ref(&path), &op).unwrap(); + assert!(!marker.exists(), "textconv ran through builtin diff"); +} + +#[test] +fn windows_contaminated_global_config_positive_control_and_isolation() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "global"); + let config = tree.p("hostile-global.config"); + let alias = format!("!{}", helper_value(&script)); + let output = Command::new(resolve_git().unwrap()) + .args(["config", "--file"]) + .arg(&config) + .args(["alias.basal-poison", &alias]) + .output() + .unwrap(); + assert!(output.status.success()); + let mut command = plain_git(&repo); + command + .env("GIT_CONFIG_GLOBAL", &config) + .arg("basal-poison"); + succeeded(run_command(command).unwrap()); + assert!( + marker.exists(), + "contaminated global alias positive control did not run" + ); + std::fs::remove_file(&marker).unwrap(); + let profile = tree.p("profile"); + std::fs::create_dir(&profile).unwrap(); + std::fs::copy(&config, profile.join(".gitconfig")).unwrap(); + let mut default_global = plain_git(&repo); + default_global + .env_remove("GIT_CONFIG_GLOBAL") + .env("HOME", &profile) + .env("USERPROFILE", &profile) + .arg("basal-poison"); + succeeded(run_command(default_global).unwrap()); + assert!( + marker.exists(), + "default user-global config positive control did not run" + ); + std::fs::remove_file(&marker).unwrap(); + let mut command = hardened_command(&repo).unwrap(); + command + .env("HOME", &profile) + .env("USERPROFILE", &profile) + .arg("basal-poison"); + let block = environment(&command.command, true).unwrap(); + assert!(!String::from_utf16_lossy(&block).contains(config.to_str().unwrap())); + let result = run_command(command).unwrap(); + assert!( + !result.success, + "global alias survived isolated global config" + ); + assert!(!marker.exists()); +} + +#[test] +fn windows_hostile_environment_child() { + let Some(repo) = std::env::var_os("BASAL_GIT_ENV_REPO") else { + return; + }; + let config = + std::env::var_os("GIT_CONFIG_GLOBAL").expect("parent supplied contaminated global config"); + let mut positive = plain_git(Path::new(&repo)); + positive + .env_remove("GIT_DIR") + .env_remove("GIT_CONFIG_COUNT") + .env("GIT_CONFIG_GLOBAL", &config) + .arg("basal-poison"); + succeeded(run_command(positive).unwrap()); + let marker = PathBuf::from(std::env::var_os("BASAL_GIT_ENV_MARKER").unwrap()); + assert!(marker.exists()); + std::fs::remove_file(&marker).unwrap(); + let result = hard_git(Path::new(&repo), &["basal-poison"]); + assert!(!result.success); + assert!( + !marker.exists(), + "inherited global config executed a helper" + ); + let mut command = hardened_command(Path::new(&repo)).unwrap(); + command.args(["rev-parse", "--show-toplevel"]); + let result = succeeded(run_command(command).unwrap()); + assert!( + String::from_utf8(result.stdout).unwrap().contains("repo"), + "inherited GIT_DIR redirected the repository" + ); +} + +#[test] +fn windows_inherited_git_environment_is_not_imported() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "inherited-global"); + let config = tree.p("global.config"); + let alias = format!("!{}", helper_value(&script)); + let output = Command::new(resolve_git().unwrap()) + .args(["config", "--file"]) + .arg(&config) + .args(["alias.basal-poison", &alias]) + .output() + .unwrap(); + assert!(output.status.success()); + // A subprocess is the real host with hostile inherited settings. No unsafe + // process-wide environment mutation races this suite's concurrent tests. + let output = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "builtins::git::windows::tests::windows_hostile_environment_child", + "--nocapture", + ]) + .env("BASAL_GIT_ENV_REPO", &repo) + .env("BASAL_GIT_ENV_MARKER", &marker) + .env("GIT_CONFIG_GLOBAL", &config) + .env("GIT_DIR", tree.p("not-a-repository")) + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "alias.basal-poison") + .env("GIT_CONFIG_VALUE_0", &alias) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("1 passed"), + "child control never executed" + ); + assert!(!marker.exists()); +} diff --git a/crates/basal-host/src/core_host.rs b/crates/basal-host/src/core_host.rs index 6b7bced..842438f 100644 --- a/crates/basal-host/src/core_host.rs +++ b/crates/basal-host/src/core_host.rs @@ -240,14 +240,44 @@ pub(crate) fn sample() -> f64 { // Live draws use OS entropy, not shared predictable process state. The // runtime journals each draw and replay serves that saved value instead. // There is deliberately no fallback when the OS cannot supply entropy. - let rc = unsafe { libc::getentropy((&mut x as *mut u64).cast(), std::mem::size_of::()) }; + fill_entropy(&mut x); + (x >> 11) as f64 / ((1u64 << 53) as f64) +} + +#[cfg(unix)] +fn fill_entropy(x: &mut u64) { + let rc = unsafe { libc::getentropy((x as *mut u64).cast(), std::mem::size_of::()) }; assert_eq!( rc, 0, "OS entropy unavailable: {}", std::io::Error::last_os_error() ); - (x >> 11) as f64 / ((1u64 << 53) as f64) +} + +#[cfg(windows)] +fn fill_entropy(x: &mut u64) { + // The system-preferred RNG needs no algorithm handle; flag value 2 is + // BCRYPT_USE_SYSTEM_PREFERRED_RNG. + #[link(name = "bcrypt")] + unsafe extern "system" { + fn BCryptGenRandom( + algorithm: *mut core::ffi::c_void, + buffer: *mut u8, + len: u32, + flags: u32, + ) -> i32; + } + const BCRYPT_USE_SYSTEM_PREFERRED_RNG: u32 = 2; + let status = unsafe { + BCryptGenRandom( + std::ptr::null_mut(), + (x as *mut u64).cast(), + std::mem::size_of::() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + }; + assert_eq!(status, 0, "OS entropy unavailable: NTSTATUS {status:#010x}"); } /// A published status lasts half an hour unless core supersedes its revision. diff --git a/crates/basal-host/tests/builtin_host_regressions.rs b/crates/basal-host/tests/builtin_host_regressions.rs index 1108d1b..14d41f8 100644 --- a/crates/basal-host/tests/builtin_host_regressions.rs +++ b/crates/basal-host/tests/builtin_host_regressions.rs @@ -2,6 +2,7 @@ use basal_host::builtins::{BuiltinHost, Grant, codes, envelope, fs, git, net}; use basal_host::{CallClass, CallRequest, Dispatched, Host}; use basal_proto::{CallKind, JsonText, Primitive, Settlement}; use serde_json::json; +#[cfg(unix)] use std::os::unix::fs::{PermissionsExt, symlink}; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; @@ -31,7 +32,7 @@ impl Drop for Tree { #[test] fn escaped_800kb_file_is_a_typed_refusal_not_successful_null() { let tree = Tree::new(); - let path = tree.0.join("root/escaped"); + let path = tree.0.join("root").join("escaped"); std::fs::write(&path, vec![0; 800 * 1024]).unwrap(); let request = CallRequest { flow_id: "f".into(), @@ -63,6 +64,7 @@ fn escaped_800kb_file_is_a_typed_refusal_not_successful_null() { } #[test] +#[cfg(unix)] fn escaped_listing_exceeds_encoded_cap_as_a_typed_refusal() { let tree = Tree::new(); let dir = tree.0.join("root"); @@ -106,13 +108,16 @@ fn outside_missing_and_symlink_targets_have_identical_refusals() { let tree = Tree::new(); let existing = tree.0.join("secret"); std::fs::write(&existing, "secret").unwrap(); + #[cfg(unix)] let link = tree.0.join("root/link"); + #[cfg(unix)] symlink(&existing, &link).unwrap(); - let missing = tree.0.join("absent/child"); + let missing = tree.0.join("absent").join("child"); for purpose in [fs::Purpose::Read, fs::Purpose::Write] { let a = fs::resolve(existing.to_str().unwrap(), &tree.roots(), purpose).unwrap_err(); let b = fs::resolve(missing.to_str().unwrap(), &tree.roots(), purpose).unwrap_err(); assert_eq!(a, b); + #[cfg(unix)] if purpose == fs::Purpose::Read { assert_eq!( a, @@ -129,9 +134,10 @@ fn outside_missing_and_symlink_targets_have_identical_refusals() { } #[test] +#[cfg(unix)] fn atomic_write_drops_special_permission_bits() { let tree = Tree::new(); - let path = tree.0.join("root/file"); + let path = tree.0.join("root").join("file"); std::fs::write(&path, "old").unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o6755)).unwrap(); fs::write(path.to_str().unwrap(), &tree.roots(), "").unwrap(); @@ -179,6 +185,7 @@ fn method_override_and_compression_headers_are_denied() { } #[test] +#[cfg(unix)] fn git_reaps_descendants_that_keep_its_output_pipe_open() { use std::process::{Command, Stdio}; use std::time::Duration; @@ -219,13 +226,21 @@ fn git_reaps_descendants_that_keep_its_output_pipe_open() { fn tag_patterns_use_git_globs_including_hierarchical_names() { let tree = Tree::new(); let repo = tree.0.join("root"); + let global_config = tree.0.join("empty.gitconfig"); + std::fs::write(&global_config, []).unwrap(); let git_command = |args: &[&str]| { let result = std::process::Command::new("git") + .env_clear() + .envs(std::env::vars_os().filter(|(key, _)| { + ["PATH", "HOME", "SystemRoot", "USERPROFILE"] + .iter() + .any(|kept| key.to_string_lossy().eq_ignore_ascii_case(kept)) + })) .arg("-C") .arg(&repo) .args(args) .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_GLOBAL", &global_config) .env("GIT_AUTHOR_NAME", "Fixture") .env("GIT_AUTHOR_EMAIL", "fixture@example.invalid") .env("GIT_COMMITTER_NAME", "Fixture") diff --git a/crates/basal-launch/Cargo.toml b/crates/basal-launch/Cargo.toml new file mode 100644 index 0000000..ec593ef --- /dev/null +++ b/crates/basal-launch/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "basal-launch" +version = "0.1.0" +description = "Starts basal's worker process under the Windows confinement: AppContainer profile, restricted tokens, job, mitigations and the checks made before the worker runs." +edition.workspace = true +license.workspace = true +publish.workspace = true +rust-version.workspace = true + +# A tiny GUI-subsystem child image used only by this crate's tests. It is a +# binary of this package so that `cargo test` builds it next to the tests. +[[bin]] +name = "basal-launch-test-helper" +path = "src/bin/test-helper.rs" +test = false +bench = false + +[features] +default = [] +# Test-only launch variants: the weaker positive controls and one variant per +# confinement check, each built so that exactly that check must catch it. +# A build without this feature contains only the full confinement. +deviations = [] + +# User32 (window stations and desktops) and Userenv (AppContainer profiles) +# are loaded at run time from System32 instead of being linked, so that no +# image linking this crate imports them. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_Console", + "Win32_System_JobObjects", + "Win32_System_LibraryLoader", + "Win32_System_Pipes", + "Win32_System_SystemInformation", + "Win32_System_SystemServices", + "Win32_System_Threading", + "Win32_System_WindowsProgramming", +] } diff --git a/crates/basal-launch/src/bin/test-helper.rs b/crates/basal-launch/src/bin/test-helper.rs new file mode 100644 index 0000000..7ee6f05 --- /dev/null +++ b/crates/basal-launch/src/bin/test-helper.rs @@ -0,0 +1,46 @@ +//! A minimal child image for basal-launch's own tests. +//! +//! It is a GUI-subsystem image, like the worker, so no console host starts +//! for it. Started by the launcher, it drops the start-up thread token the +//! launcher set (as the worker does before reading input), reports its +//! standard handles on stdout, optionally tries to create a file in its +//! TEMP directory and reports the outcome, then waits for one byte or the end +//! of stdin and exits. +#![cfg_attr(windows, windows_subsystem = "windows")] + +#[cfg(windows)] +fn main() { + use std::io::{Read, Write}; + use windows_sys::Win32::Security::RevertToSelf; + use windows_sys::Win32::System::Console::{ + GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, + }; + + let reverted = unsafe { RevertToSelf() } != 0; + let [stdin, stdout, stderr] = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] + .map(|which| unsafe { GetStdHandle(which) } as usize); + let mut out = std::io::stdout().lock(); + let _ = writeln!( + out, + "ready stdin={stdin} stdout={stdout} stderr={stderr} reverted={reverted}" + ); + if std::env::args().any(|arg| arg == "--try-temp-write") { + let outcome = match std::env::var_os("TEMP") { + None => "no-temp".to_owned(), + Some(directory) => { + let path = std::path::Path::new(&directory).join("basal-launch-probe"); + match std::fs::File::create_new(&path) { + Ok(_) => "created".to_owned(), + Err(error) => format!("denied {}", error.raw_os_error().unwrap_or(-1)), + } + } + }; + let _ = writeln!(out, "temp-write {outcome}"); + } + let _ = out.flush(); + drop(out); + let _ = std::io::stdin().read(&mut [0u8; 1]); +} + +#[cfg(not(windows))] +fn main() {} diff --git a/crates/basal-launch/src/lib.rs b/crates/basal-launch/src/lib.rs new file mode 100644 index 0000000..66469a7 --- /dev/null +++ b/crates/basal-launch/src/lib.rs @@ -0,0 +1,28 @@ +//! Starts basal's worker process under the Windows confinement. +//! +//! On Windows the worker cannot confine itself the way it does on macOS and +//! Linux: most of the confinement has to be fixed by the parent when the +//! process is created. This crate owns that part: +//! +//! - the one AppContainer profile all workers share; +//! - the restricted primary token and the matching start-up thread token; +//! - the job the worker is born in, and its limits; +//! - the creation attributes: Less Privileged AppContainer, mitigation +//! policies, child-process ban and an explicit list of inherited handles; +//! - a minimal environment, working directory, window station and desktop; +//! - the checks the parent makes on the suspended process before it lets the +//! first instruction run, and the owned process wrapper afterwards; +//! - the one way to start an unconfined child (`spawn_plain`), and the +//! process-wide spawn lock every Windows spawn in basal takes while it +//! holds inheritable handles (`spawn_lock`). +//! +//! The worker's own checks, made after it starts and before it reads any +//! input, live in the worker. +//! +//! Off Windows this crate contains no code. + +#[cfg(windows)] +mod windows; + +#[cfg(windows)] +pub use windows::*; diff --git a/crates/basal-launch/src/windows/context.rs b/crates/basal-launch/src/windows/context.rs new file mode 100644 index 0000000..df97651 --- /dev/null +++ b/crates/basal-launch/src/windows/context.rs @@ -0,0 +1,297 @@ +//! The start-up context a worker gets: a private window station and desktop, +//! a private TEMP directory, a minimal environment and a working directory. + +use super::native::{Result, check, groups, last, sid_string, token_buffer, wide}; +use super::profile::{Library, PackageSid}; +use std::ffi::c_void; +use std::mem::size_of; +use std::path::{Path, PathBuf}; +use std::ptr::{null, null_mut}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; +use windows_sys::Win32::Foundation::{HANDLE, LocalFree}; +use windows_sys::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW; +use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, LABEL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, + PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, SetFileSecurityW, TOKEN_USER, TokenGroups, + TokenUser, +}; +use windows_sys::Win32::System::SystemInformation::GetSystemWindowsDirectoryW; + +use super::native::SE_GROUP_LOGON_ID; + +/// `WINSTA_ALL_ACCESS | STANDARD_RIGHTS_REQUIRED`. +const STATION_ACCESS: u32 = 0x000f_037f; +/// Every desktop right plus the standard rights, requested by the creator. +const DESKTOP_ACCESS: u32 = 0x000f_01ff; + +type CreateStation = + unsafe extern "system" fn(*const u16, u32, u32, *const SECURITY_ATTRIBUTES) -> HANDLE; +type CreateDesktop = unsafe extern "system" fn( + *const u16, + *const u16, + *const c_void, + u32, + u32, + *const SECURITY_ATTRIBUTES, +) -> HANDLE; +type GetStation = unsafe extern "system" fn() -> HANDLE; +type SetStation = unsafe extern "system" fn(HANDLE) -> i32; +type CloseObject = unsafe extern "system" fn(HANDLE) -> i32; + +/// Creating a desktop uses the process's current window station, which is +/// process-wide state; launches that switch it must not interleave. +static STATION_SWITCH: Mutex<()> = Mutex::new(()); + +static NEXT_CONTEXT: AtomicU64 = AtomicU64::new(0); + +/// A worker's start-up context. Dropping it closes the station and desktop +/// and removes the TEMP directory, so it must outlive the worker. +pub(crate) struct Context { + /// Keeps User32 loaded until the close functions below have run. + _user32: Library, + station: HANDLE, + desktop: HANDLE, + close_station: CloseObject, + close_desktop: CloseObject, + /// `station\desktop`, NUL-terminated, for `STARTUPINFO.lpDesktop`. + pub(crate) desktop_name: Vec, + /// The working directory, NUL-terminated. + pub(crate) cwd: Vec, + /// The environment block: sorted `NAME=value` strings, each + /// NUL-terminated, then one more NUL. + pub(crate) environment: Vec, + /// The private TEMP directory. + pub(crate) temp: PathBuf, +} + +// The station and desktop handles and the module handle are process-wide +// and may be closed from any thread. +unsafe impl Send for Context {} +unsafe impl Sync for Context {} + +impl Drop for Context { + fn drop(&mut self) { + unsafe { + if !self.desktop.is_null() { + (self.close_desktop)(self.desktop); + } + (self.close_station)(self.station); + } + let _ = std::fs::remove_dir_all(&self.temp); + } +} + +/// A security descriptor parsed from SDDL, freed on drop. +struct Descriptor(PSECURITY_DESCRIPTOR); + +impl Descriptor { + fn parse(sddl: &str) -> Result { + let mut descriptor = null_mut(); + check( + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + wide(sddl).as_ptr(), + 1, + &mut descriptor, + null_mut(), + ) + }, + "ConvertStringSecurityDescriptorToSecurityDescriptorW", + )?; + Ok(Self(descriptor)) + } + + fn attributes(&self) -> SECURITY_ATTRIBUTES { + SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: self.0, + bInheritHandle: 0, + } + } +} + +impl Drop for Descriptor { + fn drop(&mut self) { + unsafe { + LocalFree(self.0); + } + } +} + +/// The Windows directory, from the system rather than from this process's +/// own environment. +pub(crate) fn system_root() -> Result { + let mut buffer = [0u16; 260]; + let length = unsafe { GetSystemWindowsDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) }; + if length == 0 || length as usize >= buffer.len() { + return Err(last("GetSystemWindowsDirectoryW")); + } + Ok(String::from_utf16_lossy(&buffer[..length as usize])) +} + +/// The environment block a worker gets, and nothing from the parent's own +/// environment: only the Windows directory variables the system DLLs read, +/// `PATH` limited to System32, and TEMP-style variables naming the private +/// directory. Names are sorted case-insensitively, as Windows requires of an +/// environment block. +pub(crate) fn environment_block(system_root: &str, temp: &str) -> Vec { + let drive = system_root.get(..2).unwrap_or(system_root); + let mut variables = [ + ("LOCALAPPDATA", temp.to_owned()), + ("PATH", format!("{system_root}\\System32")), + ("SYSTEMDRIVE", drive.to_owned()), + ("SYSTEMROOT", system_root.to_owned()), + ("TEMP", temp.to_owned()), + ("TMP", temp.to_owned()), + ("windir", system_root.to_owned()), + ]; + variables.sort_by_key(|(name, _)| name.to_ascii_uppercase()); + variables + .iter() + .flat_map(|(name, value)| { + format!("{name}={value}") + .encode_utf16() + .chain([0]) + .collect::>() + }) + .chain([0]) + .collect() +} + +/// Creates the context for one worker. +/// +/// - **Window station and desktop:** private to this worker. Only SYSTEM, +/// Administrators and the parent's user have full access; the logon SID, +/// the NULL SID and the package get read and execute on the station and +/// read-control, read-objects and write-objects (`0x20081`) on the +/// desktop. Both carry a Low no-write-up label. +/// - **TEMP:** a fresh directory with a protected DACL. The worker's package +/// and the NULL SID get only read and execute, so the variables resolve to +/// an existing directory in which the worker can create nothing. +/// - **Working directory:** the image's own directory, which the package +/// must already be allowed to read for the image to load at all. +pub(crate) fn create(image: &Path, parent_token: HANDLE, package: &PackageSid) -> Result { + unsafe { + let user32 = Library::system32("user32.dll")?; + let create_station: CreateStation = user32.symbol(b"CreateWindowStationW\0")?; + let create_desktop: CreateDesktop = user32.symbol(b"CreateDesktopW\0")?; + let get_station: GetStation = user32.symbol(b"GetProcessWindowStation\0")?; + let set_station: SetStation = user32.symbol(b"SetProcessWindowStation\0")?; + let close_station: CloseObject = user32.symbol(b"CloseWindowStation\0")?; + let close_desktop: CloseObject = user32.symbol(b"CloseDesktop\0")?; + + let user = token_buffer(parent_token, TokenUser)?; + let user = sid_string((*user.as_ptr().cast::()).User.Sid)?; + let group_data = token_buffer(parent_token, TokenGroups)?; + let logon = match groups(&group_data) + .iter() + .find(|group| group.Attributes & SE_GROUP_LOGON_ID == SE_GROUP_LOGON_ID) + { + Some(group) => Some(sid_string(group.Sid)?), + None => None, + }; + let package = package.as_str(); + let logon_station = logon + .as_deref() + .map(|sid| format!("(A;;GRGX;;;{sid})")) + .unwrap_or_default(); + let logon_desktop = logon + .as_deref() + .map(|sid| format!("(A;;0x20081;;;{sid})")) + .unwrap_or_default(); + let station_sd = Descriptor::parse(&format!( + "D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;{user}){logon_station}(A;;GRGX;;;S-1-0-0)(A;;GRGX;;;{package})S:(ML;;NW;;;LW)" + ))?; + let desktop_sd = Descriptor::parse(&format!( + "D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;{user}){logon_desktop}(A;;0x20081;;;S-1-0-0)(A;;0x20081;;;{package})S:(ML;;NW;;;LW)" + ))?; + + let name = format!( + "cortexkit_basal_{}_{}", + std::process::id(), + NEXT_CONTEXT.fetch_add(1, Ordering::Relaxed) + ); + let station = create_station( + wide(&name).as_ptr(), + 0, + STATION_ACCESS, + &station_sd.attributes(), + ); + if station.is_null() { + return Err(last("CreateWindowStationW")); + } + let mut context = Context { + _user32: user32, + station, + desktop: null_mut(), + close_station, + close_desktop, + desktop_name: wide(format!("{name}\\worker")), + cwd: Vec::new(), + environment: Vec::new(), + temp: PathBuf::new(), + }; + { + let _switch = STATION_SWITCH + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let original = get_station(); + check(set_station(station), "SetProcessWindowStation(worker)")?; + context.desktop = create_desktop( + wide("worker").as_ptr(), + null(), + null(), + 0, + DESKTOP_ACCESS, + &desktop_sd.attributes(), + ); + let desktop_error = context.desktop.is_null().then(|| last("CreateDesktopW")); + // Restore the parent's station before anything else can run on it. + check(set_station(original), "SetProcessWindowStation(restore)")?; + if let Some(error) = desktop_error { + return Err(error); + } + } + + let directory = image + .parent() + .ok_or_else(|| format!("{} has no parent directory", image.display()))?; + context.cwd = wide(directory); + let temp = std::env::temp_dir().join(&name); + std::fs::create_dir(&temp) + .map_err(|error| format!("create {}: {error}", temp.display()))?; + // The system TEMP path often holds 8.3 short names (`RUNNER~1`); + // give the worker the long form. + context.temp = long_path(&temp); + let temp_text = context.temp.to_string_lossy().into_owned(); + let temp_sd = Descriptor::parse(&format!( + "D:P(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)(A;OICI;GA;;;{user})(A;OICI;GRGX;;;S-1-0-0)(A;OICI;GRGX;;;{package})S:(ML;OICI;NW;;;LW)" + ))?; + check( + SetFileSecurityW( + wide(&context.temp).as_ptr(), + DACL_SECURITY_INFORMATION + | LABEL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION, + temp_sd.0, + ), + "SetFileSecurityW(worker TEMP)", + )?; + context.environment = environment_block(&system_root()?, &temp_text); + Ok(context) + } +} + +/// The long form of an existing path, without the `\\?\` prefix that +/// canonicalization adds, or the path unchanged if it has no such form. +fn long_path(path: &Path) -> PathBuf { + let Ok(canonical) = std::fs::canonicalize(path) else { + return path.to_owned(); + }; + let text = canonical.to_string_lossy(); + match text.strip_prefix(r"\\?\") { + Some(rest) if rest.as_bytes().get(1) == Some(&b':') => PathBuf::from(rest), + _ => path.to_owned(), + } +} diff --git a/crates/basal-launch/src/windows/deviation.rs b/crates/basal-launch/src/windows/deviation.rs new file mode 100644 index 0000000..de5867b --- /dev/null +++ b/crates/basal-launch/src/windows/deviation.rs @@ -0,0 +1,434 @@ +//! The launch variants: the full confinement, and the test-only variants. + +use super::error::Refusal; +use super::launch::MITIGATION_POLICY; + +/// Which launch recipe to build. +/// +/// A build without the `deviations` feature has only [`Deviation::Full`]. +/// With the feature, every other variant is a test control: +/// +/// - two weaker positive controls, which show that a denial seen under the +/// full confinement comes from the confinement and not from a missing +/// target or a broken probe; +/// - one variant per confinement check, each building the full recipe with +/// exactly one property broken, so that exactly that check must refuse it. +/// A check's refusal test and its mutation control share this one +/// definition of what the check is meant to catch. +/// +/// For a worker check, the parent's own checks expect the broken property, so +/// the worker gets to start and must refuse by itself. For a parent check, +/// the parent still expects the full recipe and must refuse before resume. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum Deviation { + /// The full confinement. The only variant in production builds. + #[default] + Full, + + /// Positive control: a Less Privileged AppContainer with no capabilities, + /// started with `CreateProcessW` and the parent's unrestricted token, no + /// mitigations and no initial thread token. + #[cfg(feature = "deviations")] + LpacOnly, + /// Positive control: no AppContainer at all. + #[cfg(feature = "deviations")] + Plain, + + /// Worker check `thread-token-present`. The parent cannot leave a thread + /// token behind in a correctly built worker, so it asks the worker to + /// keep one (see [`Deviation::child_argument`]). + #[cfg(feature = "deviations")] + ThreadTokenPresent, + /// Worker check `integrity-lower-failed`. The parent cannot make lowering + /// fail in a correctly built worker, so it asks the worker to simulate the + /// failure (see [`Deviation::child_argument`]). + #[cfg(feature = "deviations")] + IntegrityLowerFailed, + /// Worker check `not-lpac`: the opt-out from the `ALL_APPLICATION_PACKAGES` + /// group is left out, so objects that grant every AppContainer package + /// grant this worker too, as for an ordinary AppContainer. + #[cfg(feature = "deviations")] + NotLpac, + /// Worker check `capabilities-present`: one capability is granted. + #[cfg(feature = "deviations")] + CapabilitiesPresent, + /// Worker check `restricting-sid-mismatch`: Everyone is added to the + /// restricting SIDs, next to the NULL SID. + #[cfg(feature = "deviations")] + RestrictingSidMismatch, + /// Worker check `group-not-deny-only`: the logon SID stays enabled. + #[cfg(feature = "deviations")] + GroupNotDenyOnly, + /// Worker check `privileges-present`: the privileges that survive + /// filtering are not removed. + #[cfg(feature = "deviations")] + PrivilegesPresent, + /// Worker check `integrity-not-untrusted`. The worker lowers its own + /// integrity, so the parent asks it to skip that step (see + /// [`Deviation::child_argument`]). + #[cfg(feature = "deviations")] + IntegrityNotUntrusted, + /// Worker check `mitigation-mismatch`: the dynamic-code prohibition is + /// left out of the mitigation policy. + #[cfg(feature = "deviations")] + MitigationMismatch, + /// Worker check `handle-not-allowed`: the explicit inherited-handle list + /// is left out, so every inheritable handle of the parent is inherited. + #[cfg(feature = "deviations")] + HandleNotAllowed, + + /// Parent check `job-limits-mismatch`: the job is created with an + /// active-process limit of 2. + #[cfg(feature = "deviations")] + JobLimitsMismatch, + /// Parent check `not-in-owned-job`: the child is not created in the job. + #[cfg(feature = "deviations")] + NotInOwnedJob, + /// Parent check `birth-token-mismatch`: the privileges that survive + /// filtering are not removed, while the check still expects none. + #[cfg(feature = "deviations")] + BirthTokenMismatch, + /// Parent check `initial-token-open`: the start-up thread token is never + /// set on the suspended thread. + #[cfg(feature = "deviations")] + InitialTokenOpen, +} + +/// The three basic shapes a launch can take. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Shape { + /// The full confinement, possibly with one property broken. + Confined, + /// The LPAC-only positive control. + #[cfg(feature = "deviations")] + LpacOnly, + /// The plain positive control. + #[cfg(feature = "deviations")] + Plain, +} + +impl Deviation { + /// Every variant, for tests that cover them all. + #[cfg(feature = "deviations")] + pub const ALL: [Self; 17] = [ + Self::Full, + Self::LpacOnly, + Self::Plain, + Self::ThreadTokenPresent, + Self::IntegrityLowerFailed, + Self::NotLpac, + Self::CapabilitiesPresent, + Self::RestrictingSidMismatch, + Self::GroupNotDenyOnly, + Self::PrivilegesPresent, + Self::IntegrityNotUntrusted, + Self::MitigationMismatch, + Self::HandleNotAllowed, + Self::JobLimitsMismatch, + Self::NotInOwnedJob, + Self::BirthTokenMismatch, + Self::InitialTokenOpen, + ]; + + /// The variant's stable name. For a check's variant it is the check's + /// reason token. + pub const fn name(self) -> &'static str { + match self { + Self::Full => "full", + #[cfg(feature = "deviations")] + Self::LpacOnly => "lpac-only", + #[cfg(feature = "deviations")] + Self::Plain => "plain", + #[cfg(feature = "deviations")] + Self::ThreadTokenPresent => "thread-token-present", + #[cfg(feature = "deviations")] + Self::IntegrityLowerFailed => "integrity-lower-failed", + #[cfg(feature = "deviations")] + Self::NotLpac => "not-lpac", + #[cfg(feature = "deviations")] + Self::CapabilitiesPresent => "capabilities-present", + #[cfg(feature = "deviations")] + Self::RestrictingSidMismatch => "restricting-sid-mismatch", + #[cfg(feature = "deviations")] + Self::GroupNotDenyOnly => "group-not-deny-only", + #[cfg(feature = "deviations")] + Self::PrivilegesPresent => "privileges-present", + #[cfg(feature = "deviations")] + Self::IntegrityNotUntrusted => "integrity-not-untrusted", + #[cfg(feature = "deviations")] + Self::MitigationMismatch => "mitigation-mismatch", + #[cfg(feature = "deviations")] + Self::HandleNotAllowed => "handle-not-allowed", + #[cfg(feature = "deviations")] + Self::JobLimitsMismatch => "job-limits-mismatch", + #[cfg(feature = "deviations")] + Self::NotInOwnedJob => "not-in-owned-job", + #[cfg(feature = "deviations")] + Self::BirthTokenMismatch => "birth-token-mismatch", + #[cfg(feature = "deviations")] + Self::InitialTokenOpen => "initial-token-open", + } + } + + /// The reason the worker must exit with, for a worker check's variant. + pub const fn worker_reason(self) -> Option<&'static str> { + #[cfg(feature = "deviations")] + if matches!( + self, + Self::ThreadTokenPresent + | Self::IntegrityLowerFailed + | Self::NotLpac + | Self::CapabilitiesPresent + | Self::RestrictingSidMismatch + | Self::GroupNotDenyOnly + | Self::PrivilegesPresent + | Self::IntegrityNotUntrusted + | Self::MitigationMismatch + | Self::HandleNotAllowed + ) { + return Some(self.name()); + } + None + } + + /// The refusal the parent must return, for a parent check's variant. + pub const fn parent_refusal(self) -> Option { + #[cfg(feature = "deviations")] + match self { + Self::JobLimitsMismatch => return Some(Refusal::JobLimitsMismatch), + Self::NotInOwnedJob => return Some(Refusal::NotInOwnedJob), + Self::BirthTokenMismatch => return Some(Refusal::BirthTokenMismatch), + Self::InitialTokenOpen => return Some(Refusal::InitialTokenOpen), + _ => {} + } + None + } + + /// The argument added to the child's command line for a worker check the + /// parent cannot set up itself. Only a worker built with its own test + /// support acts on it. + pub fn child_argument(self) -> Option { + #[cfg(feature = "deviations")] + if matches!( + self, + Self::ThreadTokenPresent | Self::IntegrityLowerFailed | Self::IntegrityNotUntrusted + ) { + return Some(format!("--confinement-deviation={}", self.name())); + } + None + } + + pub(crate) const fn shape(self) -> Shape { + #[cfg(feature = "deviations")] + match self { + Self::LpacOnly => return Shape::LpacOnly, + Self::Plain => return Shape::Plain, + _ => {} + } + Shape::Confined + } + + /// Whether the `ALL_APPLICATION_PACKAGES` opt-out, which makes the + /// AppContainer a Less Privileged one, is applied. + pub(crate) const fn less_privileged(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::NotLpac) { + return false; + } + true + } + + /// Whether one capability is granted to the AppContainer. Production + /// builds grant none, so they do not have this question at all. + #[cfg(feature = "deviations")] + pub(crate) const fn grants_capability(self) -> bool { + matches!(self, Self::CapabilitiesPresent) + } + + /// Whether the logon SID is left enabled in the primary token. + pub(crate) const fn keeps_logon_group(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::GroupNotDenyOnly) { + return true; + } + false + } + + /// Whether Everyone joins the NULL SID among the restricting SIDs. + pub(crate) const fn widens_restricting_sids(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::RestrictingSidMismatch) { + return true; + } + false + } + + /// Whether the privileges that survive filtering stay in the primary. + pub(crate) const fn keeps_privileges(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::PrivilegesPresent | Self::BirthTokenMismatch) { + return true; + } + false + } + + /// Whether the parent's birth check expects privileges in the primary. + /// It differs from `keeps_privileges` only for the variant that tests the + /// birth check itself. + pub(crate) const fn expects_privileges(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::PrivilegesPresent) { + return true; + } + false + } + + /// The mitigation policy passed at creation. + pub(crate) const fn mitigation_policy(self) -> u64 { + #[cfg(feature = "deviations")] + if matches!(self, Self::MitigationMismatch) { + return MITIGATION_POLICY & !super::launch::MITIGATION_PROHIBIT_DYNAMIC_CODE; + } + MITIGATION_POLICY + } + + /// Whether the explicit inherited-handle list is passed. + pub(crate) const fn restricts_inherited_handles(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::HandleNotAllowed) { + return false; + } + true + } + + /// The active-process limit the job is created with. + pub(crate) const fn job_active_process_limit(self) -> u32 { + #[cfg(feature = "deviations")] + if matches!(self, Self::JobLimitsMismatch) { + return 2; + } + 1 + } + + /// Whether the child is created inside the owned job. + pub(crate) const fn joins_job(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::NotInOwnedJob) { + return false; + } + true + } + + /// Whether the start-up thread token is set on the suspended thread. + pub(crate) const fn sets_initial_token(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::InitialTokenOpen) { + return false; + } + true + } +} + +impl std::fmt::Display for Deviation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_is_the_default_and_names_no_check() { + assert_eq!(Deviation::default(), Deviation::Full); + assert_eq!(Deviation::Full.name(), "full"); + assert_eq!(Deviation::Full.worker_reason(), None); + assert_eq!(Deviation::Full.parent_refusal(), None); + assert_eq!(Deviation::Full.child_argument(), None); + assert_eq!(Deviation::Full.shape(), Shape::Confined); + assert_eq!(Deviation::Full.mitigation_policy(), MITIGATION_POLICY); + assert!(Deviation::Full.less_privileged()); + #[cfg(feature = "deviations")] + assert!(!Deviation::Full.grants_capability()); + assert!(!Deviation::Full.keeps_logon_group()); + assert!(!Deviation::Full.widens_restricting_sids()); + assert!(!Deviation::Full.keeps_privileges()); + assert!(!Deviation::Full.expects_privileges()); + assert!(Deviation::Full.restricts_inherited_handles()); + assert_eq!(Deviation::Full.job_active_process_limit(), 1); + assert!(Deviation::Full.joins_job()); + assert!(Deviation::Full.sets_initial_token()); + } + + /// Without the `deviations` feature the launcher can build only the full + /// recipe. The match has no wildcard arm, so this test stops compiling + /// if any other variant exists in such a build. + #[cfg(not(feature = "deviations"))] + #[test] + fn a_build_without_the_feature_has_only_the_full_recipe() { + match Deviation::default() { + Deviation::Full => {} + } + } + + /// Every check's variant names exactly one check, and changes exactly + /// one property of the full recipe (or, for the three worker checks the + /// parent cannot set up, only adds the request to the worker). + #[cfg(feature = "deviations")] + #[test] + fn every_check_variant_breaks_exactly_one_property() { + let full = knobs(Deviation::Full); + for deviation in Deviation::ALL { + let checks = usize::from(deviation.worker_reason().is_some()) + + usize::from(deviation.parent_refusal().is_some()); + let control = matches!( + deviation, + Deviation::Full | Deviation::LpacOnly | Deviation::Plain + ); + assert_eq!(checks, usize::from(!control), "{deviation}"); + if let Some(reason) = deviation.worker_reason() { + assert_eq!(reason, deviation.name()); + } + if let Some(refusal) = deviation.parent_refusal() { + assert_eq!(refusal.reason(), deviation.name()); + } + if control { + continue; + } + let changed = knobs(deviation) + .iter() + .zip(full.iter()) + .filter(|(a, b)| a != b) + .count(); + let expected = match deviation { + // `BirthTokenMismatch` builds the same token as + // `PrivilegesPresent` but leaves the parent expecting no + // privileges, so only `keeps_privileges` differs from the full + // recipe. `PrivilegesPresent` changes both `keeps_privileges` + // and `expects_privileges`. + Deviation::PrivilegesPresent => 2, + _ => 1, + }; + assert_eq!(changed, expected, "{deviation}"); + } + } + + #[cfg(feature = "deviations")] + fn knobs(deviation: Deviation) -> Vec { + vec![ + u64::from(deviation.less_privileged()), + u64::from(deviation.grants_capability()), + u64::from(deviation.keeps_logon_group()), + u64::from(deviation.widens_restricting_sids()), + u64::from(deviation.keeps_privileges()), + u64::from(deviation.expects_privileges()), + deviation.mitigation_policy(), + u64::from(deviation.restricts_inherited_handles()), + u64::from(deviation.job_active_process_limit()), + u64::from(deviation.joins_job()), + u64::from(deviation.sets_initial_token()), + u64::from(deviation.child_argument().is_some()), + ] + } +} diff --git a/crates/basal-launch/src/windows/error.rs b/crates/basal-launch/src/windows/error.rs new file mode 100644 index 0000000..e1285db --- /dev/null +++ b/crates/basal-launch/src/windows/error.rs @@ -0,0 +1,99 @@ +//! Launch failures, and the named refusals the parent's checks produce. + +use std::fmt; + +/// A named reason the launcher refused to start a worker. +/// +/// Every refusal leaves nothing running: a suspended child that fails a check +/// is killed before its first instruction executes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Refusal { + /// The shared AppContainer profile could not be created or opened, so + /// there is no package identity to confine the worker with. + AppContainerProfileUnavailable, + /// The owned job's flags or limits, read back before resume, are not the + /// ones the confinement requires. + JobLimitsMismatch, + /// The suspended child is not a member of the job the parent created. + NotInOwnedJob, + /// The suspended child's primary token differs from the token the parent + /// built. + BirthTokenMismatch, + /// The start-up thread token could not be read back from the suspended + /// thread as required, or the parent's handle to it did not close before + /// resume. + InitialTokenOpen, +} + +impl Refusal { + /// The stable reason token, as written in logs and matched by tests. + pub const fn reason(self) -> &'static str { + match self { + Self::AppContainerProfileUnavailable => "appcontainer-profile-unavailable", + Self::JobLimitsMismatch => "job-limits-mismatch", + Self::NotInOwnedJob => "not-in-owned-job", + Self::BirthTokenMismatch => "birth-token-mismatch", + Self::InitialTokenOpen => "initial-token-open", + } + } +} + +impl fmt::Display for Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.reason()) + } +} + +/// Why a launch did not produce a running worker. +#[derive(Debug)] +pub enum LaunchError { + /// A named check refused the launch. + Refused { + /// Which check refused. + refusal: Refusal, + /// What was observed, for diagnosis. + detail: String, + }, + /// A system call needed to build the launch failed. Nothing was started, + /// or what was started has been killed. + Failed(String), +} + +impl LaunchError { + pub(crate) fn refused(refusal: Refusal, detail: impl Into) -> Self { + Self::Refused { + refusal, + detail: detail.into(), + } + } + + /// The named refusal, when a check refused the launch. + pub fn refusal(&self) -> Option { + match self { + Self::Refused { refusal, .. } => Some(*refusal), + Self::Failed(_) => None, + } + } + + /// The reason token of the refusal, when a check refused the launch. + pub fn reason(&self) -> Option<&'static str> { + self.refusal().map(Refusal::reason) + } +} + +impl fmt::Display for LaunchError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Refused { refusal, detail } => write!(f, "{refusal}: {detail}"), + Self::Failed(detail) => write!(f, "worker launch failed: {detail}"), + } + } +} + +impl std::error::Error for LaunchError {} + +impl From for LaunchError { + fn from(detail: String) -> Self { + Self::Failed(detail) + } +} diff --git a/crates/basal-launch/src/windows/job.rs b/crates/basal-launch/src/windows/job.rs new file mode 100644 index 0000000..e61fd1a --- /dev/null +++ b/crates/basal-launch/src/windows/job.rs @@ -0,0 +1,172 @@ +//! The job a confined worker is born in, and its read-back. + +use super::native::{Result, check, owned}; +use std::mem::{size_of, zeroed}; +use std::os::windows::io::{AsRawHandle, OwnedHandle}; +use std::ptr::{null, null_mut}; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::System::JobObjects::{ + CreateJobObjectW, IsProcessInJob, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, + JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOB_OBJECT_LIMIT_PROCESS_MEMORY, JOBOBJECT_BASIC_UI_RESTRICTIONS, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectBasicUIRestrictions, + JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, +}; + +/// The job's limit flags, `0x2508`: kill every member when the last job +/// handle closes, end a member on an unhandled exception instead of showing +/// an error dialog, bound each member's committed memory, and bound the +/// number of live members. No breakaway flag is set, so a member can never +/// leave the job. +pub const JOB_LIMIT_FLAGS: u32 = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + | JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION + | JOB_OBJECT_LIMIT_PROCESS_MEMORY + | JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + +/// The UI restrictions, `0xff`: no USER handles from outside the job, no +/// clipboard reads or writes, no system-parameter or display-settings +/// changes, no global atoms, no desktop switching and no exit-Windows. +/// Newer SDKs also define an input-method restriction, `0x100`, and fold it +/// into `JOB_OBJECT_UILIMIT_ALL`. The confinement was validated with `0xff`, +/// so that is the value set and required on read-back. +pub const JOB_UI_RESTRICTIONS: u32 = 0xff; + +/// The limits of a job, as read back from the system. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JobLimits { + /// `LimitFlags` of the extended limit information. + pub flags: u32, + /// The most processes the job may hold at once. + pub active_process_limit: u32, + /// The commit limit of each process, in bytes. + pub process_memory_limit: usize, + /// The commit limit of the whole job, in bytes; unset is 0. + pub job_memory_limit: usize, + /// The UI restriction flags (see [`JOB_UI_RESTRICTIONS`]). + pub ui_restrictions: u32, +} + +impl JobLimits { + /// The limits a confined worker's job must have, for the given commit + /// limit: one live process, no breakaway, no whole-job memory limit, and + /// every UI restriction. + pub fn confined(commit_bytes: usize) -> Self { + Self { + flags: JOB_LIMIT_FLAGS, + active_process_limit: 1, + process_memory_limit: commit_bytes, + job_memory_limit: 0, + ui_restrictions: JOB_UI_RESTRICTIONS, + } + } +} + +/// Creates the confined worker's job. Its handle is not inheritable, so it +/// never reaches the worker. +pub(crate) fn create_confined( + commit_bytes: usize, + active_process_limit: u32, +) -> Result { + let job = owned( + unsafe { CreateJobObjectW(null(), null()) }, + "CreateJobObjectW", + )?; + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() }; + limits.BasicLimitInformation.LimitFlags = JOB_LIMIT_FLAGS; + limits.BasicLimitInformation.ActiveProcessLimit = active_process_limit; + limits.ProcessMemoryLimit = commit_bytes; + set_limits(job.as_raw_handle(), &limits)?; + let ui = JOBOBJECT_BASIC_UI_RESTRICTIONS { + UIRestrictionsClass: JOB_UI_RESTRICTIONS, + }; + check( + unsafe { + SetInformationJobObject( + job.as_raw_handle(), + JobObjectBasicUIRestrictions, + (&ui as *const JOBOBJECT_BASIC_UI_RESTRICTIONS).cast(), + size_of::() as u32, + ) + }, + "SetInformationJobObject(UI restrictions)", + )?; + Ok(job) +} + +/// Creates a job whose only limit is killing its members when the last +/// handle closes. The positive controls and the token source process are +/// put in one, so a parent that dies never leaves them behind. +pub(crate) fn create_kill_on_close() -> Result { + let job = owned( + unsafe { CreateJobObjectW(null(), null()) }, + "CreateJobObjectW", + )?; + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() }; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + set_limits(job.as_raw_handle(), &limits)?; + Ok(job) +} + +fn set_limits(job: HANDLE, limits: &JOBOBJECT_EXTENDED_LIMIT_INFORMATION) -> Result<()> { + check( + unsafe { + SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + size_of::() as u32, + ) + }, + "SetInformationJobObject(limits)", + ) +} + +/// Reads a job's limits back through the given job handle. +/// +/// The handle matters: a query with a NULL handle describes whichever job +/// the caller itself is in, such as a CI runner's, not the worker's. +pub(crate) fn read_limits(job: HANDLE) -> Result { + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() }; + let mut ui: JOBOBJECT_BASIC_UI_RESTRICTIONS = unsafe { zeroed() }; + check( + unsafe { + QueryInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (&mut limits as *mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + size_of::() as u32, + null_mut(), + ) + }, + "QueryInformationJobObject(limits)", + )?; + check( + unsafe { + QueryInformationJobObject( + job, + JobObjectBasicUIRestrictions, + (&mut ui as *mut JOBOBJECT_BASIC_UI_RESTRICTIONS).cast(), + size_of::() as u32, + null_mut(), + ) + }, + "QueryInformationJobObject(UI restrictions)", + )?; + Ok(JobLimits { + flags: limits.BasicLimitInformation.LimitFlags, + active_process_limit: limits.BasicLimitInformation.ActiveProcessLimit, + process_memory_limit: limits.ProcessMemoryLimit, + job_memory_limit: limits.JobMemoryLimit, + ui_restrictions: ui.UIRestrictionsClass, + }) +} + +/// Whether `process` is a member of `job`. +pub(crate) fn contains(job: HANDLE, process: HANDLE) -> Result { + let mut member = 0; + check( + unsafe { IsProcessInJob(process, job, &mut member) }, + "IsProcessInJob", + )?; + Ok(member != 0) +} diff --git a/crates/basal-launch/src/windows/launch.rs b/crates/basal-launch/src/windows/launch.rs new file mode 100644 index 0000000..5f19e3c --- /dev/null +++ b/crates/basal-launch/src/windows/launch.rs @@ -0,0 +1,779 @@ +//! Building the launch: creation attributes, the suspended child, the +//! parent's checks before resume, and resume. + +use super::context; +use super::deviation::{Deviation, Shape}; +use super::error::{LaunchError, Refusal}; +use super::job::{self, JobLimits}; +use super::native::{Result, SidBuf, check, last, owned, wide}; +use super::process::{ConfinedProcess, KILL_EXIT_CODE}; +use super::profile::{PackageSid, create_or_open_profile}; +use super::spawn_lock::{make_inheritable, spawn_lock}; +use super::token::{self, BirthExpectation}; +use std::ffi::{OsStr, OsString, c_void}; +use std::fs::File; +use std::mem::{size_of, zeroed}; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::{AsRawHandle, FromRawHandle, IntoRawHandle, OwnedHandle}; +use std::path::{Path, PathBuf}; +use std::ptr::{null, null_mut}; +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; +use windows_sys::Win32::Security::{ + SECURITY_CAPABILITIES, SID_AND_ATTRIBUTES, TOKEN_ALL_ACCESS, TOKEN_QUERY, +}; +use windows_sys::Win32::System::Pipes::CreatePipe; +use windows_sys::Win32::System::SystemServices::SE_GROUP_ENABLED; +use windows_sys::Win32::System::Threading::{ + CREATE_NO_WINDOW, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, CreateProcessAsUserW, + CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, INFINITE, + InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, OpenProcessToken, + OpenThreadToken, PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + PROC_THREAD_ATTRIBUTE_JOB_LIST, PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY, + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, ResumeThread, + STARTF_USESTDHANDLES, STARTUPINFOEXW, SetThreadToken, TerminateProcess, + UpdateProcThreadAttribute, WaitForSingleObject, +}; +use windows_sys::Win32::System::WindowsProgramming::{ + PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, PROCESS_CREATION_CHILD_PROCESS_RESTRICTED, +}; + +// Process-creation mitigation policy bits, the `..._ALWAYS_ON` values of +// `PROCESS_CREATION_MITIGATION_POLICY_*` in winnt.h. + +/// Using an invalid handle raises an exception instead of returning an +/// error, and the setting cannot be turned off. +pub const MITIGATION_STRICT_HANDLE_CHECKS: u64 = 1 << 24; +/// No system calls into the Win32k (GUI) kernel component. +pub const MITIGATION_WIN32K_SYSTEM_CALL_DISABLE: u64 = 1 << 28; +/// No legacy extension points (AppInit DLLs, IMEs, window hooks and the +/// like) load into the process. +pub const MITIGATION_EXTENSION_POINT_DISABLE: u64 = 1 << 32; +/// No executable memory can be created or made executable after load. +pub const MITIGATION_PROHIBIT_DYNAMIC_CODE: u64 = 1 << 36; +/// Only Microsoft-signed DLLs load. +pub const MITIGATION_MICROSOFT_SIGNED_ONLY: u64 = 1 << 44; +/// No images load from remote (network) locations. +pub const MITIGATION_NO_REMOTE_IMAGES: u64 = 1 << 52; +/// No images with a Low mandatory label load. +pub const MITIGATION_NO_LOW_LABEL_IMAGES: u64 = 1 << 56; +/// DLLs are looked up in System32 before the application directory. +pub const MITIGATION_PREFER_SYSTEM32_IMAGES: u64 = 1 << 60; + +/// The mitigation policy every confined worker is created with. +pub const MITIGATION_POLICY: u64 = MITIGATION_STRICT_HANDLE_CHECKS + | MITIGATION_WIN32K_SYSTEM_CALL_DISABLE + | MITIGATION_EXTENSION_POINT_DISABLE + | MITIGATION_PROHIBIT_DYNAMIC_CODE + | MITIGATION_MICROSOFT_SIGNED_ONLY + | MITIGATION_NO_REMOTE_IMAGES + | MITIGATION_NO_LOW_LABEL_IMAGES + | MITIGATION_PREFER_SYSTEM32_IMAGES; + +/// The capability granted only by the `capabilities-present` test variant: +/// `internetClient`. +#[cfg(feature = "deviations")] +const TEST_CAPABILITY: &str = "S-1-15-3-1"; + +/// What to start, and how. +#[derive(Debug, Clone)] +pub struct LaunchOptions { + /// The absolute path of the image. + pub program: PathBuf, + /// Arguments after the image name. The launcher appends + /// `--package-sid=` after them on every launch. + pub args: Vec, + /// The commit limit of the worker's job, in bytes. Callers pass the + /// limit of the worker's profile from `basal_proto::limits`. + pub job_commit_bytes: u64, + /// The recipe. Production builds have only the full confinement. + pub deviation: Deviation, +} + +impl LaunchOptions { + /// The full confinement for `program`, with no arguments. + pub fn new(program: impl Into, job_commit_bytes: u64) -> Self { + Self { + program: program.into(), + args: Vec::new(), + job_commit_bytes, + deviation: Deviation::Full, + } + } + + /// Adds one argument. + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + /// Selects the recipe. + pub fn deviation(mut self, deviation: Deviation) -> Self { + self.deviation = deviation; + self + } +} + +/// Starts a worker. On return it is running with stdin, stdout and stderr +/// connected to the parent through three pipes, and nothing else inherited. +/// +/// Under the full confinement the child is created suspended and is only +/// resumed after the parent has read back, through its own handles, the +/// job's limits and membership, the child's primary token and its start-up +/// thread token. Any difference kills the child before its first +/// instruction and returns the named refusal. +pub fn launch(options: &LaunchOptions) -> std::result::Result { + if !options.program.is_absolute() { + return Err(LaunchError::Failed(format!( + "the worker image path must be absolute: {}", + options.program.display() + ))); + } + let commit_bytes = usize::try_from(options.job_commit_bytes) + .map_err(|_| format!("commit limit {} does not fit", options.job_commit_bytes))?; + let package = create_or_open_profile()?; + let parent_token = token::own_token()?; + let context = context::create(&options.program, parent_token.as_raw_handle(), &package)?; + + // The child's ends are made inheritable only under the spawn lock, which + // is released below once they are closed. It is taken before the pipes + // exist so that on an early return the ends, dropped in reverse order of + // declaration, are closed before the lock is released. + let spawn_lock = spawn_lock(); + let (child_stdin, parent_stdin) = pipe()?; + let (parent_stdout, child_stdout) = pipe()?; + let (parent_stderr, child_stderr) = pipe()?; + // Only the child's three ends are inheritable, and only they are named + // in the inherited-handle list. + make_inheritable( + &spawn_lock, + &[ + child_stdin.as_raw_handle(), + child_stdout.as_raw_handle(), + child_stderr.as_raw_handle(), + ], + ) + .map_err(|error| format!("SetHandleInformation(child pipe end): {error}"))?; + let inherited: [HANDLE; 3] = [ + child_stdin.as_raw_handle(), + child_stdout.as_raw_handle(), + child_stderr.as_raw_handle(), + ]; + let mut command = command_line(&options.program, &options.args, &package, options.deviation); + let deviation = options.deviation; + + let spawned = match deviation.shape() { + Shape::Confined => spawn_confined( + options, + commit_bytes, + &package, + parent_token.as_raw_handle(), + &context, + &inherited, + &mut command, + )?, + #[cfg(feature = "deviations")] + Shape::LpacOnly | Shape::Plain => spawn_control( + options, + &package, + &context, + &inherited, + &mut command, + deviation.shape() == Shape::LpacOnly, + )?, + }; + + // The child has inherited its own copies of its three pipe ends; the + // parent's copies would keep the pipes open after the child exits. + drop((child_stdin, child_stdout, child_stderr)); + drop(spawn_lock); + Ok(ConfinedProcess::new( + spawned.process, + spawned.pid, + spawned.job, + package, + inherited.map(|handle| handle as usize), + File::from(parent_stdin), + File::from(parent_stdout), + File::from(parent_stderr), + context, + )) +} + +struct Spawned { + process: OwnedHandle, + pid: u32, + job: OwnedHandle, +} + +/// The full confinement, or one of the check variants built from it. +fn spawn_confined( + options: &LaunchOptions, + commit_bytes: usize, + package: &PackageSid, + parent_token: HANDLE, + context: &context::Context, + inherited: &[HANDLE; 3], + command: &mut [u16], +) -> std::result::Result { + let deviation = options.deviation; + let source = TokenSource::start( + &options.program, + package, + deviation.less_privileged(), + context, + )?; + let (primary, mut expected) = token::build_primary(parent_token, package.as_str(), deviation)?; + let initial = token::build_initial(source.token.as_raw_handle())?; + let job = job::create_confined(commit_bytes, deviation.job_active_process_limit())?; + + // Every value an attribute points at must live until the process exists. + let capability_sids = capability_sids(deviation)?; + let capabilities: Vec = capability_sids + .iter() + .map(|sid| SID_AND_ATTRIBUTES { + Sid: sid.as_psid(), + Attributes: SE_GROUP_ENABLED as u32, + }) + .collect(); + for sid in &capability_sids { + expected.capabilities.push(sid.to_text()?); + } + let security = security_capabilities(package, &capabilities); + let opt_out = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; + let jobs: [HANDLE; 1] = [job.as_raw_handle()]; + let mitigation = deviation.mitigation_policy(); + let child_policy = PROCESS_CREATION_CHILD_PROCESS_RESTRICTED; + + let mut attributes = AttributeList::new(6)?; + if deviation.restricts_inherited_handles() { + attributes.add(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited)?; + } + attributes.add(PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, &security)?; + if deviation.less_privileged() { + attributes.add( + PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + &opt_out, + )?; + } + if deviation.joins_job() { + attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &jobs)?; + } + attributes.add(PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY, &mitigation)?; + attributes.add(PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, &child_policy)?; + + let startup = startup_info(context, inherited, &mut attributes); + let mut info: PROCESS_INFORMATION = unsafe { zeroed() }; + check( + unsafe { + CreateProcessAsUserW( + primary.as_raw_handle(), + wide(&options.program).as_ptr(), + command.as_mut_ptr(), + null(), + null(), + 1, + CREATION_FLAGS, + context.environment.as_ptr().cast::(), + context.cwd.as_ptr(), + &startup.StartupInfo, + &mut info, + ) + }, + "CreateProcessAsUserW", + )?; + let suspended = Suspended::adopt(&info)?; + drop(attributes); + + if let Err(refusal) = check_before_resume( + &suspended, + &job, + commit_bytes, + &expected, + initial, + deviation, + ) { + suspended.kill(); + return Err(refusal); + } + suspended.resume()?; + drop(source); + Ok(Spawned { + process: suspended.process, + pid: info.dwProcessId, + job, + }) +} + +/// The parent's checks on the suspended child. On success the start-up +/// thread token is set on the main thread and the parent's handle to it is +/// closed. +fn check_before_resume( + child: &Suspended, + job: &OwnedHandle, + commit_bytes: usize, + expected: &BirthExpectation, + initial: OwnedHandle, + deviation: Deviation, +) -> std::result::Result<(), LaunchError> { + let limits = job::read_limits(job.as_raw_handle()) + .map_err(|error| LaunchError::refused(Refusal::JobLimitsMismatch, error))?; + let required = JobLimits::confined(commit_bytes); + if limits != required { + return Err(LaunchError::refused( + Refusal::JobLimitsMismatch, + format!("read back {limits:?}, required {required:?}"), + )); + } + let member = job::contains(job.as_raw_handle(), child.process.as_raw_handle()) + .map_err(|error| LaunchError::refused(Refusal::NotInOwnedJob, error))?; + if !member { + return Err(LaunchError::refused( + Refusal::NotInOwnedJob, + "the suspended child is not in the job the parent created", + )); + } + + let birth = process_token(child.process.as_raw_handle()) + .and_then(|token| token::read_facts(token.as_raw_handle())) + .map_err(|error| LaunchError::refused(Refusal::BirthTokenMismatch, error))?; + token::check_birth(&birth, expected) + .map_err(|error| LaunchError::refused(Refusal::BirthTokenMismatch, error))?; + + if deviation.sets_initial_token() { + let thread = child.thread.as_raw_handle(); + check( + unsafe { SetThreadToken(&thread, initial.as_raw_handle()) }, + "SetThreadToken(suspended main thread)", + ) + .map_err(|error| LaunchError::refused(Refusal::InitialTokenOpen, error))?; + } + let assigned = thread_token(child.thread.as_raw_handle()) + .and_then(|token| token::read_facts(token.as_raw_handle())) + .map_err(|error| LaunchError::refused(Refusal::InitialTokenOpen, error))?; + token::check_initial(&assigned, &expected.package) + .map_err(|error| LaunchError::refused(Refusal::InitialTokenOpen, error))?; + // The handle was never inheritable and is not in the handle list; close + // it now so the parent holds no reference to a token the child uses. + if unsafe { CloseHandle(initial.into_raw_handle()) } == 0 { + return Err(LaunchError::refused( + Refusal::InitialTokenOpen, + last("CloseHandle(initial token)"), + )); + } + Ok(()) +} + +/// The two positive controls: no restricted primary, no start-up token, no +/// mitigations, created with `CreateProcessW` in a job that only kills on +/// close. +#[cfg(feature = "deviations")] +fn spawn_control( + options: &LaunchOptions, + package: &PackageSid, + context: &context::Context, + inherited: &[HANDLE; 3], + command: &mut [u16], + appcontainer: bool, +) -> std::result::Result { + let job = job::create_kill_on_close()?; + let no_capabilities: [SID_AND_ATTRIBUTES; 0] = []; + let security = security_capabilities(package, &no_capabilities); + let opt_out = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; + let jobs: [HANDLE; 1] = [job.as_raw_handle()]; + let mut attributes = AttributeList::new(4)?; + attributes.add(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited)?; + if appcontainer { + attributes.add(PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, &security)?; + attributes.add( + PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + &opt_out, + )?; + } + attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &jobs)?; + let startup = startup_info(context, inherited, &mut attributes); + let mut info: PROCESS_INFORMATION = unsafe { zeroed() }; + check( + unsafe { + CreateProcessW( + wide(&options.program).as_ptr(), + command.as_mut_ptr(), + null(), + null(), + 1, + CREATION_FLAGS, + context.environment.as_ptr().cast::(), + context.cwd.as_ptr(), + &startup.StartupInfo, + &mut info, + ) + }, + "CreateProcessW(control)", + )?; + let suspended = Suspended::adopt(&info)?; + suspended.resume()?; + Ok(Spawned { + process: suspended.process, + pid: info.dwProcessId, + job, + }) +} + +/// Suspended, so the parent can check the child before it runs; extended +/// startup information, for the attribute list; no console window; and a +/// UTF-16 environment block. +const CREATION_FLAGS: u32 = + EXTENDED_STARTUPINFO_PRESENT | CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT; + +fn capability_sids(deviation: Deviation) -> Result> { + #[cfg(feature = "deviations")] + if deviation.grants_capability() { + return Ok(vec![SidBuf::parse(TEST_CAPABILITY)?]); + } + let _ = deviation; + Ok(Vec::new()) +} + +/// The AppContainer creation attribute. The capability list pointer is +/// never NULL, even when the list is empty. +fn security_capabilities( + package: &PackageSid, + capabilities: &[SID_AND_ATTRIBUTES], +) -> SECURITY_CAPABILITIES { + SECURITY_CAPABILITIES { + AppContainerSid: package.as_psid(), + Capabilities: capabilities.as_ptr().cast_mut(), + CapabilityCount: capabilities.len() as u32, + Reserved: 0, + } +} + +fn startup_info( + context: &context::Context, + inherited: &[HANDLE; 3], + attributes: &mut AttributeList, +) -> STARTUPINFOEXW { + let mut startup: STARTUPINFOEXW = unsafe { zeroed() }; + startup.StartupInfo.cb = size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = inherited[0]; + startup.StartupInfo.hStdOutput = inherited[1]; + startup.StartupInfo.hStdError = inherited[2]; + // The system only reads the desktop name. + startup.StartupInfo.lpDesktop = context.desktop_name.as_ptr().cast_mut(); + startup.lpAttributeList = attributes.as_ptr(); + startup +} + +/// The command line: the quoted image path, the caller's arguments, the +/// package SID and, for a worker check the parent cannot set up itself, the +/// request to the worker. +fn command_line( + program: &Path, + args: &[OsString], + package: &PackageSid, + deviation: Deviation, +) -> Vec { + let mut command: Vec = Vec::new(); + command.push(u16::from(b'"')); + command.extend(program.as_os_str().encode_wide()); + command.push(u16::from(b'"')); + let mut all: Vec = args.to_vec(); + all.push(format!("--package-sid={}", package.as_str()).into()); + if let Some(argument) = deviation.child_argument() { + all.push(argument.into()); + } + for arg in &all { + command.push(u16::from(b' ')); + push_argument(&mut command, arg); + } + command.push(0); + command +} + +/// Appends one argument quoted so the C runtime's parser reads it back +/// unchanged: backslashes are literal except before a double quote, where +/// they and the quote are escaped. +pub(crate) fn push_argument(command: &mut Vec, arg: &OsStr) { + const QUOTE: u16 = b'"' as u16; + const BACKSLASH: u16 = b'\\' as u16; + let units: Vec = arg.encode_wide().collect(); + let needs_quotes = units.is_empty() + || units + .iter() + .any(|&unit| unit == u16::from(b' ') || unit == u16::from(b'\t') || unit == QUOTE); + if !needs_quotes { + command.extend(units); + return; + } + command.push(QUOTE); + let mut backslashes = 0; + for unit in units { + if unit == BACKSLASH { + backslashes += 1; + } else { + if unit == QUOTE { + command.extend(std::iter::repeat_n(BACKSLASH, backslashes + 1)); + } + backslashes = 0; + } + command.push(unit); + } + command.extend(std::iter::repeat_n(BACKSLASH, backslashes)); + command.push(QUOTE); +} + +/// An anonymous pipe as (read end, write end). Neither end is inheritable. +pub(crate) fn pipe() -> Result<(OwnedHandle, OwnedHandle)> { + let mut read = null_mut(); + let mut write = null_mut(); + check( + unsafe { CreatePipe(&mut read, &mut write, null(), 0) }, + "CreatePipe", + )?; + Ok((owned(read, "CreatePipe")?, owned(write, "CreatePipe")?)) +} + +fn process_token(process: HANDLE) -> Result { + let mut token = null_mut(); + check( + unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) }, + "OpenProcessToken(child)", + )?; + owned(token, "OpenProcessToken(child)") +} + +/// The thread's impersonation token, opened with the parent's own identity +/// rather than the thread's. +fn thread_token(thread: HANDLE) -> Result { + let mut token = null_mut(); + check( + unsafe { OpenThreadToken(thread, TOKEN_QUERY, 1, &mut token) }, + "OpenThreadToken(suspended main thread)", + )?; + owned(token, "OpenThreadToken(suspended main thread)") +} + +/// A created, not yet resumed child. Dropping it without resuming kills it. +struct Suspended { + process: OwnedHandle, + thread: OwnedHandle, +} + +impl Suspended { + fn adopt(info: &PROCESS_INFORMATION) -> Result { + Ok(Self { + process: unsafe { OwnedHandle::from_raw_handle(info.hProcess) }, + thread: unsafe { OwnedHandle::from_raw_handle(info.hThread) }, + }) + } + + fn kill(&self) { + unsafe { + TerminateProcess(self.process.as_raw_handle(), KILL_EXIT_CODE); + WaitForSingleObject(self.process.as_raw_handle(), INFINITE); + } + } + + fn resume(&self) -> Result<()> { + if unsafe { ResumeThread(self.thread.as_raw_handle()) } == u32::MAX { + let error = last("ResumeThread"); + self.kill(); + return Err(error); + } + Ok(()) + } +} + +/// A process born into the worker's AppContainer and never resumed, whose +/// token is the source of the start-up thread token. Dropping it kills it. +struct TokenSource { + process: OwnedHandle, + token: OwnedHandle, + _job: OwnedHandle, +} + +impl TokenSource { + fn start( + program: &Path, + package: &PackageSid, + less_privileged: bool, + context: &context::Context, + ) -> Result { + // The source runs no code, but it is still put in a kill-on-close + // job so a parent that dies cannot leave it suspended forever. + let job = job::create_kill_on_close()?; + let no_capabilities: [SID_AND_ATTRIBUTES; 0] = []; + let security = security_capabilities(package, &no_capabilities); + let opt_out = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; + let jobs: [HANDLE; 1] = [job.as_raw_handle()]; + let mut attributes = AttributeList::new(3)?; + attributes.add(PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, &security)?; + if less_privileged { + attributes.add( + PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + &opt_out, + )?; + } + attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &jobs)?; + let mut startup: STARTUPINFOEXW = unsafe { zeroed() }; + startup.StartupInfo.cb = size_of::() as u32; + startup.lpAttributeList = attributes.as_ptr(); + let mut command = wide(format!("\"{}\"", program.display())); + let mut info: PROCESS_INFORMATION = unsafe { zeroed() }; + check( + unsafe { + CreateProcessW( + wide(program).as_ptr(), + command.as_mut_ptr(), + null(), + null(), + 0, + CREATION_FLAGS, + context.environment.as_ptr().cast::(), + context.cwd.as_ptr(), + &startup.StartupInfo, + &mut info, + ) + }, + "CreateProcessW(token source)", + )?; + let suspended = Suspended::adopt(&info)?; + let mut token = null_mut(); + let opened = check( + unsafe { + OpenProcessToken( + suspended.process.as_raw_handle(), + TOKEN_ALL_ACCESS, + &mut token, + ) + }, + "OpenProcessToken(token source)", + ); + if let Err(error) = opened { + suspended.kill(); + return Err(error); + } + let token = match owned(token, "OpenProcessToken(token source)") { + Ok(token) => token, + Err(error) => { + suspended.kill(); + return Err(error); + } + }; + Ok(Self { + process: suspended.process, + token, + _job: job, + }) + } +} + +impl Drop for TokenSource { + fn drop(&mut self) { + unsafe { + TerminateProcess(self.process.as_raw_handle(), KILL_EXIT_CODE); + WaitForSingleObject(self.process.as_raw_handle(), INFINITE); + } + } +} + +/// A process/thread attribute list. The values added must outlive every +/// use of the list, since the list stores pointers to them. +pub(crate) struct AttributeList { + buffer: Vec, +} + +impl AttributeList { + pub(crate) fn new(capacity: u32) -> Result { + let mut bytes = 0; + unsafe { InitializeProcThreadAttributeList(null_mut(), capacity, 0, &mut bytes) }; + if bytes == 0 { + return Err(last("InitializeProcThreadAttributeList(size)")); + } + let mut list = Self { + buffer: vec![0; bytes.div_ceil(size_of::())], + }; + check( + unsafe { InitializeProcThreadAttributeList(list.as_ptr(), capacity, 0, &mut bytes) }, + "InitializeProcThreadAttributeList", + )?; + Ok(list) + } + + pub(crate) fn as_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + self.buffer.as_mut_ptr().cast() + } + + pub(crate) fn add(&mut self, attribute: u32, value: &T) -> Result<()> { + check( + unsafe { + UpdateProcThreadAttribute( + self.as_ptr(), + 0, + attribute as usize, + (value as *const T).cast(), + size_of_val(value), + null_mut(), + null(), + ) + }, + &format!("UpdateProcThreadAttribute({attribute:#x})"), + ) + } +} + +impl Drop for AttributeList { + fn drop(&mut self) { + unsafe { DeleteProcThreadAttributeList(self.as_ptr()) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn quoted(arg: &str) -> String { + let mut command = Vec::new(); + push_argument(&mut command, OsStr::new(arg)); + String::from_utf16(&command).unwrap() + } + + #[test] + fn arguments_are_quoted_for_the_c_runtime_parser() { + assert_eq!( + quoted("--package-sid=S-1-15-2-1"), + "--package-sid=S-1-15-2-1" + ); + assert_eq!(quoted(""), "\"\""); + assert_eq!(quoted("a b"), "\"a b\""); + assert_eq!(quoted("a\"b"), "\"a\\\"b\""); + assert_eq!(quoted("C:\\dir name\\"), "\"C:\\dir name\\\\\""); + assert_eq!(quoted("C:\\dir\\x"), "C:\\dir\\x"); + } + + #[test] + fn the_environment_holds_only_the_named_variables_sorted() { + let block = context::environment_block("C:\\Windows", "C:\\T\\w"); + let text = String::from_utf16(&block).unwrap(); + let variables: Vec<&str> = text.trim_end_matches('\0').split('\0').collect(); + assert_eq!( + variables, + [ + "LOCALAPPDATA=C:\\T\\w", + "PATH=C:\\Windows\\System32", + "SYSTEMDRIVE=C:", + "SYSTEMROOT=C:\\Windows", + "TEMP=C:\\T\\w", + "TMP=C:\\T\\w", + "windir=C:\\Windows", + ] + ); + assert!(block.ends_with(&[0, 0])); + } + + #[test] + fn the_mitigation_policy_is_the_eight_always_on_bits() { + assert_eq!(MITIGATION_POLICY, 0x1110_1011_1100_0000); + } +} diff --git a/crates/basal-launch/src/windows/mod.rs b/crates/basal-launch/src/windows/mod.rs new file mode 100644 index 0000000..bd2e223 --- /dev/null +++ b/crates/basal-launch/src/windows/mod.rs @@ -0,0 +1,79 @@ +//! The Windows launcher. + +mod context; +mod deviation; +mod error; +mod job; +mod launch; +mod native; +mod plain; +mod process; +mod profile; +mod spawn_lock; +mod token; + +pub use deviation::Deviation; +pub use error::{LaunchError, Refusal}; +pub use job::{JOB_LIMIT_FLAGS, JOB_UI_RESTRICTIONS, JobLimits}; +pub use launch::{ + LaunchOptions, MITIGATION_EXTENSION_POINT_DISABLE, MITIGATION_MICROSOFT_SIGNED_ONLY, + MITIGATION_NO_LOW_LABEL_IMAGES, MITIGATION_NO_REMOTE_IMAGES, MITIGATION_POLICY, + MITIGATION_PREFER_SYSTEM32_IMAGES, MITIGATION_PROHIBIT_DYNAMIC_CODE, + MITIGATION_STRICT_HANDLE_CHECKS, MITIGATION_WIN32K_SYSTEM_CALL_DISABLE, launch, +}; +pub use plain::{PlainCommand, PlainStdio, spawn_plain}; +pub use process::{ConfinedProcess, KILL_EXIT_CODE, OwnedProcess}; +pub use profile::{PROFILE_NAME, PackageSid, create_or_open_profile, grant_test_binary_directory}; +pub use spawn_lock::{SpawnLock, make_inheritable, spawn_lock, spawn_lock_held}; +pub use token::{ + IMPERSONATION_LEVEL, LOW_INTEGRITY, NULL_SID, TOKEN_IMPERSONATION, TOKEN_PRIMARY, TokenFacts, +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refusal_reasons_are_the_named_tokens() { + let all = [ + ( + Refusal::AppContainerProfileUnavailable, + "appcontainer-profile-unavailable", + ), + (Refusal::JobLimitsMismatch, "job-limits-mismatch"), + (Refusal::NotInOwnedJob, "not-in-owned-job"), + (Refusal::BirthTokenMismatch, "birth-token-mismatch"), + (Refusal::InitialTokenOpen, "initial-token-open"), + ]; + for (refusal, reason) in all { + assert_eq!(refusal.reason(), reason); + let error = LaunchError::refused(refusal, "detail"); + assert_eq!(error.reason(), Some(reason)); + assert_eq!(error.to_string(), format!("{reason}: detail")); + } + assert_eq!(LaunchError::Failed("x".into()).reason(), None); + } + + #[test] + fn the_job_flags_are_0x2508() { + assert_eq!(JOB_LIMIT_FLAGS, 0x2508); + let limits = JobLimits::confined(512 << 20); + assert_eq!(limits.active_process_limit, 1); + assert_eq!(limits.job_memory_limit, 0); + assert_eq!(limits.ui_restrictions, 0xff); + } + + /// A profile name the system rejects (longer than 64 characters) is + /// refused as `appcontainer-profile-unavailable`, never launched without. + #[test] + fn an_unusable_profile_is_refused_by_name() { + let name = "x".repeat(65); + let error = profile::create_or_open_named(&name) + .map_err(profile::unavailable) + .expect_err("a 65-character profile name must be rejected"); + assert_eq!( + error.refusal(), + Some(Refusal::AppContainerProfileUnavailable) + ); + } +} diff --git a/crates/basal-launch/src/windows/native.rs b/crates/basal-launch/src/windows/native.rs new file mode 100644 index 0000000..612cb84 --- /dev/null +++ b/crates/basal-launch/src/windows/native.rs @@ -0,0 +1,182 @@ +//! Small helpers over the Win32 and native calls the launcher makes. + +use std::ffi::{OsStr, c_void}; +use std::mem::size_of; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::{FromRawHandle, OwnedHandle}; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::{GetLastError, HANDLE, INVALID_HANDLE_VALUE, LocalFree}; +use windows_sys::Win32::Security::Authorization::{ConvertSidToStringSidW, ConvertStringSidToSidW}; +use windows_sys::Win32::Security::{ + CopySid, CreateWellKnownSid, GetLengthSid, GetTokenInformation, PSID, SID_AND_ATTRIBUTES, + TOKEN_GROUPS, TOKEN_INFORMATION_CLASS, TOKEN_PRIVILEGES, WELL_KNOWN_SID_TYPE, +}; + +pub(crate) type Result = std::result::Result; + +/// `SE_GROUP_INTEGRITY`: marks the token's integrity label, which is not an +/// access group. +pub(crate) const SE_GROUP_INTEGRITY: u32 = 0x20; +/// `SE_GROUP_USE_FOR_DENY_ONLY`: the group matches only deny entries. +pub(crate) const SE_GROUP_USE_FOR_DENY_ONLY: u32 = 0x10; +/// `SE_GROUP_LOGON_ID`: the group is the session's logon SID. +pub(crate) const SE_GROUP_LOGON_ID: u32 = 0xc000_0000; + +/// A NUL-terminated UTF-16 copy of `text`. +pub(crate) fn wide(text: impl AsRef) -> Vec { + text.as_ref().encode_wide().chain(Some(0)).collect() +} + +/// The calling thread's last Win32 error, named after the call that failed. +pub(crate) fn last(api: &str) -> String { + let code = unsafe { GetLastError() }; + format!( + "{api}: Win32 {code} ({})", + std::io::Error::from_raw_os_error(code as i32) + ) +} + +/// Turns a Win32 `BOOL` result into a `Result`, naming the call. +pub(crate) fn check(ok: i32, api: &str) -> Result<()> { + if ok == 0 { Err(last(api)) } else { Ok(()) } +} + +/// Takes ownership of a handle a call returned, refusing the two failure +/// values. +pub(crate) fn owned(handle: HANDLE, api: &str) -> Result { + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + Err(last(api)) + } else { + Ok(unsafe { OwnedHandle::from_raw_handle(handle) }) + } +} + +/// Reads one variable-length token information class into an aligned buffer. +/// +/// # Safety +/// +/// `token` must be a valid token handle opened with `TOKEN_QUERY`. +pub(crate) unsafe fn token_buffer( + token: HANDLE, + class: TOKEN_INFORMATION_CLASS, +) -> Result> { + unsafe { + let mut bytes = 0; + GetTokenInformation(token, class, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(last(&format!("GetTokenInformation(class {class}, size)"))); + } + // A list with zero entries can be shorter than the C declaration, + // which reserves one entry. Keep room for that declaration so a + // reference to it never reaches past the allocation. + let allocation = (bytes as usize) + .max(size_of::()) + .max(size_of::()); + let mut data = vec![0usize; allocation.div_ceil(size_of::())]; + check( + GetTokenInformation(token, class, data.as_mut_ptr().cast(), bytes, &mut bytes), + &format!("GetTokenInformation(class {class})"), + )?; + Ok(data) + } +} + +/// The entries of a `TOKEN_GROUPS` buffer. +/// +/// # Safety +/// +/// `data` must hold a `TOKEN_GROUPS` structure returned by the system. +pub(crate) unsafe fn groups(data: &[usize]) -> &[SID_AND_ATTRIBUTES] { + unsafe { + let list = &*data.as_ptr().cast::(); + std::slice::from_raw_parts(list.Groups.as_ptr(), list.GroupCount as usize) + } +} + +/// The `S-1-...` form of a SID. +/// +/// # Safety +/// +/// `sid` must point to a valid SID. +pub(crate) unsafe fn sid_string(sid: PSID) -> Result { + unsafe { + let mut text = null_mut(); + check( + ConvertSidToStringSidW(sid, &mut text), + "ConvertSidToStringSidW", + )?; + let result = utf16_until_nul(text); + LocalFree(text.cast()); + Ok(result) + } +} + +/// Reads a NUL-terminated UTF-16 string. +/// +/// # Safety +/// +/// `text` must point to a NUL-terminated UTF-16 string. +pub(crate) unsafe fn utf16_until_nul(text: *const u16) -> String { + unsafe { + let mut len = 0; + while *text.add(len) != 0 { + len += 1; + } + String::from_utf16_lossy(std::slice::from_raw_parts(text, len)) + } +} + +/// A SID held in memory this process owns. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct SidBuf(Vec); + +impl SidBuf { + /// The SID of a well-known kind, such as the NULL SID or an integrity label. + pub(crate) fn well_known(kind: WELL_KNOWN_SID_TYPE) -> Result { + // SECURITY_MAX_SID_SIZE is 68 bytes. + let mut sid = vec![0u32; 17]; + let mut bytes = (sid.len() * 4) as u32; + check( + unsafe { CreateWellKnownSid(kind, null_mut(), sid.as_mut_ptr().cast(), &mut bytes) }, + "CreateWellKnownSid", + )?; + Ok(Self(sid)) + } + + /// Parses the `S-1-...` form. + #[cfg_attr(not(feature = "deviations"), allow(dead_code))] + pub(crate) fn parse(text: &str) -> Result { + let mut sid = null_mut(); + check( + unsafe { ConvertStringSidToSidW(wide(text).as_ptr(), &mut sid) }, + "ConvertStringSidToSidW", + )?; + let copy = unsafe { Self::copy(sid) }; + unsafe { LocalFree(sid) }; + copy + } + + /// Copies a SID the system returned. + /// + /// # Safety + /// + /// `sid` must point to a valid SID. + pub(crate) unsafe fn copy(sid: PSID) -> Result { + unsafe { + let bytes = GetLengthSid(sid); + let mut buffer = vec![0u32; (bytes as usize).div_ceil(4)]; + check(CopySid(bytes, buffer.as_mut_ptr().cast(), sid), "CopySid")?; + Ok(Self(buffer)) + } + } + + /// A pointer for calls that read the SID. Callers that write through it + /// must not exist; the pointer is mutable only because the API types are. + pub(crate) fn as_psid(&self) -> PSID { + self.0.as_ptr().cast_mut().cast::() + } + + pub(crate) fn to_text(&self) -> Result { + unsafe { sid_string(self.as_psid()) } + } +} diff --git a/crates/basal-launch/src/windows/plain.rs b/crates/basal-launch/src/windows/plain.rs new file mode 100644 index 0000000..c2edeb7 --- /dev/null +++ b/crates/basal-launch/src/windows/plain.rs @@ -0,0 +1,355 @@ +//! Unconfined children: every Windows process basal starts that is not a +//! confined worker (test fixtures, helper tools) goes through here, so that +//! it is created under the same spawn lock, inherits only its own standard +//! handles, and is born in a job that ends it with its owner. + +use super::error::{LaunchError, Refusal}; +use super::job; +use super::launch::{AttributeList, pipe, push_argument}; +use super::native::{Result, check, owned, wide}; +use super::process::{KILL_EXIT_CODE, OwnedProcess}; +use super::spawn_lock::{make_inheritable, spawn_lock}; +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString, c_void}; +use std::fs::File; +use std::mem::{size_of, zeroed}; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::path::{Path, PathBuf}; +use std::ptr::{null, null_mut}; +use windows_sys::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE, HANDLE}; +use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, +}; +use windows_sys::Win32::System::Threading::{ + CREATE_NO_WINDOW, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, + EXTENDED_STARTUPINFO_PRESENT, INFINITE, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + PROC_THREAD_ATTRIBUTE_JOB_LIST, PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, + STARTUPINFOEXW, TerminateProcess, WaitForSingleObject, +}; + +/// The longest command line `CreateProcessW` accepts, in UTF-16 units, +/// including the terminating NUL. +const MAX_COMMAND_LINE: usize = 32_767; + +/// Where one of the child's standard handles goes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlainStdio { + /// A new anonymous pipe; the parent's end is on the [`OwnedProcess`]. + Piped, + /// The `NUL` device: reads see end of file, writes are discarded. + Null, +} + +/// An unconfined child to start with [`spawn_plain`]. +#[derive(Debug, Clone)] +pub struct PlainCommand { + /// The absolute path of the image; a relative path is refused. + pub program: PathBuf, + /// Arguments after the image name, encoded so the C runtime's parser + /// reads each back unchanged. + pub args: Vec, + /// The child's whole environment. Nothing of the parent's is added. + pub env: Vec<(OsString, OsString)>, + /// The working directory; the parent's when unset. + pub cwd: Option, + /// The child's stdin. + pub stdin: PlainStdio, + /// The child's stdout. + pub stdout: PlainStdio, + /// The child's stderr. + pub stderr: PlainStdio, +} + +impl PlainCommand { + /// `program` with no arguments, an empty environment, the parent's + /// working directory and every standard handle on `NUL`. + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + args: Vec::new(), + env: Vec::new(), + cwd: None, + stdin: PlainStdio::Null, + stdout: PlainStdio::Null, + stderr: PlainStdio::Null, + } + } +} + +/// Starts an unconfined child: no restricted token and no mitigations, but +/// the same handling of handles and lifetime as a confined worker. +/// +/// - Its standard handles are made inheritable only under the spawn lock, +/// and they are the only entries of its inherited-handle list, so it +/// inherits nothing else the parent holds. +/// - It is created suspended in a fresh job whose only limit is +/// kill-on-close (so no breakaway), its membership of exactly that job is +/// verified, and only then is it resumed. A child that is not in the job +/// is killed before its first instruction. +/// - The returned [`OwnedProcess`] owns the process and the job: `kill` +/// ends the whole job, and dropping it kills the child. +pub fn spawn_plain(command: &PlainCommand) -> std::result::Result { + if !command.program.is_absolute() { + return Err(LaunchError::Failed(format!( + "the image path must be absolute: {}", + command.program.display() + ))); + } + let application = wide_checked(&command.program, "image path")?; + let mut command_line = command_line(&command.program, &command.args)?; + let environment = environment_block(&command.env)?; + let cwd = command + .cwd + .as_ref() + .map(|cwd| wide_checked(cwd, "working directory")) + .transpose()?; + let job = job::create_kill_on_close()?; + + // Declared before the handles so that on an early return they close + // before the lock is released. + let spawn_lock = spawn_lock(); + let stdin = Stdio::open(command.stdin, Direction::ToChild)?; + let stdout = Stdio::open(command.stdout, Direction::FromChild)?; + let stderr = Stdio::open(command.stderr, Direction::FromChild)?; + let inherited: [HANDLE; 3] = [ + stdin.child.as_raw_handle(), + stdout.child.as_raw_handle(), + stderr.child.as_raw_handle(), + ]; + make_inheritable(&spawn_lock, &inherited) + .map_err(|error| format!("SetHandleInformation(child stdio): {error}"))?; + let jobs: [HANDLE; 1] = [job.as_raw_handle()]; + let mut attributes = AttributeList::new(2)?; + attributes.add(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &inherited)?; + attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &jobs)?; + let mut startup: STARTUPINFOEXW = unsafe { zeroed() }; + startup.StartupInfo.cb = size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = inherited[0]; + startup.StartupInfo.hStdOutput = inherited[1]; + startup.StartupInfo.hStdError = inherited[2]; + startup.lpAttributeList = attributes.as_ptr(); + let mut info: PROCESS_INFORMATION = unsafe { zeroed() }; + check( + unsafe { + CreateProcessW( + application.as_ptr(), + command_line.as_mut_ptr(), + null(), + null(), + 1, + CREATION_FLAGS, + environment.as_ptr().cast::(), + cwd.as_ref().map_or(null(), |cwd| cwd.as_ptr()), + &startup.StartupInfo, + &mut info, + ) + }, + "CreateProcessW", + )?; + let created = Created { + process: unsafe { OwnedHandle::from_raw_handle(info.hProcess) }, + thread: unsafe { OwnedHandle::from_raw_handle(info.hThread) }, + }; + #[cfg(test)] + tests::observe(&created, &inherited); + // The child holds its own copies now. Closing the parent's before the + // lock is released ends the window in which another spawn could copy + // them, and lets the pipes report end of file when the child exits. + drop(attributes); + let [stdin, stdout, stderr] = [stdin, stdout, stderr].map(Stdio::close_child); + drop(spawn_lock); + + match job::contains(job.as_raw_handle(), created.process.as_raw_handle()) { + Ok(true) => {} + Ok(false) => { + created.kill(); + return Err(LaunchError::refused( + Refusal::NotInOwnedJob, + "the suspended child is not in the job the parent created", + )); + } + Err(error) => { + created.kill(); + return Err(LaunchError::refused(Refusal::NotInOwnedJob, error)); + } + } + // A previous suspend count other than 1 means the thread was not + // suspended exactly once by the creation, and may already have run or + // may stay suspended. + let previous = unsafe { ResumeThread(created.thread.as_raw_handle()) }; + if previous != 1 { + let error = super::native::last("ResumeThread"); + created.kill(); + return Err(LaunchError::Failed(format!( + "{error}; previous suspend count {previous}" + ))); + } + Ok(OwnedProcess::new( + created.process, + info.dwProcessId, + job, + stdin, + stdout, + stderr, + )) +} + +/// Suspended, with extended startup information for the attribute list, no +/// console window, and a UTF-16 environment block. +const CREATION_FLAGS: u32 = + EXTENDED_STARTUPINFO_PRESENT | CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT; + +/// A created child before it is handed out. +struct Created { + process: OwnedHandle, + thread: OwnedHandle, +} + +impl Created { + fn kill(&self) { + unsafe { + TerminateProcess(self.process.as_raw_handle(), KILL_EXIT_CODE); + WaitForSingleObject(self.process.as_raw_handle(), INFINITE); + } + } +} + +#[derive(Clone, Copy)] +enum Direction { + ToChild, + FromChild, +} + +/// One standard handle: the child's end, and the parent's end of a pipe. +struct Stdio { + child: OwnedHandle, + parent: Option, +} + +impl Stdio { + /// Closes the parent's copy of the child's end, keeping the parent's end. + fn close_child(self) -> Option { + drop(self.child); + self.parent + } + + /// Neither end is inheritable when opened. + fn open(kind: PlainStdio, direction: Direction) -> Result { + match kind { + PlainStdio::Piped => { + let (read, write) = pipe()?; + let (child, parent) = match direction { + Direction::ToChild => (read, write), + Direction::FromChild => (write, read), + }; + Ok(Self { + child, + parent: Some(File::from(parent)), + }) + } + PlainStdio::Null => { + let device = wide("NUL"); + let handle = unsafe { + CreateFileW( + device.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + null_mut(), + ) + }; + Ok(Self { + child: owned(handle, "CreateFileW(NUL)")?, + parent: None, + }) + } + } + } +} + +/// A NUL-terminated UTF-16 copy of a value that must not contain NUL. +fn wide_checked(value: impl AsRef, what: &str) -> Result> { + let value = value.as_ref(); + if value.encode_wide().any(|unit| unit == 0) { + return Err(format!("the {what} contains NUL")); + } + Ok(wide(value)) +} + +/// The quoted image path, then each argument encoded for the C runtime's +/// parser. The image path is quoted as is: the parser takes the first +/// argument up to the closing quote, and a path cannot contain a quote. +fn command_line(program: &Path, args: &[OsString]) -> Result> { + let mut line: Vec = Vec::new(); + line.push(u16::from(b'"')); + line.extend(program.as_os_str().encode_wide()); + line.push(u16::from(b'"')); + for arg in args { + if arg.encode_wide().any(|unit| unit == 0) { + return Err("an argument contains NUL".to_owned()); + } + line.push(u16::from(b' ')); + push_argument(&mut line, arg); + } + line.push(0); + if line.len() > MAX_COMMAND_LINE { + return Err(format!( + "the command line is {} UTF-16 units, over the limit of {MAX_COMMAND_LINE}", + line.len() + )); + } + Ok(line) +} + +/// The environment block: `NAME=value` strings sorted by upper-cased name, +/// as Windows keeps them, each NUL-terminated, then one more NUL. Names +/// compare case-insensitively, and a later entry replaces an earlier one +/// with the same name. +fn environment_block(env: &[(OsString, OsString)]) -> Result> { + let mut sorted: BTreeMap, (Vec, Vec)> = BTreeMap::new(); + for (name, value) in env { + let name: Vec = name.encode_wide().collect(); + let value: Vec = value.encode_wide().collect(); + // Windows itself keeps a few names that start with `=` (the + // per-drive working directories), so only a later `=` is refused. + if name.is_empty() + || name.iter().skip(1).any(|&unit| unit == u16::from(b'=')) + || name.contains(&0) + || value.contains(&0) + { + return Err(format!( + "invalid environment entry {:?}", + String::from_utf16_lossy(&name) + )); + } + let key = name + .iter() + .map(|&unit| match u8::try_from(unit) { + Ok(byte) => u16::from(byte.to_ascii_uppercase()), + Err(_) => unit, + }) + .collect(); + sorted.insert(key, (name, value)); + } + let mut block = Vec::new(); + for (name, value) in sorted.into_values() { + block.extend(name); + block.push(u16::from(b'=')); + block.extend(value); + block.push(0); + } + // An empty block is two NULs: one for the absent first string, one to + // end the block. + if block.is_empty() { + block.push(0); + } + block.push(0); + Ok(block) +} + +#[cfg(test)] +mod tests; diff --git a/crates/basal-launch/src/windows/plain/tests.rs b/crates/basal-launch/src/windows/plain/tests.rs new file mode 100644 index 0000000..5373195 --- /dev/null +++ b/crates/basal-launch/src/windows/plain/tests.rs @@ -0,0 +1,317 @@ +use super::*; +use crate::windows::spawn_lock::spawn_lock_held; +use std::cell::RefCell; +use std::io::Read; +use std::sync::mpsc; +use windows_sys::Win32::Foundation::{ + CompareObjectHandles, DUPLICATE_SAME_ACCESS, DuplicateHandle, ERROR_INVALID_HANDLE, + WAIT_OBJECT_0, +}; +use windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, STARTUPINFOW, SuspendThread}; + +type Observer = Box; + +thread_local! { + /// Called by `spawn_plain` on this thread while the child is suspended + /// and the parent still holds its copies of the child's handles. + static OBSERVER: RefCell> = const { RefCell::new(None) }; +} + +pub(super) fn observe(created: &Created, inherited: &[HANDLE; 3]) { + let observer = OBSERVER.with(|slot| slot.borrow_mut().take()); + if let Some(mut observer) = observer { + observer(created, inherited); + } +} + +fn cmd() -> PathBuf { + let root = std::env::var_os("SystemRoot").expect("SystemRoot is set"); + Path::new(&root).join("System32").join("cmd.exe") +} + +fn cmd_command(args: &[&str]) -> PlainCommand { + let mut command = PlainCommand::new(cmd()); + command.args = args.iter().map(OsString::from).collect(); + command.env = vec![( + "SystemRoot".into(), + std::env::var_os("SystemRoot").expect("SystemRoot is set"), + )]; + command +} + +/// A `cmd.exe` that waits for a line on its piped stdin, so it stays alive +/// until it is killed. +fn waiting_cmd() -> PlainCommand { + let mut command = cmd_command(&["/d", "/q", "/k"]); + command.stdin = PlainStdio::Piped; + command +} + +/// Whether `process` holds the object the parent's `original` handle names. +/// An inherited handle keeps its value in the child, so the value is +/// duplicated out of the child and compared as an object, which also tells +/// apart an unrelated handle that happens to have the same value. +fn process_has_handle(process: HANDLE, original: HANDLE) -> bool { + let mut duplicate = null_mut(); + if unsafe { + DuplicateHandle( + process, + original, + GetCurrentProcess(), + &mut duplicate, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) + } == 0 + { + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(ERROR_INVALID_HANDLE as i32), + "could not inspect the child's handle table" + ); + return false; + } + let duplicate = unsafe { OwnedHandle::from_raw_handle(duplicate) }; + unsafe { CompareObjectHandles(original, duplicate.as_raw_handle()) != 0 } +} + +/// Starts `cmd.exe` suspended the broad way: every inheritable handle, no +/// list. The caller kills it. +fn unlisted_suspended_cmd() -> Created { + let application = wide(cmd()); + let mut line = wide(format!("\"{}\"", cmd().display())); + let mut startup: STARTUPINFOW = unsafe { zeroed() }; + startup.cb = size_of::() as u32; + let mut info: PROCESS_INFORMATION = unsafe { zeroed() }; + check( + unsafe { + CreateProcessW( + application.as_ptr(), + line.as_mut_ptr(), + null(), + null(), + 1, + CREATE_SUSPENDED | CREATE_NO_WINDOW, + null(), + null(), + &startup, + &mut info, + ) + }, + "CreateProcessW(unlisted control)", + ) + .unwrap(); + Created { + process: unsafe { OwnedHandle::from_raw_handle(info.hProcess) }, + thread: unsafe { OwnedHandle::from_raw_handle(info.hThread) }, + } +} + +#[derive(Debug, PartialEq, Eq)] +struct Observed { + lock_held: bool, + previous_suspend_count: u32, + holds_listed: [bool; 3], + holds_stray: bool, +} + +/// While the child exists but has not run, the spawn lock is held, the main +/// thread is suspended exactly once, and the child holds its three listed +/// standard handles but not an unrelated inheritable handle of the parent. +/// The unlisted control shows that same handle really is inheritable. +#[test] +fn the_child_is_created_suspended_under_the_lock_inheriting_only_its_stdio() { + let (_stray_read, stray_write) = pipe().unwrap(); + { + // The control is a broad spawn, so it runs under the lock like any + // other: it must copy only the handle it is testing. + let lock = spawn_lock(); + make_inheritable(&lock, &[stray_write.as_raw_handle()]).unwrap(); + let control = unlisted_suspended_cmd(); + let inherited = + process_has_handle(control.process.as_raw_handle(), stray_write.as_raw_handle()); + control.kill(); + assert!(inherited, "the unlisted control did not inherit the handle"); + } + // The stray handle stays inheritable, as one opened by code that does + // not take the lock would be. + let stray = stray_write.as_raw_handle() as usize; + let (sender, receiver) = mpsc::channel(); + OBSERVER.with(|slot| { + *slot.borrow_mut() = Some(Box::new(move |created: &Created, inherited| { + let thread = created.thread.as_raw_handle(); + let previous_suspend_count = unsafe { SuspendThread(thread) }; + unsafe { ResumeThread(thread) }; + let process = created.process.as_raw_handle(); + let _ = sender.send(Observed { + lock_held: spawn_lock_held(), + previous_suspend_count, + holds_listed: inherited.map(|handle| process_has_handle(process, handle)), + holds_stray: process_has_handle(process, stray as HANDLE), + }); + })); + }); + let child = spawn_plain(&waiting_cmd()).expect("spawn cmd.exe"); + let observed = receiver.try_recv().expect("the observer ran"); + assert_eq!( + observed, + Observed { + lock_held: true, + previous_suspend_count: 1, + holds_listed: [true; 3], + holds_stray: false, + } + ); + assert!(!spawn_lock_held(), "the lock is released on return"); + assert!( + !process_has_handle(child.as_raw_handle(), stray_write.as_raw_handle()), + "the running child holds the unrelated handle" + ); + child.kill().unwrap(); +} + +/// The child is in its own job, whose only limit is kill-on-close: no +/// breakaway flag, so the child cannot leave it. +#[test] +fn the_child_is_in_a_fresh_kill_on_close_job_without_breakaway() { + let first = spawn_plain(&waiting_cmd()).expect("spawn cmd.exe"); + let second = spawn_plain(&waiting_cmd()).expect("spawn cmd.exe"); + assert!(first.in_owned_job().unwrap()); + let limits = first.job_limits().unwrap(); + assert_eq!(limits.flags, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); + assert_eq!(limits.active_process_limit, 0); + // Each spawn has a job of its own: killing the first one's job leaves + // the second running. + first.kill().unwrap(); + assert_eq!(first.wait().unwrap(), KILL_EXIT_CODE); + assert_eq!(second.try_wait().unwrap(), None); + assert!(second.in_owned_job().unwrap()); +} + +#[test] +fn kill_wait_and_try_wait_report_the_kill_code() { + let child = spawn_plain(&waiting_cmd()).expect("spawn cmd.exe"); + assert_eq!(child.try_wait().unwrap(), None, "cmd.exe waits on stdin"); + child.kill().unwrap(); + assert_eq!(child.wait().unwrap(), KILL_EXIT_CODE); + assert_eq!(child.try_wait().unwrap(), Some(KILL_EXIT_CODE)); + child.kill().expect("killing an ended child succeeds"); +} + +#[test] +fn dropping_the_process_kills_the_child() { + let child = spawn_plain(&waiting_cmd()).expect("spawn cmd.exe"); + let mut process = null_mut(); + check( + unsafe { + DuplicateHandle( + GetCurrentProcess(), + child.as_raw_handle(), + GetCurrentProcess(), + &mut process, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) + }, + "DuplicateHandle(child)", + ) + .unwrap(); + let process = unsafe { OwnedHandle::from_raw_handle(process) }; + assert_eq!(child.try_wait().unwrap(), None); + drop(child); + assert_eq!( + unsafe { WaitForSingleObject(process.as_raw_handle(), 0) }, + WAIT_OBJECT_0, + "the child was still running after its owner was dropped" + ); +} + +#[test] +fn piped_output_and_the_exit_code_reach_the_parent() { + let mut command = cmd_command(&["/d", "/c", "echo", "hello&exit", "7"]); + command.stdout = PlainStdio::Piped; + let mut child = spawn_plain(&command).expect("spawn cmd.exe"); + let mut output = String::new(); + child + .stdout + .take() + .expect("stdout is piped") + .read_to_string(&mut output) + .unwrap(); + assert_eq!(output.trim_end(), "hello"); + assert_eq!(child.wait().unwrap(), 7); + assert!(child.stdin.is_none() && child.stderr.is_none()); +} + +/// The child sees the given variables and none of the parent's. +#[test] +fn the_environment_replaces_the_parents() { + assert!( + std::env::var_os("COMPUTERNAME").is_some(), + "the parent's own variable this test looks for is set" + ); + let mut command = cmd_command(&["/d", "/c", "set"]); + command.env.push(("BASAL_PLAIN_PROBE".into(), "x y".into())); + command.stdout = PlainStdio::Piped; + let mut child = spawn_plain(&command).expect("spawn cmd.exe"); + let mut output = String::new(); + child + .stdout + .take() + .unwrap() + .read_to_string(&mut output) + .unwrap(); + assert_eq!(child.wait().unwrap(), 0); + let lines: Vec<&str> = output.lines().collect(); + assert!(lines.contains(&"BASAL_PLAIN_PROBE=x y"), "{output}"); + assert!( + !lines.iter().any(|line| line.starts_with("COMPUTERNAME=")), + "{output}" + ); +} + +#[test] +fn a_relative_image_path_or_a_nul_is_refused_before_anything_starts() { + let error = spawn_plain(&PlainCommand::new("cmd.exe")).unwrap_err(); + assert!(error.to_string().contains("must be absolute"), "{error}"); + let mut command = cmd_command(&["/c", "exit"]); + command.args.push(OsString::from("a\0b")); + assert!(spawn_plain(&command).is_err()); + let mut command = cmd_command(&["/c", "exit"]); + command.env.push(("A=B".into(), "c".into())); + assert!(spawn_plain(&command).is_err()); +} + +#[test] +fn the_environment_block_is_sorted_and_later_names_replace_earlier_ones() { + let block = environment_block(&[ + ("b".into(), "2".into()), + ("A".into(), "1".into()), + ("a".into(), "3".into()), + ("=C:".into(), "C:\\".into()), + ]) + .unwrap(); + assert_eq!( + String::from_utf16(&block).unwrap(), + "=C:=C:\\\0a=3\0b=2\0\0" + ); + assert_eq!(environment_block(&[]).unwrap(), [0, 0]); +} + +#[test] +fn the_command_line_quotes_the_image_and_encodes_each_argument() { + let line = command_line( + Path::new("C:\\Program Files\\x.exe"), + &["a b".into(), "c".into(), "".into()], + ) + .unwrap(); + assert_eq!( + String::from_utf16(&line).unwrap(), + "\"C:\\Program Files\\x.exe\" \"a b\" c \"\"\0" + ); + let long = vec![OsString::from("x".repeat(MAX_COMMAND_LINE))]; + assert!(command_line(Path::new("C:\\x.exe"), &long).is_err()); +} diff --git a/crates/basal-launch/src/windows/process.rs b/crates/basal-launch/src/windows/process.rs new file mode 100644 index 0000000..c8519ef --- /dev/null +++ b/crates/basal-launch/src/windows/process.rs @@ -0,0 +1,238 @@ +//! The running child, owned together with its job. + +use super::context::Context; +use super::job::{self, JobLimits}; +use super::profile::PackageSid; +use super::token::{self, TokenFacts}; +use std::fs::File; +use std::io; +use std::ops::{Deref, DerefMut}; +use std::os::windows::io::{AsRawHandle, OwnedHandle, RawHandle}; +use std::path::Path; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows_sys::Win32::Security::TOKEN_QUERY; +use windows_sys::Win32::System::JobObjects::TerminateJobObject; +use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, INFINITE, OpenProcessToken, TerminateProcess, WaitForSingleObject, +}; + +/// The exit code a killed child reports, by analogy with a shell's 128 + 9 +/// for SIGKILL. It is distinct from the worker's own refusal exit (70) and +/// from the NTSTATUS codes a confinement fault ends a process with. +pub const KILL_EXIT_CODE: u32 = 137; + +/// A child process started by this crate, owned together with the job it +/// was born in and the parent's ends of its standard pipes. +/// +/// Dropping it kills the child and waits for it to end, then closes the job, +/// whose kill-on-close limit ends anything else still in it. +pub struct OwnedProcess { + process: OwnedHandle, + pid: u32, + job: OwnedHandle, + /// Writes to the child's stdin, when it was piped. + pub stdin: Option, + /// Reads the child's stdout, when it was piped. + pub stdout: Option, + /// Reads the child's stderr, when it was piped. + pub stderr: Option, +} + +impl OwnedProcess { + pub(crate) fn new( + process: OwnedHandle, + pid: u32, + job: OwnedHandle, + stdin: Option, + stdout: Option, + stderr: Option, + ) -> Self { + Self { + process, + pid, + job, + stdin, + stdout, + stderr, + } + } + + /// The child's process id. + pub fn id(&self) -> u32 { + self.pid + } + + /// Kills every process in the child's job, then the child itself, with + /// [`KILL_EXIT_CODE`]. Killing a child that has already ended succeeds. + pub fn kill(&self) -> io::Result<()> { + let job_killed = + unsafe { TerminateJobObject(self.job.as_raw_handle(), KILL_EXIT_CODE) } != 0; + if unsafe { TerminateProcess(self.process.as_raw_handle(), KILL_EXIT_CODE) } != 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + // Terminating a process that is already ending fails with access + // denied. That happens right after the job kill above, which ends the + // child (a member of the job) whether or not it has finished exiting + // yet, and for a child that had already exited. + if job_killed && self.in_owned_job().unwrap_or(false) { + return Ok(()); + } + match self.try_wait()? { + Some(_) => Ok(()), + None => Err(error), + } + } + + /// The exit code, if the child has ended. Never blocks. + pub fn try_wait(&self) -> io::Result> { + match unsafe { WaitForSingleObject(self.process.as_raw_handle(), 0) } { + WAIT_OBJECT_0 => self.exit_code().map(Some), + WAIT_TIMEOUT => Ok(None), + _ => Err(io::Error::last_os_error()), + } + } + + /// Blocks until the child ends, and returns its exit code. + pub fn wait(&self) -> io::Result { + match unsafe { WaitForSingleObject(self.process.as_raw_handle(), INFINITE) } { + WAIT_OBJECT_0 => self.exit_code(), + _ => Err(io::Error::last_os_error()), + } + } + + fn exit_code(&self) -> io::Result { + let mut code = 0; + if unsafe { GetExitCodeProcess(self.process.as_raw_handle(), &mut code) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(code) + } + + /// Reads the limits of the child's job through the parent's own handle. + pub fn job_limits(&self) -> Result { + job::read_limits(self.job.as_raw_handle()) + } + + /// Whether the child is in the job the parent created for it. + pub fn in_owned_job(&self) -> Result { + job::contains(self.job.as_raw_handle(), self.process.as_raw_handle()) + } +} + +impl AsRawHandle for OwnedProcess { + /// The process handle. + fn as_raw_handle(&self) -> RawHandle { + self.process.as_raw_handle() + } +} + +impl std::fmt::Debug for OwnedProcess { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OwnedProcess") + .field("pid", &self.pid) + .finish_non_exhaustive() + } +} + +impl Drop for OwnedProcess { + fn drop(&mut self) { + if self.kill().is_ok() { + let _ = self.wait(); + } + } +} + +/// A worker started by [`launch`](crate::launch): the [`OwnedProcess`] +/// (reached through `Deref`, so `kill`, `wait`, `try_wait` and the pipes are +/// the same as for any child) plus what only a confined launch has. +/// +/// Dropping it kills the worker and waits for it to end, then closes the job +/// and removes the worker's window station, desktop and TEMP directory. +pub struct ConfinedProcess { + // Fields drop in order: the process (killed and waited for) first, the + // start-up context last, after the worker has ended. + process: OwnedProcess, + package: PackageSid, + inherited_stdio: [usize; 3], + context: Context, +} + +impl ConfinedProcess { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + process: OwnedHandle, + pid: u32, + job: OwnedHandle, + package: PackageSid, + inherited_stdio: [usize; 3], + stdin: File, + stdout: File, + stderr: File, + context: Context, + ) -> Self { + Self { + process: OwnedProcess::new(process, pid, job, Some(stdin), Some(stdout), Some(stderr)), + package, + inherited_stdio, + context, + } + } + + /// The package SID the worker runs under. + pub fn package_sid(&self) -> &PackageSid { + &self.package + } + + /// The handle values the worker was given as stdin, stdout and stderr. + /// An inherited handle keeps its value in the child, so these are also + /// the values the worker sees. + pub fn inherited_stdio(&self) -> [usize; 3] { + self.inherited_stdio + } + + /// The worker's private TEMP directory. + pub fn temp_dir(&self) -> &Path { + &self.context.temp + } + + /// Reads the worker's current primary token. + pub fn primary_token(&self) -> Result { + let mut token = null_mut(); + if unsafe { OpenProcessToken(self.process.as_raw_handle(), TOKEN_QUERY, &mut token) } == 0 { + return Err(super::native::last("OpenProcessToken(worker)")); + } + let token = super::native::owned(token, "OpenProcessToken(worker)")?; + token::read_facts(token.as_raw_handle()) + } +} + +impl Deref for ConfinedProcess { + type Target = OwnedProcess; + fn deref(&self) -> &OwnedProcess { + &self.process + } +} + +impl DerefMut for ConfinedProcess { + fn deref_mut(&mut self) -> &mut OwnedProcess { + &mut self.process + } +} + +impl AsRawHandle for ConfinedProcess { + /// The process handle. + fn as_raw_handle(&self) -> RawHandle { + self.process.as_raw_handle() + } +} + +impl std::fmt::Debug for ConfinedProcess { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ConfinedProcess") + .field("pid", &self.process.pid) + .field("package", &self.package) + .finish_non_exhaustive() + } +} diff --git a/crates/basal-launch/src/windows/profile.rs b/crates/basal-launch/src/windows/profile.rs new file mode 100644 index 0000000..1f6261c --- /dev/null +++ b/crates/basal-launch/src/windows/profile.rs @@ -0,0 +1,403 @@ +//! The AppContainer profile every worker shares, and the test-only grant on +//! the directory of a worker binary. + +use super::error::{LaunchError, Refusal}; +use super::native::{Result, SidBuf, check, last, owned, wide}; +use std::ffi::c_void; +use std::os::windows::io::AsRawHandle; +use std::path::Path; +use std::ptr::{null, null_mut}; +use std::sync::Mutex; +use windows_sys::Win32::Foundation::{ + FreeLibrary, HMODULE, LocalFree, WAIT_ABANDONED, WAIT_OBJECT_0, +}; +use windows_sys::Win32::Security::Authorization::{ + EXPLICIT_ACCESS_W, GRANT_ACCESS, GetNamedSecurityInfoW, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, + SetEntriesInAclW, SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_UNKNOWN, TRUSTEE_W, +}; +use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, FreeSid, PSID, SID_AND_ATTRIBUTES, + SUB_CONTAINERS_AND_OBJECTS_INHERIT, +}; +use windows_sys::Win32::Storage::FileSystem::{FILE_GENERIC_EXECUTE, FILE_GENERIC_READ}; +use windows_sys::Win32::System::LibraryLoader::{ + GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW, +}; +use windows_sys::Win32::System::Threading::{ + CreateMutexW, INFINITE, ReleaseMutex, WaitForSingleObject, +}; + +/// The name of the one AppContainer profile all basal workers run under. +pub const PROFILE_NAME: &str = "cortexkit.basal.worker"; + +/// Serializes profile creation across every process of this user's session, +/// so concurrent first launches see one creation and later ones an existing +/// profile. +const PROFILE_MUTEX_NAME: &str = "Local\\cortexkit.basal.worker.profile"; + +/// `HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS)`. +const HRESULT_ALREADY_EXISTS: i32 = 0x8007_00b7_u32 as i32; + +type CreateProfile = unsafe extern "system" fn( + *const u16, + *const u16, + *const u16, + *const SID_AND_ATTRIBUTES, + u32, + *mut PSID, +) -> i32; +type DeriveSid = unsafe extern "system" fn(*const u16, *mut PSID) -> i32; + +/// The package SID of an AppContainer profile. +#[derive(Clone, PartialEq, Eq)] +pub struct PackageSid { + sid: SidBuf, + text: String, +} + +impl PackageSid { + /// The `S-1-15-2-...` form, as passed to the worker in `--package-sid`. + pub fn as_str(&self) -> &str { + &self.text + } + + pub(crate) fn as_psid(&self) -> PSID { + self.sid.as_psid() + } + + /// Takes a SID the profile API returned and frees the original. + /// + /// # Safety + /// + /// `sid` must be a valid SID allocated with `AllocateAndInitializeSid`, + /// as the profile API documents. + unsafe fn adopt(sid: PSID) -> Result { + let copy = unsafe { SidBuf::copy(sid) }; + unsafe { FreeSid(sid) }; + let sid = copy?; + let text = sid.to_text()?; + Ok(Self { sid, text }) + } +} + +impl std::fmt::Debug for PackageSid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "PackageSid({})", self.text) + } +} + +impl std::fmt::Display for PackageSid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.text) + } +} + +/// Creates the shared worker profile, or opens it if it exists, with no +/// capabilities. Either way the result is the profile's package SID. +/// +/// A failure is the named refusal `appcontainer-profile-unavailable`; no +/// worker is ever started without a profile. +pub fn create_or_open_profile() -> std::result::Result { + create_or_open_named(PROFILE_NAME).map_err(unavailable) +} + +pub(crate) fn unavailable(detail: String) -> LaunchError { + LaunchError::refused(Refusal::AppContainerProfileUnavailable, detail) +} + +pub(crate) fn create_or_open_named(name: &str) -> Result { + // Userenv pulls User32 and other GUI libraries in when it loads. It is + // loaded here, at run time, only in the parent, so that no image linking + // this crate imports it. + let userenv = Library::system32("userenv.dll")?; + let create: CreateProfile = unsafe { userenv.symbol(b"CreateAppContainerProfile\0")? }; + let derive: DeriveSid = + unsafe { userenv.symbol(b"DeriveAppContainerSidFromAppContainerName\0")? }; + let _guard = SessionMutex::acquire(PROFILE_MUTEX_NAME)?; + let name_w = wide(name); + let description = wide("CortexKit basal flow worker"); + let mut sid = null_mut(); + let hr = unsafe { + create( + name_w.as_ptr(), + name_w.as_ptr(), + description.as_ptr(), + null(), + 0, + &mut sid, + ) + }; + if hr >= 0 { + return unsafe { PackageSid::adopt(sid) }; + } + if hr != HRESULT_ALREADY_EXISTS { + return Err(format!( + "CreateAppContainerProfile({name}): HRESULT {:#010x}", + hr as u32 + )); + } + // The profile exists. Its package SID is a fixed function of its name. + let mut sid = null_mut(); + let hr = unsafe { derive(name_w.as_ptr(), &mut sid) }; + if hr < 0 { + return Err(format!( + "DeriveAppContainerSidFromAppContainerName({name}): HRESULT {:#010x}", + hr as u32 + )); + } + unsafe { PackageSid::adopt(sid) } +} + +/// Grants the package SID read and execute on the directory holding +/// `binary`, inherited by its contents, and changes no other entry of any ACL. +/// +/// An AppContainer process cannot load an image, or open its working +/// directory, from a directory that grants its package nothing. In production +/// that grant is part of installation. Test harnesses, which run binaries +/// from a build directory, call this instead; the production launch path +/// never does. +pub fn grant_test_binary_directory( + binary: &Path, + package: &PackageSid, +) -> std::result::Result<(), LaunchError> { + static GRANTS: Mutex<()> = Mutex::new(()); + let directory = binary + .parent() + .ok_or_else(|| format!("{} has no parent directory", binary.display()))?; + let path = wide(directory); + // Reading and rewriting a DACL is not atomic; serialize this process's + // grants so two concurrent ones cannot drop each other's entry. + let _guard = GRANTS.lock().unwrap_or_else(|poison| poison.into_inner()); + unsafe { + let mut old = null_mut(); + let mut descriptor = null_mut(); + let error = GetNamedSecurityInfoW( + path.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + &mut old, + null_mut(), + &mut descriptor, + ); + if error != 0 { + return Err(format!( + "GetNamedSecurityInfoW({}): Win32 {error}", + directory.display() + ) + .into()); + } + let entry = EXPLICIT_ACCESS_W { + grfAccessPermissions: FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, + grfAccessMode: GRANT_ACCESS, + grfInheritance: SUB_CONTAINERS_AND_OBJECTS_INHERIT, + Trustee: TRUSTEE_W { + pMultipleTrustee: null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: package.as_psid().cast(), + }, + }; + let mut new = null_mut(); + let error = SetEntriesInAclW(1, &entry, old, &mut new); + if error != 0 { + LocalFree(descriptor); + return Err(format!("SetEntriesInAclW: Win32 {error}").into()); + } + let error = SetNamedSecurityInfoW( + path.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + new, + null(), + ); + LocalFree(new.cast()); + LocalFree(descriptor); + if error != 0 { + return Err(format!( + "SetNamedSecurityInfoW({}): Win32 {error}", + directory.display() + ) + .into()); + } + } + Ok(()) +} + +/// A DLL loaded from System32 only, never from the search path. +pub(crate) struct Library(HMODULE); + +impl Library { + pub(crate) fn system32(name: &str) -> Result { + let module = unsafe { + LoadLibraryExW( + wide(name).as_ptr(), + null_mut(), + LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + }; + if module.is_null() { + Err(last(&format!("LoadLibraryExW({name})"))) + } else { + Ok(Self(module)) + } + } + + /// Looks up an export. + /// + /// # Safety + /// + /// `T` must be the function pointer type of the export, and `name` must + /// end with a NUL byte. + pub(crate) unsafe fn symbol(&self, name: &[u8]) -> Result { + debug_assert_eq!(size_of::(), size_of::<*const c_void>()); + let function = unsafe { GetProcAddress(self.0, name.as_ptr()) }.ok_or_else(|| { + last(&format!( + "GetProcAddress({})", + String::from_utf8_lossy(&name[..name.len().saturating_sub(1)]) + )) + })?; + Ok(unsafe { std::mem::transmute_copy(&function) }) + } +} + +impl Drop for Library { + fn drop(&mut self) { + unsafe { + FreeLibrary(self.0); + } + } +} + +// The module handle is process-wide and FreeLibrary may run on any thread. +unsafe impl Send for Library {} + +/// A named mutex held for the session, released on drop. +struct SessionMutex(std::os::windows::io::OwnedHandle); + +impl SessionMutex { + fn acquire(name: &str) -> Result { + let handle = owned( + unsafe { CreateMutexW(null(), 0, wide(name).as_ptr()) }, + "CreateMutexW(profile)", + )?; + // An abandoned mutex is still acquired: its previous holder died, and + // profile creation is safe to repeat. + match unsafe { WaitForSingleObject(handle.as_raw_handle(), INFINITE) } { + WAIT_OBJECT_0 | WAIT_ABANDONED => Ok(Self(handle)), + _ => Err(last("WaitForSingleObject(profile mutex)")), + } + } +} + +impl Drop for SessionMutex { + fn drop(&mut self) { + let _ = check( + unsafe { ReleaseMutex(self.0.as_raw_handle()) }, + "ReleaseMutex(profile)", + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Barrier}; + + /// A profile used only by the race test below, so deleting it cannot + /// disturb launches other tests make under the shared profile. + const RACE_PROFILE: &str = "cortexkit.basal.launch-test.race"; + + fn delete_profile(name: &str) { + type DeleteProfile = unsafe extern "system" fn(*const u16) -> i32; + let userenv = Library::system32("userenv.dll").unwrap(); + let delete: DeleteProfile = + unsafe { userenv.symbol(b"DeleteAppContainerProfile\0").unwrap() }; + // Deleting a profile that does not exist also succeeds. + let hr = unsafe { delete(wide(name).as_ptr()) }; + assert!( + hr >= 0, + "DeleteAppContainerProfile({name}): {:#010x}", + hr as u32 + ); + } + + fn derived(name: &str) -> String { + let userenv = Library::system32("userenv.dll").unwrap(); + let derive: DeriveSid = unsafe { + userenv + .symbol(b"DeriveAppContainerSidFromAppContainerName\0") + .unwrap() + }; + let mut sid = null_mut(); + let hr = unsafe { derive(wide(name).as_ptr(), &mut sid) }; + assert!( + hr >= 0, + "DeriveAppContainerSidFromAppContainerName: {:#010x}", + hr as u32 + ); + unsafe { PackageSid::adopt(sid) } + .unwrap() + .as_str() + .to_owned() + } + + fn race(name: &'static str, threads: usize) -> Vec { + let barrier = Arc::new(Barrier::new(threads)); + let handles: Vec<_> = (0..threads) + .map(|_| { + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + create_or_open_named(name).map(|sid| sid.as_str().to_owned()) + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| { + handle + .join() + .expect("thread panicked") + .expect("create or open") + }) + .collect() + } + + /// Eight threads create a profile that does not exist yet, all at once. + /// Exactly the race a first launch from concurrent tests meets: every + /// one of them gets the profile's one package SID. + #[test] + fn concurrent_first_creation_yields_one_package_sid() { + delete_profile(RACE_PROFILE); + let sids = race(RACE_PROFILE, 8); + let expected = derived(RACE_PROFILE); + println!( + "race profile {RACE_PROFILE}: {} creations, package SID {expected}", + sids.len() + ); + assert!(expected.starts_with("S-1-15-2-"), "{expected}"); + assert!( + sids.iter().all(|sid| *sid == expected), + "{sids:?} != {expected}" + ); + delete_profile(RACE_PROFILE); + } + + /// The shared worker profile, opened concurrently while it exists, + /// always yields the SID derived from its name. + #[test] + fn concurrent_opens_of_the_worker_profile_agree() { + let sids = race(PROFILE_NAME, 8); + let expected = derived(PROFILE_NAME); + println!("worker profile {PROFILE_NAME}: package SID {expected}"); + assert!( + sids.iter().all(|sid| *sid == expected), + "{sids:?} != {expected}" + ); + } +} diff --git a/crates/basal-launch/src/windows/spawn_lock.rs b/crates/basal-launch/src/windows/spawn_lock.rs new file mode 100644 index 0000000..66eb77e --- /dev/null +++ b/crates/basal-launch/src/windows/spawn_lock.rs @@ -0,0 +1,94 @@ +//! The one lock every Windows child spawn in basal takes. +//! +//! A child created with `bInheritHandles = TRUE` and no explicit handle list +//! inherits every inheritable handle in the parent at that instant. basal's +//! own spawns pass an explicit list, so they inherit only their own three +//! standard handles; but a handle is only listable once it is inheritable, +//! so between "made inheritable" and "parent's copy closed" any concurrent +//! spawn without a list (a test variant, a library, the standard library's +//! `Command`) would copy it. A child holding another child's pipe end keeps +//! that pipe open after its real owner exits, and can read or write it. +//! +//! The rule that closes this window: a spawn holds [`SpawnLock`] from the +//! moment it makes its child's handles inheritable, through process +//! creation, until it has closed its copies of them. So at any instant at +//! most one spawn's handles are inheritable, and any spawn under the lock, +//! with or without a handle list, can only inherit its own. + +use std::cell::Cell; +use std::os::windows::io::RawHandle; +use std::sync::{Mutex, MutexGuard}; +use windows_sys::Win32::Foundation::{HANDLE_FLAG_INHERIT, SetHandleInformation}; + +static SPAWN: Mutex<()> = Mutex::new(()); + +thread_local! { + static HELD: Cell = const { Cell::new(false) }; +} + +/// Proof that the calling thread holds the process-wide spawn lock. +/// Dropping it releases the lock. +#[must_use = "the lock is released as soon as the guard is dropped"] +pub struct SpawnLock { + _guard: MutexGuard<'static, ()>, +} + +impl Drop for SpawnLock { + fn drop(&mut self) { + HELD.with(|held| held.set(false)); + } +} + +impl std::fmt::Debug for SpawnLock { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SpawnLock") + } +} + +/// Takes the process-wide spawn lock, blocking until it is free. The lock is +/// not reentrant: a thread that already holds it must not call this again. +/// +/// A panic while the lock was held leaves no state behind it to repair, so +/// a poisoned lock is simply taken. +pub fn spawn_lock() -> SpawnLock { + let guard = SPAWN + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + HELD.with(|held| held.set(true)); + SpawnLock { _guard: guard } +} + +/// Whether the calling thread holds the spawn lock. +pub fn spawn_lock_held() -> bool { + HELD.with(Cell::get) +} + +/// Marks handles inheritable for a child about to be created. It takes the +/// lock guard so that no caller can make a handle inheritable outside it. +/// The caller must close its copies before dropping the guard. +pub fn make_inheritable(_held: &SpawnLock, handles: &[RawHandle]) -> std::io::Result<()> { + for &handle in handles { + if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } == 0 { + return Err(std::io::Error::last_os_error()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_guard_marks_the_thread_and_excludes_other_threads() { + assert!(!spawn_lock_held()); + let guard = spawn_lock(); + assert!(spawn_lock_held()); + let elsewhere = std::thread::spawn(|| (spawn_lock_held(), SPAWN.try_lock().is_err())) + .join() + .unwrap(); + assert_eq!(elsewhere, (false, true)); + drop(guard); + assert!(!spawn_lock_held()); + } +} diff --git a/crates/basal-launch/src/windows/token.rs b/crates/basal-launch/src/windows/token.rs new file mode 100644 index 0000000..3a4816f --- /dev/null +++ b/crates/basal-launch/src/windows/token.rs @@ -0,0 +1,628 @@ +//! The worker's tokens: how they are built, and how the parent reads a +//! token back and compares it with what it built. + +use super::deviation::Deviation; +use super::native::{ + Result, SE_GROUP_INTEGRITY, SE_GROUP_LOGON_ID, SE_GROUP_USE_FOR_DENY_ONLY, SidBuf, check, + groups, last, owned, sid_string, token_buffer, +}; +use std::ffi::c_void; +use std::mem::size_of; +use std::os::windows::io::{AsRawHandle, OwnedHandle}; +use std::ptr::{null, null_mut}; +use windows_sys::Win32::Foundation::{ + ERROR_NOT_ALL_ASSIGNED, GetHandleInformation, GetLastError, HANDLE, HANDLE_FLAG_INHERIT, + SetHandleInformation, +}; +use windows_sys::Win32::Security::{ + AdjustTokenPrivileges, CreateRestrictedToken, DACL_SECURITY_INFORMATION, DISABLE_MAX_PRIVILEGE, + DuplicateTokenEx, EqualSid, GetKernelObjectSecurity, LUID_AND_ATTRIBUTES, SE_PRIVILEGE_REMOVED, + SID_AND_ATTRIBUTES, SecurityImpersonation, SetKernelObjectSecurity, SetTokenInformation, + TOKEN_ALL_ACCESS, TOKEN_APPCONTAINER_INFORMATION, TOKEN_INFORMATION_CLASS, + TOKEN_MANDATORY_LABEL, TOKEN_PRIVILEGES, TOKEN_USER, TokenAppContainerSid, TokenCapabilities, + TokenGroups, TokenImpersonation, TokenImpersonationLevel, TokenIntegrityLevel, + TokenIsAppContainer, TokenPrivileges, TokenRestrictedSids, TokenSecurityAttributes, TokenType, + TokenUser, WinLowLabelSid, WinNullSid, WinWorldSid, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + +/// The Low integrity label, `S-1-16-4096`. Process creation turns the +/// primary of an AppContainer process Low whatever the supplied token says, +/// and an Untrusted-at-birth worker fails before entry, so the worker is born +/// Low and lowers itself to Untrusted before reading input. +pub const LOW_INTEGRITY: &str = "S-1-16-4096"; + +/// The NULL SID, `S-1-0-0`: the worker primary's only restricting SID. No +/// object grants it anything unless a grant names it explicitly, so every +/// access check the restricted half of the token must also pass fails. +pub const NULL_SID: &str = "S-1-0-0"; + +/// The claim that marks a Less Privileged AppContainer token. +const LPAC_CLAIM: &str = "WIN://NOALLAPPPKG"; + +/// `TOKEN_TYPE::TokenPrimary`. +pub const TOKEN_PRIMARY: i32 = 1; +/// `TOKEN_TYPE::TokenImpersonation`. +pub const TOKEN_IMPERSONATION: i32 = 2; +/// `SECURITY_IMPERSONATION_LEVEL::SecurityImpersonation`. +pub const IMPERSONATION_LEVEL: i32 = 2; + +/// What the parent reads back from a token. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TokenFacts { + /// `TOKEN_TYPE`: 1 primary, 2 impersonation. + pub token_type: i32, + /// The impersonation level of an impersonation token. + pub impersonation_level: Option, + /// The integrity label SID. + pub integrity: String, + /// The AppContainer (package) SID, if the token is an AppContainer token. + pub appcontainer: Option, + /// The capability SIDs. + pub capabilities: Vec, + /// Whether the token carries the Less Privileged AppContainer claim, + /// `WIN://NOALLAPPPKG` as the single unsigned value 1. + pub less_privileged: bool, + /// The restricting SIDs. + pub restricting_sids: Vec, + /// Access groups (every group but the integrity label) that are not + /// deny-only. + pub enabled_groups: Vec, + /// How many access groups are deny-only. + pub deny_only_groups: usize, + /// How many privileges the token holds, enabled or not. + pub privileges: usize, +} + +/// Reads the facts of a token opened with `TOKEN_QUERY`. +pub(crate) fn read_facts(token: HANDLE) -> Result { + unsafe { + let token_type = token_buffer(token, TokenType)?; + let token_type = *token_type.as_ptr().cast::(); + let impersonation_level = if token_type == TOKEN_IMPERSONATION { + let level = token_buffer(token, TokenImpersonationLevel)?; + Some(*level.as_ptr().cast::()) + } else { + None + }; + let label = token_buffer(token, TokenIntegrityLevel)?; + let integrity = sid_string((*label.as_ptr().cast::()).Label.Sid)?; + let is_app = token_buffer(token, TokenIsAppContainer)?; + let is_app = *is_app.as_ptr().cast::() != 0; + let (appcontainer, capabilities) = if is_app { + let info = token_buffer(token, TokenAppContainerSid)?; + let sid = (*info.as_ptr().cast::()).TokenAppContainer; + let capabilities = token_buffer(token, TokenCapabilities)?; + (Some(sid_string(sid)?), sid_list(groups(&capabilities))?) + } else { + (None, Vec::new()) + }; + let restricting = token_buffer(token, TokenRestrictedSids)?; + let group_data = token_buffer(token, TokenGroups)?; + let mut enabled_groups = Vec::new(); + let mut deny_only_groups = 0; + for group in groups(&group_data) { + if group.Attributes & SE_GROUP_INTEGRITY != 0 { + continue; + } + if group.Attributes & SE_GROUP_USE_FOR_DENY_ONLY != 0 { + deny_only_groups += 1; + } else { + enabled_groups.push(sid_string(group.Sid)?); + } + } + let privileges = token_buffer(token, TokenPrivileges)?; + let privileges = (*privileges.as_ptr().cast::()).PrivilegeCount as usize; + Ok(TokenFacts { + token_type, + impersonation_level, + integrity, + appcontainer, + capabilities, + // Only an AppContainer token can be a Less Privileged one. + less_privileged: is_app && less_privileged_claim(token)?, + restricting_sids: sid_list(groups(&restricting))?, + enabled_groups, + deny_only_groups, + privileges, + }) + } +} + +fn sid_list(list: &[SID_AND_ATTRIBUTES]) -> Result> { + list.iter() + .map(|entry| unsafe { sid_string(entry.Sid) }) + .collect() +} + +/// What the parent built into the worker's primary, and so expects to read +/// back from the suspended child. +#[derive(Debug, Clone)] +pub(crate) struct BirthExpectation { + pub(crate) package: String, + pub(crate) less_privileged: bool, + pub(crate) capabilities: Vec, + pub(crate) restricting_sids: Vec, + pub(crate) enabled_groups: Vec, + pub(crate) privileges: bool, +} + +/// Compares the suspended child's primary with what was built. Returns every +/// difference found. +pub(crate) fn check_birth( + facts: &TokenFacts, + expected: &BirthExpectation, +) -> std::result::Result<(), String> { + let mut wrong = Vec::new(); + if facts.token_type != TOKEN_PRIMARY { + wrong.push(format!("token type {} is not primary", facts.token_type)); + } + if facts.integrity != LOW_INTEGRITY { + wrong.push(format!("integrity {} is not Low", facts.integrity)); + } + if facts.appcontainer.as_deref() != Some(expected.package.as_str()) { + wrong.push(format!( + "AppContainer SID {:?} is not the package {}", + facts.appcontainer, expected.package + )); + } + if facts.capabilities != expected.capabilities { + wrong.push(format!( + "capabilities {:?}, expected {:?}", + facts.capabilities, expected.capabilities + )); + } + if facts.less_privileged != expected.less_privileged { + wrong.push(format!( + "{LPAC_CLAIM} claim present {}, expected {}", + facts.less_privileged, expected.less_privileged + )); + } + if sorted(&facts.restricting_sids) != sorted(&expected.restricting_sids) { + wrong.push(format!( + "restricting SIDs {:?}, expected {:?}", + facts.restricting_sids, expected.restricting_sids + )); + } + if sorted(&facts.enabled_groups) != sorted(&expected.enabled_groups) { + wrong.push(format!( + "groups not deny-only {:?}, expected {:?}", + facts.enabled_groups, expected.enabled_groups + )); + } + if (facts.privileges != 0) != expected.privileges { + wrong.push(format!( + "{} privileges, expected {}", + facts.privileges, + if expected.privileges { "some" } else { "none" } + )); + } + if wrong.is_empty() { + Ok(()) + } else { + Err(wrong.join("; ")) + } +} + +/// Compares the start-up thread token, read back from the suspended thread, +/// with the one the parent set: a Low impersonation token at +/// SecurityImpersonation for the same package. A token the system judges +/// stronger than the process's primary is silently downgraded to +/// SecurityIdentification, and the child then fails before entry, so the +/// level must be read back rather than assumed. +pub(crate) fn check_initial(facts: &TokenFacts, package: &str) -> std::result::Result<(), String> { + let mut wrong = Vec::new(); + if facts.token_type != TOKEN_IMPERSONATION { + wrong.push(format!( + "token type {} is not impersonation", + facts.token_type + )); + } + if facts.impersonation_level != Some(IMPERSONATION_LEVEL) { + wrong.push(format!( + "impersonation level {:?} is not SecurityImpersonation", + facts.impersonation_level + )); + } + if facts.integrity != LOW_INTEGRITY { + wrong.push(format!("integrity {} is not Low", facts.integrity)); + } + if facts.appcontainer.as_deref() != Some(package) { + wrong.push(format!( + "AppContainer SID {:?} is not the package {package}", + facts.appcontainer + )); + } + if wrong.is_empty() { + Ok(()) + } else { + Err(wrong.join("; ")) + } +} + +fn sorted(list: &[String]) -> Vec<&str> { + let mut list: Vec<&str> = list.iter().map(String::as_str).collect(); + list.sort_unstable(); + list +} + +/// Opens this process's own token with full access, the source of the +/// worker's primary. +pub(crate) fn own_token() -> Result { + let mut token = null_mut(); + check( + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, &mut token) }, + "OpenProcessToken(parent)", + )?; + owned(token, "OpenProcessToken(parent)") +} + +/// Builds the worker's primary token from the parent's own token: every +/// access group deny-only (logon included), no privileges, the NULL SID as +/// the only restricting SID, Low integrity. +/// +/// The AppContainer part is not added here. Process creation lowboxes this +/// token to the package with the security capabilities passed as a creation +/// attribute. Because the token is a restricted copy of the caller's own, +/// creating a process with it needs no SeAssignPrimaryTokenPrivilege. +pub(crate) fn build_primary( + parent: HANDLE, + package: &str, + deviation: Deviation, +) -> Result<(OwnedHandle, BirthExpectation)> { + unsafe { + let data = token_buffer(parent, TokenGroups)?; + let mut kept_enabled = Vec::new(); + let mut disabled = Vec::new(); + for group in groups(&data) { + // The integrity label is not an access group. + if group.Attributes & SE_GROUP_INTEGRITY != 0 { + continue; + } + if deviation.keeps_logon_group() + && group.Attributes & SE_GROUP_LOGON_ID == SE_GROUP_LOGON_ID + { + kept_enabled.push(sid_string(group.Sid)?); + continue; + } + disabled.push(SID_AND_ATTRIBUTES { + Sid: group.Sid, + Attributes: 0, + }); + } + if deviation.keeps_logon_group() && kept_enabled.is_empty() { + return Err("the parent token has no logon SID to keep enabled".into()); + } + let null_sid = SidBuf::well_known(WinNullSid)?; + let world = SidBuf::well_known(WinWorldSid)?; + let mut restrict = vec![SID_AND_ATTRIBUTES { + Sid: null_sid.as_psid(), + Attributes: 0, + }]; + if deviation.widens_restricting_sids() { + restrict.push(SID_AND_ATTRIBUTES { + Sid: world.as_psid(), + Attributes: 0, + }); + } + let mut token = null_mut(); + check( + CreateRestrictedToken( + parent, + DISABLE_MAX_PRIVILEGE, + disabled.len() as u32, + disabled.as_ptr(), + 0, + null(), + restrict.len() as u32, + restrict.as_ptr(), + &mut token, + ), + "CreateRestrictedToken(primary)", + )?; + let token = owned(token, "CreateRestrictedToken(primary)")?; + // DISABLE_MAX_PRIVILEGE keeps SeChangeNotifyPrivilege. Remove it and + // anything else left, so the token holds no privilege at all. + if !deviation.keeps_privileges() { + remove_privileges(token.as_raw_handle())?; + } + ensure_low(token.as_raw_handle())?; + let mut restricting_sids = vec![null_sid.to_text()?]; + if deviation.widens_restricting_sids() { + restricting_sids.push(world.to_text()?); + } + Ok(( + token, + BirthExpectation { + package: package.to_owned(), + less_privileged: deviation.less_privileged(), + capabilities: Vec::new(), + restricting_sids, + enabled_groups: kept_enabled, + privileges: deviation.expects_privileges(), + }, + )) + } +} + +/// Builds the start-up thread token from the token of a never-resumed +/// process born into the same AppContainer: a Low, same-package impersonation +/// token whose restricting SIDs are its own user and groups. +/// +/// The worker's primary allows almost nothing, which is too little for the +/// loader to map system DLLs and initialize the process. The loader runs on +/// the main thread, which impersonates this token until the worker reverts +/// to its primary before reading any input. Restricting the token to the +/// same SIDs it already has keeps it from counting as stronger than the +/// restricted primary, which would get it downgraded to identification +/// level and fail the child before entry. +pub(crate) fn build_initial(source: HANDLE) -> Result { + unsafe { + let source_groups = token_buffer(source, TokenGroups)?; + let source_user = token_buffer(source, TokenUser)?; + let source_dacl = token_dacl(source)?; + let mut same_access = groups(&source_groups) + .iter() + .filter(|group| group.Attributes & SE_GROUP_INTEGRITY == 0) + .map(|group| SID_AND_ATTRIBUTES { + Sid: group.Sid, + Attributes: 0, + }) + .collect::>(); + same_access.push(SID_AND_ATTRIBUTES { + Sid: (*source_user.as_ptr().cast::()).User.Sid, + Attributes: 0, + }); + let mut loader = null_mut(); + check( + CreateRestrictedToken( + source, + DISABLE_MAX_PRIVILEGE, + 0, + null(), + 0, + null(), + same_access.len() as u32, + same_access.as_ptr(), + &mut loader, + ), + "CreateRestrictedToken(initial)", + )?; + let loader = owned(loader, "CreateRestrictedToken(initial)")?; + // Filtering and duplicating otherwise give the new token object the + // parent's default DACL, which does not grant the package SID: the + // child could not query its own start-up token. + set_token_dacl(loader.as_raw_handle(), &source_dacl)?; + ensure_low(loader.as_raw_handle())?; + let loader_dacl = token_dacl(loader.as_raw_handle())?; + let mut initial = null_mut(); + check( + DuplicateTokenEx( + loader.as_raw_handle(), + TOKEN_ALL_ACCESS, + null(), + SecurityImpersonation, + TokenImpersonation, + &mut initial, + ), + "DuplicateTokenEx(initial)", + )?; + let initial = owned(initial, "DuplicateTokenEx(initial)")?; + set_token_dacl(initial.as_raw_handle(), &loader_dacl)?; + check( + SetHandleInformation(initial.as_raw_handle(), HANDLE_FLAG_INHERIT, 0), + "SetHandleInformation(initial token)", + )?; + let mut flags = 0; + check( + GetHandleInformation(initial.as_raw_handle(), &mut flags), + "GetHandleInformation(initial token)", + )?; + if flags & HANDLE_FLAG_INHERIT != 0 { + return Err("the initial token handle stayed inheritable".into()); + } + Ok(initial) + } +} + +/// Removes every privilege the token still holds. +unsafe fn remove_privileges(token: HANDLE) -> Result<()> { + unsafe { + let data = token_buffer(token, TokenPrivileges)?; + let list = &*data.as_ptr().cast::(); + let entries = + std::slice::from_raw_parts(list.Privileges.as_ptr(), list.PrivilegeCount as usize); + for entry in entries { + let remove = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: entry.Luid, + Attributes: SE_PRIVILEGE_REMOVED, + }], + }; + check( + AdjustTokenPrivileges(token, 0, &remove, 0, null_mut(), null_mut()), + "AdjustTokenPrivileges(remove)", + )?; + // The call succeeds even when it changed nothing; that case is + // reported only through the last error. + if GetLastError() == ERROR_NOT_ALL_ASSIGNED { + return Err(last("AdjustTokenPrivileges(remove)")); + } + } + Ok(()) + } +} + +/// Sets the token's integrity to Low unless it is Low already. Lowering +/// needs only TOKEN_ADJUST_DEFAULT on the handle. +unsafe fn ensure_low(token: HANDLE) -> Result<()> { + unsafe { + let low = SidBuf::well_known(WinLowLabelSid)?; + let current = token_buffer(token, TokenIntegrityLevel)?; + let current = (*current.as_ptr().cast::()) + .Label + .Sid; + if EqualSid(current, low.as_psid()) != 0 { + return Ok(()); + } + let label = TOKEN_MANDATORY_LABEL { + Label: SID_AND_ATTRIBUTES { + Sid: low.as_psid(), + Attributes: SE_GROUP_INTEGRITY, + }, + }; + let length = size_of::() + + windows_sys::Win32::Security::GetLengthSid(low.as_psid()) as usize; + check( + SetTokenInformation( + token, + TokenIntegrityLevel, + (&label as *const TOKEN_MANDATORY_LABEL).cast(), + length as u32, + ), + "SetTokenInformation(TokenIntegrityLevel)", + ) + } +} + +unsafe fn token_dacl(token: HANDLE) -> Result> { + unsafe { + let mut bytes = 0; + GetKernelObjectSecurity(token, DACL_SECURITY_INFORMATION, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(last("GetKernelObjectSecurity(token DACL size)")); + } + let mut data = vec![0usize; (bytes as usize).div_ceil(size_of::())]; + check( + GetKernelObjectSecurity( + token, + DACL_SECURITY_INFORMATION, + data.as_mut_ptr().cast(), + bytes, + &mut bytes, + ), + "GetKernelObjectSecurity(token DACL)", + )?; + Ok(data) + } +} + +unsafe fn set_token_dacl(token: HANDLE, descriptor: &[usize]) -> Result<()> { + check( + unsafe { + SetKernelObjectSecurity( + token, + DACL_SECURITY_INFORMATION, + descriptor.as_ptr().cast_mut().cast(), + ) + }, + "SetKernelObjectSecurity(token DACL)", + ) +} + +// Token security attributes have no Win32 reader, so the claim is read with +// the native call. The layouts are those of +// TOKEN_SECURITY_ATTRIBUTES_INFORMATION and TOKEN_SECURITY_ATTRIBUTE_V1 on +// 64-bit Windows. +#[repr(C)] +struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *mut u16, +} + +#[repr(C)] +struct SecurityAttribute { + name: UnicodeString, + value_type: u16, + reserved: u16, + flags: u32, + value_count: u32, + values: *mut c_void, +} + +#[repr(C)] +struct SecurityAttributes { + version: u16, + reserved: u16, + count: u32, + attributes: *mut SecurityAttribute, +} + +/// `TOKEN_SECURITY_ATTRIBUTE_TYPE_UINT64`. +const ATTRIBUTE_TYPE_UINT64: u16 = 2; + +#[link(name = "ntdll", kind = "raw-dylib")] +unsafe extern "system" { + fn NtQueryInformationToken( + token: HANDLE, + class: TOKEN_INFORMATION_CLASS, + buffer: *mut c_void, + length: u32, + returned: *mut u32, + ) -> i32; +} + +/// Whether the token carries `WIN://NOALLAPPPKG` as the single unsigned +/// value 1, the mark of a Less Privileged AppContainer. The dedicated +/// information class, `TokenIsLessPrivilegedAppContainer`, fails with +/// "invalid information class" on Windows Server 2022 and Windows 11, so the +/// claim is read instead. +fn less_privileged_claim(token: HANDLE) -> Result { + unsafe { + let mut bytes = 0; + let status = + NtQueryInformationToken(token, TokenSecurityAttributes, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(format!( + "NtQueryInformationToken(TokenSecurityAttributes, size): {:#010x}", + status as u32 + )); + } + let mut data = vec![0usize; (bytes as usize).div_ceil(size_of::())]; + let status = NtQueryInformationToken( + token, + TokenSecurityAttributes, + data.as_mut_ptr().cast(), + bytes, + &mut bytes, + ); + if status != 0 { + return Err(format!( + "NtQueryInformationToken(TokenSecurityAttributes): {:#010x}", + status as u32 + )); + } + let header = &*data.as_ptr().cast::(); + if header.version != 1 { + return Err(format!( + "unsupported token security attributes version {}", + header.version + )); + } + if header.count == 0 { + return Ok(false); + } + let entries = std::slice::from_raw_parts(header.attributes, header.count as usize); + for entry in entries { + let name = &entry.name; + let name = if name.buffer.is_null() { + String::new() + } else { + String::from_utf16_lossy(std::slice::from_raw_parts( + name.buffer, + usize::from(name.length) / 2, + )) + }; + if name != LPAC_CLAIM { + continue; + } + if entry.value_type != ATTRIBUTE_TYPE_UINT64 || entry.value_count != 1 { + return Ok(false); + } + return Ok(*entry.values.cast::() == 1); + } + Ok(false) + } +} diff --git a/crates/basal-launch/tests/windows_launch.rs b/crates/basal-launch/tests/windows_launch.rs new file mode 100644 index 0000000..e64f42b --- /dev/null +++ b/crates/basal-launch/tests/windows_launch.rs @@ -0,0 +1,264 @@ +//! Real launches of a GUI-subsystem test child under the Windows confinement, +//! read back by the parent. +#![cfg(windows)] + +use basal_launch::{ + ConfinedProcess, Deviation, JobLimits, KILL_EXIT_CODE, LOW_INTEGRITY, LaunchOptions, NULL_SID, + TOKEN_PRIMARY, create_or_open_profile, grant_test_binary_directory, launch, +}; +use std::io::{BufRead, BufReader, Read}; +use std::os::windows::io::AsRawHandle; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use windows_sys::Win32::System::Threading::{GetProcessMitigationPolicy, ProcessDynamicCodePolicy}; + +/// The job commit limit for these launches. Production passes the limit of +/// the worker's profile from `basal_proto::limits`; the test child needs far +/// less than this. +const COMMIT_BYTES: u64 = 512 * 1024 * 1024; + +/// The test child, copied into a directory of its own so that the package +/// grant changes no ACL but that directory's. +fn placed_child() -> &'static Path { + static PLACED: OnceLock = OnceLock::new(); + PLACED.get_or_init(|| { + let built = Path::new(env!("CARGO_BIN_EXE_basal-launch-test-helper")); + let directory = built + .parent() + .expect("the test child has a directory") + .join("basal-launch-placed"); + std::fs::create_dir_all(&directory).expect("create the placement directory"); + let placed = directory.join("basal-launch-test-helper.exe"); + std::fs::copy(built, &placed).expect("place the test child"); + let package = create_or_open_profile().expect("worker profile"); + grant_test_binary_directory(&placed, &package).expect("grant the package read and execute"); + placed + }) +} + +fn start(deviation: Deviation, args: &[&str]) -> ConfinedProcess { + let mut options = LaunchOptions::new(placed_child(), COMMIT_BYTES).deviation(deviation); + for arg in args { + options = options.arg(arg); + } + launch(&options).unwrap_or_else(|error| panic!("launch {deviation}: {error}")) +} + +/// Reads `count` lines from the child's stdout. If the child ends first, +/// fails with its exit code and stderr. +fn lines(child: &mut ConfinedProcess, count: usize) -> Vec { + let mut stdout = BufReader::new(child.stdout.take().expect("stdout")); + let mut lines = Vec::new(); + for _ in 0..count { + let mut line = String::new(); + let read = stdout + .read_line(&mut line) + .expect("read the child's stdout"); + if read == 0 { + let code = child.wait().expect("wait for the child"); + let mut stderr = String::new(); + let _ = child + .stderr + .take() + .expect("stderr") + .read_to_string(&mut stderr); + panic!("the child ended with {code:#010x} after {lines:?}; stderr: {stderr}"); + } + lines.push(line.trim_end().to_owned()); + } + child.stdout = Some(stdout.into_inner()); + lines +} + +/// The child's dynamic-code policy word, read through the parent's handle. +fn dynamic_code_policy(child: &ConfinedProcess) -> u32 { + let mut policy = 0u32; + let ok = unsafe { + GetProcessMitigationPolicy( + child.as_raw_handle(), + ProcessDynamicCodePolicy, + (&mut policy as *mut u32).cast(), + 4, + ) + }; + assert_ne!( + ok, + 0, + "GetProcessMitigationPolicy: {}", + std::io::Error::last_os_error() + ); + policy +} + +fn kill_and_reap(child: &ConfinedProcess) { + child.kill().expect("kill"); + assert_eq!(child.wait().expect("wait"), KILL_EXIT_CODE); + assert_eq!(child.try_wait().expect("try_wait"), Some(KILL_EXIT_CODE)); +} + +/// The full confinement starts the GUI-subsystem child, which runs to its +/// first output with exactly the three pipes as its standard handles. The +/// parent then reads back the child's primary token and job through its own +/// handles, and kills it. +#[test] +fn full_confinement_starts_the_child_reads_back_its_token_and_job_and_kills_it() { + let mut child = start(Deviation::Full, &[]); + let ready = lines(&mut child, 1).remove(0); + let [stdin, stdout, stderr] = child.inherited_stdio(); + assert_eq!( + ready, + format!("ready stdin={stdin} stdout={stdout} stderr={stderr} reverted=true") + ); + assert_eq!( + child.try_wait().expect("try_wait"), + None, + "the child waits on stdin" + ); + + let token = child.primary_token().expect("read the primary token"); + let package = child.package_sid().as_str().to_owned(); + println!("full: pid {} package {package}", child.id()); + println!("full: primary token {token:?}"); + assert_eq!(token.token_type, TOKEN_PRIMARY); + assert_eq!(token.appcontainer.as_deref(), Some(package.as_str())); + assert!( + token.less_privileged, + "the WIN://NOALLAPPPKG claim is present" + ); + assert_eq!(token.capabilities, Vec::::new()); + assert_eq!(token.integrity, LOW_INTEGRITY); + assert_eq!(token.restricting_sids, [NULL_SID]); + assert_eq!(token.enabled_groups, Vec::::new()); + assert!(token.deny_only_groups > 0); + assert_eq!(token.privileges, 0); + + let limits = child.job_limits().expect("read the job limits"); + println!("full: job limits {limits:?}"); + assert_eq!(limits, JobLimits::confined(COMMIT_BYTES as usize)); + assert!(child.in_owned_job().expect("job membership")); + let dynamic_code = dynamic_code_policy(&child); + println!("full: dynamic-code policy {dynamic_code:#x}"); + assert_eq!(dynamic_code & 1, 1, "dynamic code is prohibited"); + + kill_and_reap(&child); + println!("full: killed, exit code {KILL_EXIT_CODE}"); +} + +/// The fully confined child cannot create a file in its own TEMP directory: +/// the directory exists and resolves, but grants the worker no write. +#[test] +fn full_confinement_denies_a_file_in_the_childs_temp_directory() { + let mut child = start(Deviation::Full, &["--try-temp-write"]); + let report = lines(&mut child, 2); + println!("temp: {report:?} in {}", child.temp_dir().display()); + assert!(child.temp_dir().is_dir(), "the TEMP directory exists"); + // Win32 5 is ERROR_ACCESS_DENIED. + assert_eq!(report[1], "temp-write denied 5"); + assert!(!child.temp_dir().join("basal-launch-probe").exists()); + kill_and_reap(&child); +} + +#[cfg(feature = "deviations")] +mod deviations { + use super::*; + use basal_launch::Refusal; + + fn assert_refused(deviation: Deviation, refusal: Refusal) { + assert_eq!(deviation.parent_refusal(), Some(refusal)); + let options = LaunchOptions::new(placed_child(), COMMIT_BYTES).deviation(deviation); + let error = launch(&options).expect_err("the parent must refuse before resume"); + println!("{deviation}: {error}"); + assert_eq!(error.refusal(), Some(refusal), "{error}"); + } + + #[test] + fn a_job_with_other_limits_is_refused_as_job_limits_mismatch() { + assert_refused(Deviation::JobLimitsMismatch, Refusal::JobLimitsMismatch); + } + + #[test] + fn a_child_outside_the_owned_job_is_refused_as_not_in_owned_job() { + assert_refused(Deviation::NotInOwnedJob, Refusal::NotInOwnedJob); + } + + #[test] + fn a_primary_with_privileges_is_refused_as_birth_token_mismatch() { + assert_refused(Deviation::BirthTokenMismatch, Refusal::BirthTokenMismatch); + } + + #[test] + fn a_missing_start_up_token_is_refused_as_initial_token_open() { + assert_refused(Deviation::InitialTokenOpen, Refusal::InitialTokenOpen); + } + + /// Each worker check's variant passes the parent's checks, because the + /// parent expects the broken property, and the child starts with that + /// property visibly broken. + #[test] + fn every_worker_check_variant_starts_with_its_property_broken() { + for deviation in Deviation::ALL { + if deviation.worker_reason().is_none() { + continue; + } + let mut child = start(deviation, &[]); + let ready = lines(&mut child, 1).remove(0); + assert!(ready.starts_with("ready "), "{deviation}: {ready}"); + let token = child.primary_token().expect("read the primary token"); + println!("{deviation}: started; primary token {token:?}"); + match deviation { + Deviation::NotLpac => assert!(!token.less_privileged), + Deviation::CapabilitiesPresent => assert_eq!(token.capabilities, ["S-1-15-3-1"]), + Deviation::RestrictingSidMismatch => { + let mut sids = token.restricting_sids.clone(); + sids.sort(); + assert_eq!(sids, [NULL_SID, "S-1-1-0"]); + } + Deviation::GroupNotDenyOnly => assert_eq!(token.enabled_groups.len(), 1), + Deviation::PrivilegesPresent => assert!(token.privileges > 0), + Deviation::MitigationMismatch => assert_eq!(dynamic_code_policy(&child) & 1, 0), + _ => { + assert!(token.less_privileged); + assert_eq!(token.restricting_sids, [NULL_SID]); + } + } + if deviation != Deviation::NotLpac { + assert!(token.less_privileged, "{deviation}"); + } + kill_and_reap(&child); + } + } + + /// The positive controls start, with the weaker tokens they are meant + /// to have. The plain child can create a file in the TEMP directory, + /// which shows the path the confined child is denied is a real, writable + /// one. The LPAC-only child is denied too. + #[test] + fn the_positive_controls_start_with_their_weaker_tokens() { + let mut child = start(Deviation::LpacOnly, &["--try-temp-write"]); + let report = lines(&mut child, 2); + println!("lpac-only: {report:?}"); + assert!(report[1].starts_with("temp-write denied "), "{report:?}"); + let token = child.primary_token().expect("read the primary token"); + println!("lpac-only: primary token {token:?}"); + assert_eq!( + token.appcontainer.as_deref(), + Some(child.package_sid().as_str()) + ); + assert!(token.less_privileged); + assert_eq!(token.capabilities, Vec::::new()); + assert_eq!(token.restricting_sids, Vec::::new()); + assert!(!token.enabled_groups.is_empty()); + kill_and_reap(&child); + + let mut child = start(Deviation::Plain, &["--try-temp-write"]); + let report = lines(&mut child, 2); + println!("plain: {report:?}"); + assert_eq!(report[1], "temp-write created"); + assert!(child.temp_dir().join("basal-launch-probe").is_file()); + let token = child.primary_token().expect("read the primary token"); + println!("plain: primary token {token:?}"); + assert_eq!(token.appcontainer, None); + assert!(!token.less_privileged); + kill_and_reap(&child); + } +} diff --git a/crates/basal-rig/Cargo.toml b/crates/basal-rig/Cargo.toml index b7c4003..377ddd7 100644 --- a/crates/basal-rig/Cargo.toml +++ b/crates/basal-rig/Cargo.toml @@ -31,6 +31,14 @@ subc-client-rs.workspace = true subc-protocol.workspace = true tokio = { workspace = true, features = ["sync", "time"] } +[target.'cfg(windows)'.dependencies] +basal-launch.workspace = true +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Threading", +] } + [dev-dependencies] basal-host.workspace = true basal-proto.workspace = true diff --git a/crates/basal-rig/src/bin/basal-rig-contract.rs b/crates/basal-rig/src/bin/basal-rig-contract.rs index 48e7570..ce40c92 100644 --- a/crates/basal-rig/src/bin/basal-rig-contract.rs +++ b/crates/basal-rig/src/bin/basal-rig-contract.rs @@ -50,6 +50,9 @@ mod ownership; mod package_cases; #[path = "basal-rig-contract/seeded.rs"] mod seeded; +#[cfg(windows)] +#[path = "basal-rig-contract/windows.rs"] +mod windows; const CORE: &str = "prefrontal-core"; const BASAL: &str = "basal"; @@ -1394,6 +1397,7 @@ async fn relay_refusals(rig: &Rig, flow: &Flow, agent: &Agent) -> Case { case } +#[cfg(unix)] fn basal_pid() -> Option { let out = std::process::Command::new("ps") .args(["-axo", "pid=,command="]) @@ -1411,6 +1415,11 @@ fn basal_pid() -> Option { }) } +#[cfg(windows)] +fn basal_pid() -> Option { + windows::basal_pid() +} + async fn crash(rig: &Rig, flow: &Flow, agent: &Agent, since: i64) -> Case { let mut case = Case::new(CRASH_CASE); // Arm the kill switch before approving, so the flow's first run cannot diff --git a/crates/basal-rig/src/bin/basal-rig-contract/seeded.rs b/crates/basal-rig/src/bin/basal-rig-contract/seeded.rs index 3432f37..e0ed635 100644 --- a/crates/basal-rig/src/bin/basal-rig-contract/seeded.rs +++ b/crates/basal-rig/src/bin/basal-rig-contract/seeded.rs @@ -9,13 +9,21 @@ pub(super) fn module_cli_path(home: &std::path::Path) -> Result { return Err("module control requires the isolated ckdev-flows home".into()); } - Ok(root.join("bin/ckdev-ck")) + #[cfg(unix)] + { + Ok(root.join("bin/ckdev-ck")) + } + #[cfg(windows)] + { + Ok(root.join("bin").join("ckdev-ck.exe")) + } } pub(super) fn ck(args: &[&str]) -> Result { let connection = std::env::var_os("SUBC_CONNECTION_FILE").ok_or("no isolated connection file")?; let home = PathBuf::from(std::env::var_os("HOME").ok_or("missing isolated HOME")?); + #[cfg(unix)] let output = std::process::Command::new(module_cli_path(&home)?) .arg("--subc") .arg(connection) @@ -23,6 +31,8 @@ pub(super) fn ck(args: &[&str]) -> Result { .args(args) .output() .map_err(|e| e.to_string())?; + #[cfg(windows)] + let output = super::windows::ck_output(module_cli_path(&home)?, connection, args)?; if !output.status.success() { return Err(format!( "ckdev-ck {args:?}: {} {}", @@ -195,6 +205,7 @@ pub(super) async fn stale( case } +#[cfg(unix)] pub(super) fn no_store_handles(path: &std::path::Path) -> Result<(), String> { let mut command = std::process::Command::new("lsof"); command.arg("-F").arg("p").arg(path); @@ -212,3 +223,33 @@ pub(super) fn no_store_handles(path: &std::path::Path) -> Result<(), String> { } Ok(()) } + +#[cfg(windows)] +pub(super) fn no_store_handles(path: &std::path::Path) -> Result<(), String> { + use std::os::windows::fs::OpenOptionsExt; + let mut files = vec![path.to_path_buf()]; + for suffix in ["-wal", "-shm"] { + let sidecar = PathBuf::from(format!("{}{suffix}", path.display())); + if sidecar.exists() { + files.push(sidecar); + } + } + // No-sharing opens refuse while any other reader or writer owns the file. + // Hold all three together so the observation covers the store and sidecars. + let _handles = files + .iter() + .map(|file| { + std::fs::OpenOptions::new() + .read(true) + .share_mode(0) + .open(file) + .map_err(|e| { + format!( + "cannot prove {} has no open store handles: {e}", + file.display() + ) + }) + }) + .collect::, _>>()?; + Ok(()) +} diff --git a/crates/basal-rig/src/bin/basal-rig-contract/tests.rs b/crates/basal-rig/src/bin/basal-rig-contract/tests.rs index c71dd23..f4869a9 100644 --- a/crates/basal-rig/src/bin/basal-rig-contract/tests.rs +++ b/crates/basal-rig/src/bin/basal-rig-contract/tests.rs @@ -129,15 +129,19 @@ fn seeded_fixture_requires_a_terminal_disabled_module_and_no_pid() { #[test] fn seeded_fixture_rejects_foreign_paths_and_symlinked_stores() { - let output = std::process::Command::new("mktemp") - .arg("-d") - .arg(std::env::temp_dir().join("basal-rig-fixture.XXXXXXXX")) - .output() - .unwrap(); - assert!(output.status.success()); - let root = PathBuf::from(String::from_utf8(output.stdout).unwrap().trim()) - .canonicalize() - .unwrap(); + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let root = loop { + let root = std::env::temp_dir().join(format!( + "basal-rig-fixture-{}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + match std::fs::create_dir(&root) { + Ok(()) => break root.canonicalize().unwrap(), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("create isolated fixture: {error}"), + } + }; let rig = root.join("ckdev-flows"); let home = rig.join("home"); let store = rig.join("data/cortexkit/basal/store.db"); @@ -151,9 +155,12 @@ fn seeded_fixture_rejects_foreign_paths_and_symlinked_stores() { let foreign = root.join("production.db"); std::fs::write(&foreign, []).unwrap(); assert!(seeded::fixture_path_for(&home, &foreign, "basal").is_err()); - std::fs::remove_file(&store).unwrap(); - std::os::unix::fs::symlink(&foreign, &store).unwrap(); - assert!(seeded::fixture_path_for(&home, &store, "basal").is_err()); + #[cfg(unix)] + { + std::fs::remove_file(&store).unwrap(); + std::os::unix::fs::symlink(&foreign, &store).unwrap(); + assert!(seeded::fixture_path_for(&home, &store, "basal").is_err()); + } assert!(seeded::fixture_path_for(&home, &store, "other").is_err()); std::fs::remove_dir_all(&root).unwrap(); } @@ -191,10 +198,38 @@ fn authorship_fingerprint_allows_only_the_one_author_cell() { #[test] fn fixture_module_control_uses_only_the_absolute_isolated_cli() { + let root = std::env::temp_dir().join("rig").join("ckdev-flows"); + let home = root.join("home"); assert_eq!( - seeded::module_cli_path(std::path::Path::new("/rig/ckdev-flows/home")).unwrap(), - PathBuf::from("/rig/ckdev-flows/bin/ckdev-ck") + seeded::module_cli_path(&home).unwrap(), + root.join("bin").join(if cfg!(windows) { + "ckdev-ck.exe" + } else { + "ckdev-ck" + }) ); - assert!(seeded::module_cli_path(std::path::Path::new("/rig/home")).is_err()); + assert!(seeded::module_cli_path(&std::env::temp_dir().join("rig").join("home")).is_err()); assert!(seeded::module_cli_path(std::path::Path::new("ckdev-flows/home")).is_err()); } + +#[cfg(windows)] +#[test] +fn store_handle_probe_refuses_an_open_database_or_sidecar() { + let directory = + std::env::temp_dir().join(format!("basal-rig-handle-probe-{}", std::process::id())); + std::fs::create_dir(&directory).unwrap(); + let store = directory.join("store.db"); + std::fs::write(&store, []).unwrap(); + for suffix in ["", "-wal", "-shm"] { + let path = PathBuf::from(format!("{}{suffix}", store.display())); + std::fs::write(&path, []).unwrap(); + let held = std::fs::File::open(&path).unwrap(); + assert!( + seeded::no_store_handles(&store).is_err(), + "open {suffix:?} was not refused" + ); + drop(held); + assert!(seeded::no_store_handles(&store).is_ok()); + } + std::fs::remove_dir_all(directory).unwrap(); +} diff --git a/crates/basal-rig/src/bin/basal-rig-contract/windows.rs b/crates/basal-rig/src/bin/basal-rig-contract/windows.rs new file mode 100644 index 0000000..71ae8fb --- /dev/null +++ b/crates/basal-rig/src/bin/basal-rig-contract/windows.rs @@ -0,0 +1,100 @@ +//! Native process observations and CLI capture for the isolated Windows rig. + +use std::ffi::OsString; +use std::io::Read; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::path::PathBuf; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS, +}; +use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, +}; + +pub(super) fn basal_pid() -> Option { + let home = PathBuf::from(std::env::var_os("HOME")?); + let expected = home.parent()?.join("bin/ckdev-basal.exe"); + // SAFETY: snapshot handles are newly owned unless the call returns INVALID_HANDLE_VALUE. + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return None; + } + let snapshot = unsafe { OwnedHandle::from_raw_handle(snapshot) }; + let mut row: PROCESSENTRY32W = unsafe { std::mem::zeroed() }; + row.dwSize = std::mem::size_of_val(&row) as u32; + let mut more = unsafe { Process32FirstW(snapshot.as_raw_handle(), &mut row) }; + while more != 0 { + let process = + unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, row.th32ProcessID) }; + if !process.is_null() { + let process = unsafe { OwnedHandle::from_raw_handle(process) }; + let mut path = vec![0u16; 32768]; + let mut size = path.len() as u32; + // SAFETY: size describes the writable UTF-16 buffer; the process is owned. + if unsafe { + QueryFullProcessImageNameW(process.as_raw_handle(), 0, path.as_mut_ptr(), &mut size) + } != 0 + { + use std::os::windows::ffi::OsStringExt; + let actual = PathBuf::from(OsString::from_wide(&path[..size as usize])); + if actual + .to_string_lossy() + .eq_ignore_ascii_case(&expected.to_string_lossy()) + { + return Some(row.th32ProcessID.to_string()); + } + } + } + more = unsafe { Process32NextW(snapshot.as_raw_handle(), &mut row) }; + } + None +} + +pub(super) fn ck_output( + program: PathBuf, + connection: OsString, + args: &[&str], +) -> Result { + use basal_launch::{PlainCommand, PlainStdio}; + use std::os::windows::process::ExitStatusExt; + let mut argv = vec!["--subc".into(), connection, "--json".into()]; + argv.extend(args.iter().map(|arg| OsString::from(*arg))); + let command = PlainCommand { + program, + args: argv, + env: std::env::vars_os().collect(), + cwd: None, + stdin: PlainStdio::Null, + stdout: PlainStdio::Piped, + stderr: PlainStdio::Piped, + }; + let mut child = basal_launch::spawn_plain(&command).map_err(|e| e.to_string())?; + let mut stdout = child.stdout.take().ok_or("no stdout pipe")?; + let mut stderr = child.stderr.take().ok_or("no stderr pipe")?; + let out = std::thread::spawn(move || { + let mut bytes = vec![]; + stdout.read_to_end(&mut bytes).map(|_| bytes) + }); + let err = std::thread::spawn(move || { + let mut bytes = vec![]; + stderr.read_to_end(&mut bytes).map(|_| bytes) + }); + let status = child.wait().map_err(|e| e.to_string())?; + // End descendants before joining pipe readers, even when the CLI exited normally. + child.kill().map_err(|e| e.to_string())?; + drop(child); + let stdout = out + .join() + .map_err(|_| "stdout reader panicked")? + .map_err(|e| e.to_string())?; + let stderr = err + .join() + .map_err(|_| "stderr reader panicked")? + .map_err(|e| e.to_string())?; + Ok(std::process::Output { + status: std::process::ExitStatus::from_raw(status), + stdout, + stderr, + }) +} diff --git a/crates/basal-rig/tests/stores.rs b/crates/basal-rig/tests/stores.rs index ce61dee..487c830 100644 --- a/crates/basal-rig/tests/stores.rs +++ b/crates/basal-rig/tests/stores.rs @@ -7,13 +7,19 @@ use serde_json::{Value, json}; struct Scratch(PathBuf); impl Scratch { fn new() -> Self { - let output = std::process::Command::new("mktemp") - .arg("-d") - .arg(std::env::temp_dir().join("basal-rig-store.XXXXXXXX")) - .output() - .expect("create isolated store directory"); - assert!(output.status.success(), "{output:?}"); - let dir = PathBuf::from(String::from_utf8(output.stdout).unwrap().trim()); + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let dir = loop { + let dir = std::env::temp_dir().join(format!( + "basal-rig-store-{}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + match std::fs::create_dir(&dir) { + Ok(()) => break dir, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("create isolated store directory: {error}"), + } + }; Self(dir.join("store.db")) } } diff --git a/crates/basal-testkit/Cargo.toml b/crates/basal-testkit/Cargo.toml index 355eb50..f8112a2 100644 --- a/crates/basal-testkit/Cargo.toml +++ b/crates/basal-testkit/Cargo.toml @@ -19,6 +19,15 @@ libc.workspace = true rcgen.workspace = true rustls.workspace = true +[target.'cfg(windows)'.dependencies] +basal-launch.workspace = true +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_ProcessStatus", + "Win32_System_Threading", +] } + [build-dependencies] serde_json.workspace = true diff --git a/crates/basal-testkit/src/bin/basal-test-parent.rs b/crates/basal-testkit/src/bin/basal-test-parent.rs index 44826df..fb5371f 100644 --- a/crates/basal-testkit/src/bin/basal-test-parent.rs +++ b/crates/basal-testkit/src/bin/basal-test-parent.rs @@ -1,5 +1,5 @@ //! A test parent process: basal-core's store and driver, real workers, and -//! the mock host, in one process the kill harness can `kill -9`. +//! the mock host, in one process the kill harness can terminate abruptly. //! //! ```text //! basal-test-parent --dir --worker @@ -13,7 +13,7 @@ //! `/mock.json`, synced before every reply, so effects survive the //! kill like a remote system's would. //! -//! With `--kill-at`, the process sends itself SIGKILL the moment it passes +//! With `--kill-at`, the process terminates without cleanup the moment it passes //! that boundary. Otherwise it prints one JSON line: the run's summary and //! every boundary it passed. @@ -69,6 +69,14 @@ fn run(args: Args) -> Result { let probe = Arc::new(match args.kill_at { Some(point) => Probe::act_at(point, |_, boundary| { eprintln!("basal-test-parent: killed at {boundary:?}"); + #[cfg(windows)] + unsafe { + // SAFETY: the pseudo-handle names this process; termination + // deliberately bypasses destructors to simulate a crash. + use windows_sys::Win32::System::Threading::{GetCurrentProcess, TerminateProcess}; + TerminateProcess(GetCurrentProcess(), basal_launch::KILL_EXIT_CODE); + } + #[cfg(unix)] // SAFETY: kill(2) on our own pid has no memory-safety // preconditions. unsafe { @@ -123,6 +131,21 @@ fn run(args: Args) -> Result { } fn main() -> ExitCode { + if let Some(calls) = std::env::var_os("BASAL_DISCOVERY_CALLS") { + // A native sentinel for the discovery test: if runtime discovery ever + // invokes its CARGO override, record the invocation before refusing it. + use std::io::Write; + writeln!( + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(calls) + .expect("sentinel log"), + "build" + ) + .expect("record invocation"); + return ExitCode::FAILURE; + } let args = match parse() { Ok(a) => a, Err(e) => { diff --git a/crates/basal-testkit/src/bin/worker-bench.rs b/crates/basal-testkit/src/bin/worker-bench.rs index 195f980..22b6e51 100644 --- a/crates/basal-testkit/src/bin/worker-bench.rs +++ b/crates/basal-testkit/src/bin/worker-bench.rs @@ -16,8 +16,8 @@ //! Every sample is kept in the output; summaries are the median and the //! nearest-rank 95th percentile. +use basal_testkit::command::Command; use std::path::{Path, PathBuf}; -use std::process::Command; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::thread; diff --git a/crates/basal-testkit/src/binaries.rs b/crates/basal-testkit/src/binaries.rs index b0c0749..08d1d98 100644 --- a/crates/basal-testkit/src/binaries.rs +++ b/crates/basal-testkit/src/binaries.rs @@ -208,14 +208,21 @@ pub fn dev_binary(binary: impl AsRef) -> PathBuf { static COPIES: OnceLock>> = OnceLock::new(); let mut copies = COPIES.get_or_init(Mutex::default).lock().unwrap(); let placed = copies.entry(binary.to_path_buf()).or_insert_with(|| { - let worker = binary.with_file_name("ck-basal-worker"); - let module = binary.with_file_name("ck-basal"); + let suffix = if cfg!(windows) { ".exe" } else { "" }; + let worker_name = format!("ck-basal-worker{suffix}"); + let module_name = format!("ck-basal{suffix}"); + let worker = binary.with_file_name(&worker_name); + let module = binary.with_file_name(&module_name); let mut sources = vec![binary]; - if binary.file_name().is_some_and(|name| name == "ck-basal") && worker.is_file() { + if binary + .file_name() + .is_some_and(|name| name == module_name.as_str()) + && worker.is_file() + { sources.push(&worker); } else if binary .file_name() - .is_some_and(|name| name == "ck-basal-worker") + .is_some_and(|name| name == worker_name.as_str()) && module.is_file() { sources.insert(0, &module); @@ -225,6 +232,26 @@ pub fn dev_binary(binary: impl AsRef) -> PathBuf { placed.path(binary).expect("development executable path") } +/// Places a test worker and makes that directory launchable by the Windows +/// AppContainer. Production installation never uses this test-only ACL grant. +pub(crate) fn dev_worker_binary(binary: impl AsRef) -> PathBuf { + let binary = dev_binary(binary); + #[cfg(windows)] + { + use std::collections::HashSet; + static GRANTED: OnceLock>> = OnceLock::new(); + let directory = binary.parent().expect("worker directory").to_path_buf(); + let mut granted = GRANTED.get_or_init(Mutex::default).lock().unwrap(); + if !granted.contains(&directory) { + let package = basal_launch::create_or_open_profile().expect("test worker package SID"); + basal_launch::grant_test_binary_directory(&binary, &package) + .expect("test worker directory ACL"); + granted.insert(directory); + } + } + binary +} + #[cfg(test)] mod tests { use super::*; @@ -258,6 +285,7 @@ mod tests { #[test] fn module_and_worker_share_a_content_addressed_development_directory() { + #[cfg(unix)] use std::os::unix::fs::PermissionsExt; let source = SourceFiles::new(); @@ -270,6 +298,7 @@ mod tests { assert_eq!(path.file_name().unwrap(), name); assert_eq!(fs::read(&path).unwrap(), fs::read(original).unwrap()); assert_eq!(path.parent(), Some(placed.directory.as_path())); + #[cfg(unix)] assert_eq!( fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o755 @@ -279,6 +308,7 @@ mod tests { #[test] fn repeated_placement_reuses_the_same_file_and_inode() { + #[cfg(unix)] use std::os::unix::fs::MetadataExt; let source = SourceFiles::new(); @@ -286,10 +316,33 @@ mod tests { let first = DevBinaries::new(&[&binary]).unwrap().path(&binary).unwrap(); let second = DevBinaries::new(&[&binary]).unwrap().path(&binary).unwrap(); assert_eq!(first, second); + #[cfg(unix)] assert_eq!( fs::metadata(first).unwrap().ino(), fs::metadata(second).unwrap().ino() ); + #[cfg(windows)] + assert_eq!(file_identity(&first), file_identity(&second)); + } + + #[cfg(windows)] + fn file_identity(path: &Path) -> (u32, u32, u32) { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + let file = File::open(path).unwrap(); + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + // SAFETY: file owns the handle and info is writable for the call. + assert_ne!( + unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) }, + 0 + ); + ( + info.dwVolumeSerialNumber, + info.nFileIndexHigh, + info.nFileIndexLow, + ) } #[test] diff --git a/crates/basal-testkit/src/channel.rs b/crates/basal-testkit/src/channel.rs index e182760..e03a3c7 100644 --- a/crates/basal-testkit/src/channel.rs +++ b/crates/basal-testkit/src/channel.rs @@ -155,9 +155,9 @@ pub fn worker_binary() -> PathBuf { static PATH: OnceLock = OnceLock::new(); PATH.get_or_init(|| { if let Some(p) = std::env::var_os("BASAL_WORKER_BIN") { - return crate::dev_binary(PathBuf::from(p)); + return crate::binaries::dev_worker_binary(PathBuf::from(p)); } - crate::dev_binary(env!("BASAL_TEST_WORKER_BIN")) + crate::binaries::dev_worker_binary(env!("BASAL_TEST_WORKER_BIN")) }) .clone() } diff --git a/crates/basal-testkit/src/command.rs b/crates/basal-testkit/src/command.rs new file mode 100644 index 0000000..6f44f42 --- /dev/null +++ b/crates/basal-testkit/src/command.rs @@ -0,0 +1,153 @@ +//! Commands used by test parents, with an explicit environment on Windows. +//! +//! Keeping the environment here avoids trying to recover `env_clear` from a +//! standard Command, which does not expose whether inheritance was disabled. + +#[cfg(unix)] +pub use std::process::Command; + +#[cfg(windows)] +mod windows { + use std::collections::BTreeMap; + use std::ffi::{OsStr, OsString}; + use std::io; + use std::path::{Path, PathBuf}; + use std::process::{ExitStatus, Output}; + use std::time::Duration; + + pub struct Command { + program: OsString, + args: Vec, + env: BTreeMap, + cwd: Option, + } + + impl Command { + pub fn new(program: impl AsRef) -> Self { + Self { + program: program.as_ref().into(), + args: vec![], + env: std::env::vars_os().collect(), + cwd: None, + } + } + + pub fn arg(&mut self, arg: impl AsRef) -> &mut Self { + self.args.push(arg.as_ref().into()); + self + } + + pub fn args(&mut self, args: I) -> &mut Self + where + I: IntoIterator, + S: AsRef, + { + for arg in args { + self.arg(arg); + } + self + } + + pub fn env(&mut self, key: impl AsRef, value: impl AsRef) -> &mut Self { + self.env_remove(key.as_ref()); + self.env.insert(key.as_ref().into(), value.as_ref().into()); + self + } + + pub fn envs(&mut self, env: I) -> &mut Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + for (key, value) in env { + self.env(key, value); + } + self + } + + pub fn env_clear(&mut self) -> &mut Self { + self.env.clear(); + self + } + + pub fn env_remove(&mut self, key: impl AsRef) -> &mut Self { + // Windows environment keys are case-insensitive, unlike OsString's ordering. + let key = key.as_ref().to_string_lossy(); + self.env + .retain(|name, _| !name.to_string_lossy().eq_ignore_ascii_case(&key)); + self + } + + pub fn current_dir(&mut self, path: impl AsRef) -> &mut Self { + self.cwd = Some(path.as_ref().into()); + self + } + + pub fn output(&mut self) -> io::Result { + self.output_until(Duration::from_secs(950)) + } + pub fn status(&mut self) -> io::Result { + self.output().map(|output| output.status) + } + + pub fn output_until(&self, timeout: Duration) -> io::Result { + use basal_launch::{PlainCommand, PlainStdio}; + let program = resolve_program(&self.program, &self.env, self.cwd.as_deref())?; + let command = PlainCommand { + program, + args: self.args.clone(), + env: self + .env + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + cwd: self.cwd.clone(), + stdin: PlainStdio::Null, + stdout: PlainStdio::Piped, + stderr: PlainStdio::Piped, + }; + super::super::process::windows::output_until(&command, timeout) + } + } + + fn resolve_program( + program: &OsStr, + env: &BTreeMap, + cwd: Option<&Path>, + ) -> io::Result { + let path = Path::new(program); + if path.is_absolute() { + return Ok(path.into()); + } + if path.components().count() > 1 { + return Ok(cwd + .map(PathBuf::from) + .unwrap_or(std::env::current_dir()?) + .join(path)); + } + let search = env + .iter() + .find(|(key, _)| key.to_string_lossy().eq_ignore_ascii_case("PATH")) + .map(|(_, value)| value) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "command PATH is absent"))?; + for directory in std::env::split_paths(search) { + let candidate = directory.join(path); + let candidate = if candidate.extension().is_none() { + candidate.with_extension("exe") + } else { + candidate + }; + if candidate.is_file() { + return Ok(candidate); + } + } + Err(io::Error::new( + io::ErrorKind::NotFound, + format!("executable {} not found", path.display()), + )) + } +} + +#[cfg(windows)] +pub use windows::Command; diff --git a/crates/basal-testkit/src/git.rs b/crates/basal-testkit/src/git.rs index c668985..2f726e0 100644 --- a/crates/basal-testkit/src/git.rs +++ b/crates/basal-testkit/src/git.rs @@ -1,5 +1,5 @@ +use crate::command::Command; use std::ffi::{OsStr, OsString}; -use std::process::Command; /// Starts git without inheriting caller configuration or environment. /// @@ -19,14 +19,19 @@ fn git_command_with_env(base_env: impl IntoIterator // would reach git, and the isolation test would see the difference. command.envs(base_env.iter().cloned()); command.env_clear(); - for key in [OsStr::new("PATH"), OsStr::new("HOME")] { + for key in [ + OsStr::new("PATH"), + OsStr::new("HOME"), + OsStr::new("SystemRoot"), + OsStr::new("USERPROFILE"), + ] { if let Some((_, value)) = base_env.iter().rev().find(|(name, _)| name == key) { command.env(key, value); } } command .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_GLOBAL", empty_global_config()) .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_AUTHOR_NAME", "Basal Test") .env("GIT_AUTHOR_EMAIL", "basal-test@example.com") @@ -35,7 +40,17 @@ fn git_command_with_env(base_env: impl IntoIterator command } -#[cfg(test)] +fn empty_global_config() -> &'static std::path::Path { + static CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + CONFIG.get_or_init(|| { + let dir = crate::harness::scratch("empty-git-config"); + let path = dir.join("global.gitconfig"); + std::fs::write(&path, []).expect("empty git global config"); + path + }) +} + +#[cfg(all(test, unix))] mod tests { use std::os::unix::fs::PermissionsExt; diff --git a/crates/basal-testkit/src/https.rs b/crates/basal-testkit/src/https.rs index e018f2b..ac4c328 100644 --- a/crates/basal-testkit/src/https.rs +++ b/crates/basal-testkit/src/https.rs @@ -99,6 +99,7 @@ pub struct TestServer { stop: Arc, thread: Option>, #[cfg(test)] + #[cfg(unix)] blocking_accept: bool, } @@ -121,7 +122,7 @@ impl TestServer { .expect("server certificate"); let config = Arc::new(config); let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); - #[cfg(test)] + #[cfg(all(test, unix))] let blocking_accept = { use std::os::fd::AsRawFd; // SAFETY: listener owns this live socket; F_GETFL only reads flags. @@ -166,6 +167,7 @@ impl TestServer { stop, thread: Some(thread), #[cfg(test)] + #[cfg(unix)] blocking_accept, } } @@ -299,6 +301,7 @@ mod tests { let server = TestServer::start(|_| BTreeMap::new()); let connections = server.connections.clone(); let seen = server.seen.clone(); + #[cfg(unix)] assert!( server.blocking_accept, "the accept loop must block instead of polling" diff --git a/crates/basal-testkit/src/lib.rs b/crates/basal-testkit/src/lib.rs index 078e648..b695f5c 100644 --- a/crates/basal-testkit/src/lib.rs +++ b/crates/basal-testkit/src/lib.rs @@ -10,6 +10,7 @@ mod backoff; pub mod binaries; pub mod channel; +pub mod command; pub mod fuzz; pub mod git; pub mod harness; diff --git a/crates/basal-testkit/src/process.rs b/crates/basal-testkit/src/process.rs index 0ee4dff..8b6bb3a 100644 --- a/crates/basal-testkit/src/process.rs +++ b/crates/basal-testkit/src/process.rs @@ -1,9 +1,12 @@ //! A real `ck-basal-worker` child process, driven over its stdio frames. +use crate::command::Command; use std::fmt; use std::io::{self, Read, Write}; use std::path::Path; -use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; +use std::process::ExitStatus; +#[cfg(unix)] +use std::process::{Child, ChildStdin, Stdio}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; use std::sync::{Arc, Mutex}; use std::thread; @@ -44,6 +47,15 @@ impl std::error::Error for ParentError {} type Incoming = Result; +#[cfg(windows)] +pub(crate) mod windows; +#[cfg(windows)] +use windows::Child; +#[cfg(windows)] +pub use windows::terminate_process; +#[cfg(windows)] +type ChildStdin = std::fs::File; + /// A spawned worker. Killed on drop. pub struct WorkerProcess { child: Child, @@ -62,19 +74,24 @@ impl WorkerProcess { } pub fn spawn_with_args(binary: &Path, args: &[&str]) -> io::Result { - let binary = crate::dev_binary(binary); - let mut command = Command::new(binary); - command.args(args); - #[cfg(target_os = "linux")] - if !args.iter().any(|arg| arg.starts_with("--landlock=")) { - command.arg("--landlock=required"); - } - let mut child = command - .env_clear() - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; + let binary = crate::binaries::dev_worker_binary(binary); + #[cfg(windows)] + let mut child = windows::spawn_worker(&binary, args)?; + #[cfg(unix)] + let mut child = { + let mut command = Command::new(binary); + command.args(args); + #[cfg(target_os = "linux")] + if !args.iter().any(|arg| arg.starts_with("--landlock=")) { + command.arg("--landlock=required"); + } + command + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()? + }; let stdin = child.stdin.take(); let mut stdout = child .stdout @@ -251,7 +268,11 @@ pub fn rss_kib(pid: u32) -> Option { } Some(unsafe { info.assume_init() }.pti_resident_size / 1024) } - #[cfg(not(target_os = "macos"))] + #[cfg(windows)] + { + windows::rss_kib(pid) + } + #[cfg(all(unix, not(target_os = "macos")))] { let out = Command::new("ps") .args(["-o", "rss=", "-p", &pid.to_string()]) @@ -270,25 +291,31 @@ impl Drop for WorkerProcess { /// Probe the operating system without waiting for an exit. A successful /// process-exit wait can hide a missing reap in the cleanup path. pub fn assert_reaped(pid: u32) { - let mut status = 0; - // SAFETY: waitpid writes only to this status and the caller's child pid. - let result = unsafe { - libc::waitpid( - pid.try_into().expect("child pid"), - &mut status, - libc::WNOHANG, - ) - }; - assert_eq!(result, -1, "worker {pid} was not reaped"); - assert_eq!( - std::io::Error::last_os_error().raw_os_error(), - Some(libc::ECHILD) - ); + #[cfg(windows)] + windows::assert_reaped(pid); + #[cfg(unix)] + { + let mut status = 0; + // SAFETY: waitpid writes only to this status and the caller's child pid. + let result = unsafe { + libc::waitpid( + pid.try_into().expect("child pid"), + &mut status, + libc::WNOHANG, + ) + }; + assert_eq!(result, -1, "worker {pid} was not reaped"); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ECHILD) + ); + } } /// Capture a subprocess with a deadline, draining both pipes concurrently. /// Timeout kills the process group, so inherited pipes cannot keep a reader /// alive after the parent has been reaped. +#[cfg(unix)] pub fn output_until(command: &mut Command, timeout: Duration) -> io::Result { use std::os::unix::process::CommandExt; // SAFETY: setpgid is async-signal-safe and touches no Rust-owned memory. @@ -351,9 +378,15 @@ pub fn output_until(command: &mut Command, timeout: Duration) -> io::Result io::Result { + command.output_until(timeout) +} + #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] #[test] fn subprocess_deadline_reaps_descendants_holding_output_pipes() { let error = output_until( diff --git a/crates/basal-testkit/src/process/windows.rs b/crates/basal-testkit/src/process/windows.rs new file mode 100644 index 0000000..33fafa7 --- /dev/null +++ b/crates/basal-testkit/src/process/windows.rs @@ -0,0 +1,204 @@ +//! Native worker ownership and process observations for the Windows test parent. + +use basal_launch::{ConfinedProcess, LaunchOptions}; +use std::collections::HashMap; +use std::fs::File; +use std::io; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::os::windows::process::ExitStatusExt; +use std::path::Path; +use std::process::ExitStatus; +use std::sync::{Arc, Mutex, OnceLock, Weak}; +use windows_sys::Win32::Foundation::WAIT_OBJECT_0; +use windows_sys::Win32::System::ProcessStatus::{K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS}; +use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_TERMINATE, PROCESS_VM_READ, TerminateProcess, + WaitForSingleObject, +}; + +struct ReapState { + process: Weak, + signaled: bool, +} + +fn processes() -> &'static Mutex> { + static PROCESSES: OnceLock>> = OnceLock::new(); + PROCESSES.get_or_init(Mutex::default) +} + +pub(super) struct Child { + process: Option>, + pid: u32, + status: Option, + pub stdin: Option, + pub stdout: Option, + pub stderr: Option, +} + +pub(super) fn spawn_worker(binary: &Path, args: &[&str]) -> io::Result { + let mut options = LaunchOptions::new(binary, basal_proto::limits::FLOW_JOB_COMMIT_BYTES); + options.args = args.iter().map(|arg| (*arg).into()).collect(); + let mut process = basal_launch::launch(&options).map_err(io::Error::other)?; + let stdin = process.stdin.take(); + let stdout = process.stdout.take(); + let stderr = process.stderr.take(); + let process = Arc::new(process); + let pid = process.id(); + processes().lock().unwrap().insert( + pid, + ReapState { + process: Arc::downgrade(&process), + signaled: false, + }, + ); + Ok(Child { + process: Some(process), + pid, + status: None, + stdin, + stdout, + stderr, + }) +} + +impl Child { + pub fn id(&self) -> u32 { + self.pid + } + + pub fn kill(&self) -> io::Result<()> { + self.process + .as_ref() + .map_or(Ok(()), |process| process.kill()) + } + + fn release(&mut self, code: u32) -> ExitStatus { + let process = self.process.take().expect("owned process before release"); + // Observe the real handle before releasing it, rather than treating a + // cached exit code or the disappearance of a pid as evidence of exit. + let signaled = unsafe { WaitForSingleObject(process.as_raw_handle(), 0) } == WAIT_OBJECT_0; + processes() + .lock() + .unwrap() + .get_mut(&self.pid) + .unwrap() + .signaled = signaled; + drop(process); + let status = ExitStatus::from_raw(code); + self.status = Some(status); + status + } + + pub fn try_wait(&mut self) -> io::Result> { + if let Some(status) = self.status { + return Ok(Some(status)); + } + let code = self.process.as_ref().unwrap().try_wait()?; + Ok(code.map(|code| self.release(code))) + } + + pub fn wait(&mut self) -> io::Result { + if let Some(status) = self.status { + return Ok(status); + } + let code = self.process.as_ref().unwrap().wait()?; + Ok(self.release(code)) + } +} + +pub(super) fn assert_reaped(pid: u32) { + let state = processes() + .lock() + .unwrap() + .get(&pid) + .map(|state| (state.process.clone(), state.signaled)); + let (process, signaled) = state.expect("process was launched by the test kit"); + assert!(signaled, "worker {pid}'s process handle was not signaled"); + assert!( + process.upgrade().is_none(), + "worker {pid}'s process handle is still owned" + ); +} + +pub(super) fn rss_kib(pid: u32) -> Option { + // SAFETY: OpenProcess returns a new owned handle, or NULL on failure. + let handle = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid) }; + if handle.is_null() { + return None; + } + let handle = unsafe { OwnedHandle::from_raw_handle(handle) }; + let mut counters: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of_val(&counters) as u32; + counters.cb = size; + // SAFETY: counters is writable for exactly the declared structure size. + if unsafe { K32GetProcessMemoryInfo(handle.as_raw_handle(), &mut counters, size) } == 0 { + return None; + } + Some(counters.WorkingSetSize as u64 / 1024) +} + +pub fn terminate_process(pid: u32) -> io::Result<()> { + // SAFETY: OpenProcess returns a fresh owned handle, or NULL on failure. + let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) }; + if handle.is_null() { + return Err(io::Error::last_os_error()); + } + let handle = unsafe { OwnedHandle::from_raw_handle(handle) }; + if unsafe { TerminateProcess(handle.as_raw_handle(), basal_launch::KILL_EXIT_CODE) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +pub(crate) fn output_until( + command: &basal_launch::PlainCommand, + timeout: std::time::Duration, +) -> io::Result { + use std::io::Read; + use std::time::{Duration, Instant}; + let mut child = basal_launch::spawn_plain(command).map_err(io::Error::other)?; + let mut stdout = child.stdout.take().expect("stdout pipe"); + let mut stderr = child.stderr.take().expect("stderr pipe"); + let out = std::thread::spawn(move || { + let mut bytes = Vec::new(); + stdout.read_to_end(&mut bytes).map(|_| bytes) + }); + let err = std::thread::spawn(move || { + let mut bytes = Vec::new(); + stderr.read_to_end(&mut bytes).map(|_| bytes) + }); + let deadline = Instant::now() + timeout; + let mut timed_out = false; + let status = loop { + match child.try_wait() { + Ok(Some(code)) => break Ok(ExitStatus::from_raw(code)), + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)), + result => { + timed_out = result.is_ok(); + child.kill()?; + break child.wait().map(ExitStatus::from_raw); + } + } + }; + // Even a normally exited parent can leave a descendant holding a pipe. + // End the entire job before joining readers, never only the root process. + child.kill()?; + drop(child); + let stdout = out + .join() + .map_err(|_| io::Error::other("stdout reader panicked"))??; + let stderr = err + .join() + .map_err(|_| io::Error::other("stderr reader panicked"))??; + if timed_out { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "subprocess deadline expired; job killed and process released", + )); + } + Ok(std::process::Output { + status: status?, + stdout, + stderr, + }) +} diff --git a/crates/basal-testkit/tests/bench_args.rs b/crates/basal-testkit/tests/bench_args.rs index b6657d6..70d214c 100644 --- a/crates/basal-testkit/tests/bench_args.rs +++ b/crates/basal-testkit/tests/bench_args.rs @@ -1,4 +1,4 @@ -use std::process::Command; +use basal_testkit::command::Command; #[test] fn worker_benchmark_refuses_invalid_arguments_before_spawning() { diff --git a/crates/basal-testkit/tests/builtins_fs.rs b/crates/basal-testkit/tests/builtins_fs.rs index 7755d74..15ceec8 100644 --- a/crates/basal-testkit/tests/builtins_fs.rs +++ b/crates/basal-testkit/tests/builtins_fs.rs @@ -5,10 +5,13 @@ //! reads refuse text that is not UTF-8 or is over the cap; writes replace //! the whole file and never through a symlink. +#[cfg(unix)] use std::os::unix::fs::symlink; use std::path::PathBuf; -use basal_host::builtins::fs::{self, Purpose, Target}; +use basal_host::builtins::fs; +#[cfg(unix)] +use basal_host::builtins::fs::{Purpose, Target}; use basal_host::builtins::{BuiltinHost, Denial, Failure, Grant, codes, envelope}; use basal_proto::Primitive; use basal_testkit::harness::scratch; @@ -44,7 +47,16 @@ impl Tree { } fn p(&self, rel: &str) -> String { - self.root.join(rel).display().to_string() + let path = if rel.is_empty() { + self.root.clone() + } else { + self.root.join(rel) + }; + if cfg!(windows) { + path.display().to_string().replace('/', "\\") + } else { + path.display().to_string() + } } } @@ -69,14 +81,25 @@ fn reads_inside_a_root_and_refuses_a_dotdot_escape() { fs::read(&t.p("a.txt"), &roots, 1024).expect("read"), json!({ "text": "inside" }) ); + #[cfg(unix)] assert_eq!( fs::read(&t.p("sub/../a.txt"), &roots, 1024).expect("read"), json!({ "text": "inside" }) ); let escape = t.p("../outside/secret.txt"); - assert_eq!(code(fs::read(&escape, &roots, 1024)), codes::DENIED); - assert_eq!(code(fs::stat(&escape, &roots)), codes::DENIED); - assert_eq!(code(fs::list(&t.p(".."), &roots)), codes::DENIED); + let escape_code = if cfg!(windows) { + codes::INVALID_ARGUMENTS + } else { + codes::DENIED + }; + #[cfg(windows)] + assert_eq!( + code(fs::read(&t.p("sub/../a.txt"), &roots, 1024)), + codes::INVALID_ARGUMENTS + ); + assert_eq!(code(fs::read(&escape, &roots, 1024)), escape_code); + assert_eq!(code(fs::stat(&escape, &roots)), escape_code); + assert_eq!(code(fs::list(&t.p(".."), &roots)), escape_code); // A sibling whose name only starts with the root's is not under it. let sibling = t.base.join("rootx"); std::fs::create_dir_all(&sibling).expect("sibling"); @@ -97,6 +120,7 @@ fn reads_inside_a_root_and_refuses_a_dotdot_escape() { } #[test] +#[cfg(unix)] fn a_symlink_resolving_outside_the_root_is_refused() { let t = Tree::new("fs-symlink"); let roots = t.roots(); @@ -154,7 +178,9 @@ fn stat_of_a_missing_path_is_exists_false_only_when_its_parent_is_in_scope() { fn list_names_entries_and_their_kinds() { let t = Tree::new("fs-list"); let roots = t.roots(); + #[cfg(unix)] symlink(t.root.join("a.txt"), t.root.join("link")).expect("link"); + #[cfg(unix)] assert_eq!( fs::list(&t.p(""), &roots).expect("list"), json!([ @@ -163,6 +189,13 @@ fn list_names_entries_and_their_kinds() { { "name": "sub", "kind": "dir" }, ]) ); + #[cfg(windows)] + assert_eq!( + fs::list(&t.p(""), &roots).expect("list"), + json!([ + { "name": "a.txt", "kind": "file" }, { "name": "sub", "kind": "dir" } + ]) + ); for i in 0..=fs::MAX_LIST_ENTRIES { std::fs::write(t.root.join(format!("sub/f{i}")), "").expect("file"); } @@ -225,21 +258,37 @@ fn write_replaces_the_whole_file_inside_a_write_root_only() { .collect(); assert!(names.iter().all(|n| !n.contains(".basal-")), "{names:?}"); - // Outside the root, through `..`, through a directory symlink, or onto - // a symlink: refused, and nothing outside changes. - symlink(&t.outside, t.root.join("out-dir")).expect("dir link"); - symlink(t.outside.join("secret.txt"), t.root.join("out-link")).expect("link"); - for path in [ - t.p("../outside/new.txt"), - t.p("out-dir/new.txt"), - t.p("out-link"), - ] { - assert_eq!( - code(fs::write(&path, &roots, "pwned")), - codes::DENIED, - "{path}" - ); + // Raw relative components are refused on Windows before resolution. + #[cfg(windows)] + assert_eq!( + code(fs::write(&t.p("../outside/new.txt"), &roots, "pwned")), + codes::INVALID_ARGUMENTS + ); + // Unix permits symlink creation without an elevated test account. + #[cfg(unix)] + { + symlink(&t.outside, t.root.join("out-dir")).expect("dir link"); + symlink(t.outside.join("secret.txt"), t.root.join("out-link")).expect("link"); + for path in [ + t.p("../outside/new.txt"), + t.p("out-dir/new.txt"), + t.p("out-link"), + ] { + assert_eq!( + code(fs::write(&path, &roots, "pwned")), + codes::DENIED, + "{path}" + ); + } } + assert_eq!( + code(fs::write( + t.outside.join("new.txt").to_str().unwrap(), + &roots, + "pwned" + )), + codes::DENIED + ); assert_eq!( std::fs::read_to_string(t.outside.join("secret.txt")).expect("secret"), "secret" @@ -259,6 +308,7 @@ fn write_replaces_the_whole_file_inside_a_write_root_only() { /// component is then swapped for a symlink to a file outside. The open /// refuses to follow it. #[test] +#[cfg(unix)] fn a_symlink_swapped_into_the_last_component_after_the_check_is_not_followed() { let t = Tree::new("fs-swap-last"); let roots = t.roots(); @@ -278,6 +328,7 @@ fn a_symlink_swapped_into_the_last_component_after_the_check_is_not_followed() { /// directory outside after the check. The open succeeds, but the opened /// file's real path lies outside the root, so it is refused. #[test] +#[cfg(unix)] fn a_directory_swapped_for_a_symlink_after_the_check_is_caught_after_the_open() { let t = Tree::new("fs-swap-dir"); let roots = t.roots(); diff --git a/crates/basal-testkit/tests/builtins_git.rs b/crates/basal-testkit/tests/builtins_git.rs index 712cd8e..78341a4 100644 --- a/crates/basal-testkit/tests/builtins_git.rs +++ b/crates/basal-testkit/tests/builtins_git.rs @@ -2,24 +2,34 @@ //! the answers, the approved-repository check, argument checks, and that a //! repository's own configuration cannot make a built-in run a program. -use basal_host::builtins::git::{self, Op, hardened_command, run_command}; +use basal_host::builtins::git::{self, Op}; +#[cfg(unix)] +use basal_host::builtins::git::{hardened_command, run_command}; use basal_host::builtins::{Denial, codes}; use basal_proto::Primitive; use basal_testkit::git::git_command; use basal_testkit::harness::scratch; use serde_json::{Value, json}; +#[cfg(unix)] use std::os::unix::process::ExitStatusExt; use std::path::{Path, PathBuf}; fn child_exit(status: &std::process::ExitStatus) -> String { - match status.code() { - Some(code) => format!("exit code {code}"), - None => format!( - "terminated by signal {}", - status - .signal() - .map_or_else(|| "unknown".to_owned(), |signal| signal.to_string()) - ), + #[cfg(windows)] + { + status.to_string() + } + #[cfg(unix)] + { + match status.code() { + Some(code) => format!("exit code {code}"), + None => format!( + "terminated by signal {}", + status + .signal() + .map_or_else(|| "unknown".to_owned(), |signal| signal.to_string()) + ), + } } } @@ -201,6 +211,7 @@ fn arguments_that_git_could_read_as_options_are_refused() { } /// A script `name` in `dir` that appends its name to `marker` when run. +#[cfg(unix)] fn plant(dir: &Path, name: &str, marker: &Path) -> PathBuf { let path = dir.join(name); std::fs::write( @@ -220,6 +231,7 @@ fn plant(dir: &Path, name: &str, marker: &Path) -> PathBuf { /// while plain git in the same repository does run the fsmonitor program, /// which shows the plant works. #[test] +#[cfg(unix)] fn a_repository_config_cannot_make_a_built_in_run_a_program() { let repo = Repo::new("git-fsmonitor"); let marker = repo.base.join("marker"); diff --git a/crates/basal-testkit/tests/fs_write_temps.rs b/crates/basal-testkit/tests/fs_write_temps.rs index 8b0d3d9..35cf3df 100644 --- a/crates/basal-testkit/tests/fs_write_temps.rs +++ b/crates/basal-testkit/tests/fs_write_temps.rs @@ -11,6 +11,7 @@ mod common; +#[cfg(unix)] use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, mpsc}; @@ -170,7 +171,7 @@ impl Files { } fn out(&self) -> PathBuf { - self.root.join("sub/out.txt") + self.root.join("sub").join("out.txt") } fn secret(&self) -> PathBuf { @@ -323,11 +324,14 @@ fn a_cancelled_runs_write_that_dies_mid_flight_is_cleaned_up() { enum Tamper { /// Nothing: recovery removes it. None, + #[cfg(unix)] /// The file was replaced by a symlink to a file outside the root. SymlinkedTemp, + #[cfg(unix)] /// The directory was moved outside the root and a symlink to it put in /// its place. DirectoryMovedOutside, + #[cfg(unix)] /// The directory was moved elsewhere inside the root and a symlink to it /// put in its place. DirectoryMovedWithin, @@ -369,8 +373,11 @@ fn crash_mid_write(world: &World, files: &Files, manifest: &Value) -> (String, T fn a_crash_mid_write_is_cleaned_up_by_recovery_without_following_a_swapped_path() { for tamper in [ Tamper::None, + #[cfg(unix)] Tamper::SymlinkedTemp, + #[cfg(unix)] Tamper::DirectoryMovedOutside, + #[cfg(unix)] Tamper::DirectoryMovedWithin, ] { let world = World::new("fs-temps-crash"); @@ -382,17 +389,20 @@ fn a_crash_mid_write_is_cleaned_up_by_recovery_without_following_a_swapped_path( // Where the recorded file is after tampering, if it is anywhere. let left = match tamper { Tamper::None => None, + #[cfg(unix)] Tamper::SymlinkedTemp => { std::fs::remove_file(&temp).expect("remove"); symlink(files.secret(), &temp).expect("symlink"); Some(temp.clone()) } + #[cfg(unix)] Tamper::DirectoryMovedOutside => { let moved = files.outside.join("sub"); std::fs::rename(&sub, &moved).expect("move out"); symlink(&moved, &sub).expect("symlink"); Some(moved.join(&lease.temp)) } + #[cfg(unix)] Tamper::DirectoryMovedWithin => { let moved = files.root.join("moved"); std::fs::rename(&sub, &moved).expect("move within"); @@ -411,7 +421,10 @@ fn a_crash_mid_write_is_cleaned_up_by_recovery_without_following_a_swapped_path( Some(left) => { let meta = std::fs::symlink_metadata(&left) .unwrap_or_else(|e| panic!("{tamper:?}: {left:?} was removed: {e}")); + #[cfg(unix)] assert_eq!(meta.is_symlink(), tamper == Tamper::SymlinkedTemp); + #[cfg(windows)] + assert!(!meta.is_symlink()); } } assert_eq!( @@ -437,6 +450,7 @@ fn a_crash_mid_write_is_cleaned_up_by_recovery_without_following_a_swapped_path( /// directory still exists under its recorded path, but no longer lies in /// the root the write was granted, so its file is not removed. #[test] +#[cfg(unix)] fn a_crash_record_whose_directory_left_its_root_is_not_acted_on() { let world = World::new("fs-temps-retarget"); let files = Files::new("fs-temps-retarget-files"); @@ -495,6 +509,7 @@ fn legacy_temporary_files_are_removed_once_from_written_directories() { for name in keep { std::fs::write(sub.join(name), "keep").expect("decoy"); } + #[cfg(unix)] symlink(files.secret(), sub.join(".basal-55-6.tmp")).expect("symlink"); std::fs::write(files.root.join(".basal-7-8.tmp"), "unwritten dir").expect("root stray"); std::fs::write(files.outside.join(".basal-9-9.tmp"), "outside").expect("outside"); @@ -507,9 +522,11 @@ fn legacy_temporary_files_are_removed_once_from_written_directories() { assert!(!sub.join(".basal-123-4.tmp").exists()); let mut expected: Vec = keep.iter().map(|n| (*n).to_owned()).collect(); + #[cfg(unix)] expected.push(".basal-55-6.tmp".to_owned()); expected.sort(); assert_eq!(temps_in(&sub), expected); + #[cfg(unix)] assert!( std::fs::symlink_metadata(sub.join(".basal-55-6.tmp")) .expect("symlink kept") @@ -540,6 +557,13 @@ fn remove_temp_removes_only_a_recorded_regular_file() { let files = Files::new("fs-temps-remove"); let sub = files.root.join("sub"); let dir = std::fs::canonicalize(&sub).expect("real dir"); + #[cfg(windows)] + let dir = PathBuf::from( + dir.to_str() + .unwrap() + .strip_prefix(r"\\?\") + .unwrap_or(dir.to_str().unwrap()), + ); let roots = vec![files.root.display().to_string()]; let lease = |temp: &str| TempLease { call_key: "k".into(), diff --git a/crates/basal-testkit/tests/journal_kill.rs b/crates/basal-testkit/tests/journal_kill.rs index 310449a..819af56 100644 --- a/crates/basal-testkit/tests/journal_kill.rs +++ b/crates/basal-testkit/tests/journal_kill.rs @@ -2,9 +2,10 @@ //! representative run, and separately of the worker. Every killed run must //! recover to the uncut run's final state and effect counts. +use basal_testkit::command::Command; +#[cfg(unix)] use std::os::unix::process::ExitStatusExt; use std::path::Path; -use std::process::Command; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; @@ -27,7 +28,10 @@ fn parent(dir: &Path, kill_at: Option<&Point>) -> (bool, Option, String) } let out = basal_testkit::process::output_until(&mut cmd, std::time::Duration::from_secs(950)) .expect("test parent must finish within its cleanup deadline"); + #[cfg(unix)] let killed = out.status.signal() == Some(libc::SIGKILL); + #[cfg(windows)] + let killed = out.status.code() == Some(basal_launch::KILL_EXIT_CODE as i32); let json = String::from_utf8_lossy(&out.stdout) .lines() .last() @@ -133,6 +137,10 @@ fn killing_the_worker_at_every_boundary_recovers_to_the_uncut_state() { let probe = Arc::new(Probe::act_at(point.clone(), move |_, _| { let pid = source.counts.live_pid.load(Ordering::SeqCst); if pid != 0 { + #[cfg(windows)] + basal_testkit::process::terminate_process(pid) + .expect("terminate live worker"); + #[cfg(unix)] // SAFETY: kill(2) has no memory-safety // preconditions; the pid is a worker this test // spawned and has not reaped. diff --git a/crates/basal-testkit/tests/windows_process.rs b/crates/basal-testkit/tests/windows_process.rs new file mode 100644 index 0000000..da5796e --- /dev/null +++ b/crates/basal-testkit/tests/windows_process.rs @@ -0,0 +1,162 @@ +#![cfg(windows)] + +use basal_testkit::command::Command; +use basal_testkit::harness::scratch; +use basal_testkit::process::{assert_reaped, output_until}; +use basal_testkit::{WorkerProcess, worker_binary}; +use std::path::Path; +use std::time::Duration; + +fn pipe_tree(dir: &Path, hold_parent: bool) -> Command { + let powershell = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()) + .join("System32") + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe"); + let pid_file = dir.join("descendant.pid"); + let quoted_path = pid_file.display().to_string().replace('\'', "''"); + // Start-Process without redirection inherits the parent's standard pipes. + // The child publishes its pid before waiting, so a missing job kill would + // leave both a live process and a pipe reader that cannot finish. + let script = format!( + "$p = Start-Process -PassThru -NoNewWindow -FilePath '{}' -ArgumentList '-NoProfile','-Command','\"[IO.File]::WriteAllText(''{}'', [string]$PID); Start-Sleep -Seconds 600\"'; while (!(Test-Path '{}')) {{ Start-Sleep -Milliseconds 10 }}; {}", + powershell.display(), + quoted_path, + quoted_path, + if hold_parent { + "Start-Sleep -Seconds 600" + } else { + "exit 0" + }, + ); + let mut command = Command::new(powershell); + command + .args(["-NoProfile", "-NonInteractive", "-Command"]) + .arg(script); + command +} + +fn assert_descendant_dead(dir: &Path) { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::Foundation::WAIT_OBJECT_0; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject, + }; + let pid: u32 = std::fs::read_to_string(dir.join("descendant.pid")) + .expect("descendant readiness") + .trim() + .parse() + .unwrap(); + // SYNCHRONIZE is a standard access right, not a process-specific flag. + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | 0x0010_0000, 0, pid) }; + if handle.is_null() { + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(87), + "only an exited, released process may disappear" + ); + return; + } + let handle = unsafe { OwnedHandle::from_raw_handle(handle) }; + assert_eq!( + unsafe { WaitForSingleObject(handle.as_raw_handle(), 30_000) }, + WAIT_OBJECT_0, + "descendant survived job cleanup" + ); +} + +#[test] +fn subprocess_deadline_reaps_descendants_holding_output_pipes() { + let dir = scratch("windows-pipe-deadline"); + let mut command = pipe_tree(&dir, true); + let error = output_until(&mut command, Duration::from_secs(10)).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); + assert_descendant_dead(&dir); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn subprocess_exit_reaps_descendants_holding_output_pipes() { + let dir = scratch("windows-pipe-exit"); + let output = output_until(&mut pipe_tree(&dir, false), Duration::from_secs(30)).unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_descendant_dead(&dir); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn worker_memory_sampling_and_reaping_observe_native_process_state() { + let (mut worker, _) = WorkerProcess::start(&worker_binary(), Duration::from_secs(60)).unwrap(); + let pid = worker.pid(); + assert!(worker.rss_kib().is_some_and(|kib| kib > 0)); + assert!( + std::panic::catch_unwind(|| assert_reaped(pid)).is_err(), + "a running worker is not reaped" + ); + worker.close_stdin(); + assert!(worker.wait_exit(Duration::from_secs(60)).unwrap().success()); + assert_reaped(pid); + drop(worker); + assert_reaped(pid); +} + +#[test] +fn discovered_worker_launches_with_full_confinement_without_an_extra_acl_grant() { + use basal_proto::{ + Confinement, PROTOCOL_VERSION, ParentMessage, WorkerMessage, encode_parent_frame, + read_worker_message, + }; + use std::io::Write; + let binary = worker_binary(); + assert_eq!(binary, basal_testkit::channel::worker_binary()); + assert!( + binary + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("ckdev-") + ); + let options = + basal_launch::LaunchOptions::new(&binary, basal_proto::limits::FLOW_JOB_COMMIT_BYTES); + let mut child = basal_launch::launch(&options).unwrap(); + child + .stdin + .as_mut() + .unwrap() + .write_all( + &encode_parent_frame(&ParentMessage::Hello { + protocol_version: PROTOCOL_VERSION, + }) + .unwrap(), + ) + .unwrap(); + let mut stdout = child.stdout.take().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || { + let _ = tx.send(read_worker_message(&mut stdout)); + }); + let frame = rx + .recv_timeout(Duration::from_secs(60)) + .expect("handshake before hang deadline") + .unwrap(); + let WorkerMessage::Welcome(welcome) = frame else { + panic!("expected welcome, got {frame:?}"); + }; + assert_eq!( + welcome.confinement, + Confinement::Windows { + lpac: true, + untrusted: true, + no_thread_token: true, + mitigations: true, + handle_table: true + } + ); + child.stdin.take(); + assert_eq!(child.wait().unwrap(), 0); + reader.join().unwrap(); +} diff --git a/crates/basal-testkit/tests/worker_discovery.rs b/crates/basal-testkit/tests/worker_discovery.rs index 53c3776..2175dc3 100644 --- a/crates/basal-testkit/tests/worker_discovery.rs +++ b/crates/basal-testkit/tests/worker_discovery.rs @@ -1,18 +1,27 @@ //! Cargo owns the build-time worker path; runtime discovery never invokes it. +use basal_testkit::command::Command; +#[cfg(unix)] use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] use std::os::unix::process::ExitStatusExt; -use std::process::Command; fn child_exit(status: &std::process::ExitStatus) -> String { - match status.code() { - Some(code) => format!("exit code {code}"), - None => format!( - "terminated by signal {}", - status - .signal() - .map_or_else(|| "unknown".to_owned(), |signal| signal.to_string()) - ), + #[cfg(windows)] + { + status.to_string() + } + #[cfg(unix)] + { + match status.code() { + Some(code) => format!("exit code {code}"), + None => format!( + "terminated by signal {}", + status + .signal() + .map_or_else(|| "unknown".to_owned(), |signal| signal.to_string()) + ), + } } } @@ -49,13 +58,18 @@ fn every_test_process_uses_the_built_worker_without_invoking_cargo() { return; } let dir = basal_testkit::harness::scratch("worker-discovery"); + #[cfg(unix)] let cargo = dir.join("cargo-fixture"); + #[cfg(windows)] + let cargo = std::path::PathBuf::from(env!("CARGO_BIN_EXE_basal-test-parent")); let calls = dir.join("calls"); + #[cfg(unix)] std::fs::write( &cargo, "#!/bin/sh\necho build >> \"$BASAL_DISCOVERY_CALLS\"\nexit 1\n", ) .unwrap(); + #[cfg(unix)] std::fs::set_permissions(&cargo, std::fs::Permissions::from_mode(0o755)).unwrap(); for _ in 0..2 { let result = Command::new(std::env::current_exe().unwrap()) diff --git a/crates/basal-worker/Cargo.toml b/crates/basal-worker/Cargo.toml index 58349ed..ed7f0a2 100644 --- a/crates/basal-worker/Cargo.toml +++ b/crates/basal-worker/Cargo.toml @@ -19,9 +19,37 @@ rquickjs.workspace = true landlock = "=0.4.7" seccompiler = "=0.5.0" +# The worker's own startup checks on Windows: its tokens, mitigation +# policies, handle table and thread CPU time. Every function used here is +# exported by kernel32, advapi32 or ntdll, so the image imports no DLL that +# would pull User32, GDI or COM into the confined process. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_System_Console", + "Win32_System_Threading", +] } + +[features] +default = [] +# Test-only: lets the worker act on `--confinement-deviation=`, which +# basal-launch passes for the three startup checks the parent cannot break +# from outside (a thread token left in place, a failed or skipped integrity +# lowering). A worker built without this feature refuses that argument as +# unknown, so a production worker has no way to skip a check. +deviations = [] + [dev-dependencies] -basal-testkit.workspace = true serde_json.workspace = true +basal-testkit.workspace = true + +# On Windows every test spawn of the worker goes through the launcher, under +# the real confinement. Its test variants break one confinement property at a +# time, for the worker's refusal tests. +[target.'cfg(windows)'.dev-dependencies] +basal-launch = { workspace = true, features = ["deviations"] } [[bin]] name = "ck-basal-worker" diff --git a/crates/basal-worker/examples/wrapper_stack.rs b/crates/basal-worker/examples/wrapper_stack.rs index 7f57759..9efbbe0 100644 --- a/crates/basal-worker/examples/wrapper_stack.rs +++ b/crates/basal-worker/examples/wrapper_stack.rs @@ -2,6 +2,7 @@ //! the engine freezes its global object, both directly and through the worker. //! Frozen globals reject new names before a call can run; scoped functions can //! recurse. Direct versus worker entry separates those engine costs from OS confinement. + use basal_proto::{ActivationRequest, ActivationResult, Budgets, JsonText, Profile}; use basal_worker::{engine, link::Channel}; use std::cell::RefCell; diff --git a/crates/basal-worker/src/clock.rs b/crates/basal-worker/src/clock.rs index 5dde9ad..775454d 100644 --- a/crates/basal-worker/src/clock.rs +++ b/crates/basal-worker/src/clock.rs @@ -10,12 +10,25 @@ use std::cell::Cell; use std::time::Duration; +#[cfg(any(windows, test))] +pub mod windows; + +/// The calling thread's CPU time. +/// +/// On Windows it is the thread's kernel plus user time from `GetThreadTimes` +/// (see [`windows`]), which advances in scheduler ticks. +#[cfg(windows)] +pub fn thread_cpu_time() -> Duration { + windows::thread_cpu_time() +} + /// The calling thread's CPU time. /// /// `CLOCK_THREAD_CPUTIME_ID` is supported on every macOS release this worker /// targets and on Linux; if the call ever fails, the budget falls back to /// treating the clock as stopped, and the parent's wall-clock deadline (which /// kills the worker) remains the backstop. +#[cfg(not(windows))] pub fn thread_cpu_time() -> Duration { let mut ts = libc::timespec { tv_sec: 0, @@ -39,22 +52,31 @@ pub struct JsClock { spent: Cell, entered_at: Cell>, exhausted: Cell, + /// Where CPU time samples come from: [`thread_cpu_time`], or a scripted + /// source in tests. + sample: fn() -> Duration, } impl JsClock { pub fn new(budget: Duration) -> Self { + Self::with_source(budget, thread_cpu_time) + } + + /// A clock that reads its CPU time samples from `sample`. + pub fn with_source(budget: Duration, sample: fn() -> Duration) -> Self { Self { budget, spent: Cell::new(Duration::ZERO), entered_at: Cell::new(None), exhausted: Cell::new(false), + sample, } } /// Starts counting: the worker is about to run JavaScript. pub fn enter(&self) { if self.entered_at.get().is_none() { - self.entered_at.set(Some(thread_cpu_time())); + self.entered_at.set(Some((self.sample)())); } } @@ -62,7 +84,7 @@ impl JsClock { /// about to wait on the parent. pub fn leave(&self) { if let Some(start) = self.entered_at.take() { - let now = thread_cpu_time(); + let now = (self.sample)(); self.spent.set(self.spent.get() + now.saturating_sub(start)); } } @@ -71,7 +93,7 @@ impl JsClock { let running = self .entered_at .get() - .map(|start| thread_cpu_time().saturating_sub(start)) + .map(|start| (self.sample)().saturating_sub(start)) .unwrap_or_default(); self.spent.get() + running } @@ -103,18 +125,76 @@ mod tests { } } + /// Against the real clock. The budget and the burns span several + /// scheduler ticks (about 15.6 ms on Windows, where the thread CPU clock + /// advances a tick at a time), so tick rounding cannot flip the outcome. #[test] fn time_outside_the_engine_is_not_counted() { - let clock = JsClock::new(Duration::from_millis(30)); + let clock = JsClock::new(Duration::from_millis(150)); clock.enter(); - burn(Duration::from_millis(5)); + burn(Duration::from_millis(30)); clock.leave(); // Sleeping and burning CPU while left must not count. std::thread::sleep(Duration::from_millis(50)); - burn(Duration::from_millis(40)); + burn(Duration::from_millis(200)); assert!(!clock.over_budget(), "spent {:?}", clock.spent()); clock.enter(); - burn(Duration::from_millis(40)); + burn(Duration::from_millis(200)); + assert!(clock.over_budget()); + } + + thread_local! { + static SAMPLE: Cell = const { Cell::new(Duration::ZERO) }; + } + + fn scripted() -> Duration { + SAMPLE.with(Cell::get) + } + + fn set(millis: u64) { + SAMPLE.with(|sample| sample.set(Duration::from_millis(millis))); + } + + /// With injected samples: only the intervals between `enter` and + /// `leave` count, a running interval counts up to the latest sample, and + /// exhaustion stays latched. + #[test] + fn only_samples_taken_inside_the_engine_are_counted() { + set(1_000); + let clock = JsClock::with_source(Duration::from_millis(100), scripted); + clock.enter(); + set(1_060); + clock.leave(); + assert_eq!(clock.spent(), Duration::from_millis(60)); + // A long gap outside the engine is not counted. + set(9_000); + assert_eq!(clock.spent(), Duration::from_millis(60)); + assert!(!clock.over_budget()); + clock.enter(); + // Entering twice keeps the first sample. + set(9_020); + clock.enter(); + set(9_040); + assert_eq!(clock.spent(), Duration::from_millis(100)); + assert!(!clock.over_budget(), "exactly the budget is not over it"); + set(9_041); assert!(clock.over_budget()); + clock.leave(); + assert!(clock.exhausted()); + assert!(clock.over_budget(), "exhaustion is latched"); + } + + /// A clock that failed returns zero, which can be below the sample taken + /// on entry. The interval then counts as nothing rather than wrapping. + #[test] + fn a_sample_below_the_entry_sample_counts_as_nothing() { + set(500); + let clock = JsClock::with_source(Duration::from_millis(10), scripted); + clock.enter(); + set(0); + assert_eq!(clock.spent(), Duration::ZERO); + clock.leave(); + assert_eq!(clock.spent(), Duration::ZERO); + assert!(!clock.over_budget()); } } diff --git a/crates/basal-worker/src/clock/windows.rs b/crates/basal-worker/src/clock/windows.rs new file mode 100644 index 0000000..ee9dd76 --- /dev/null +++ b/crates/basal-worker/src/clock/windows.rs @@ -0,0 +1,99 @@ +//! The thread CPU clock on Windows. +//! +//! `GetThreadTimes` reports the calling thread's kernel and user time in +//! 100-nanosecond units. The kernel charges that time a scheduler tick at a +//! time (about 15.6 ms by default), so the clock advances in ticks: a budget +//! smaller than a tick is imprecise, and the activation's wall deadline, +//! enforced by the parent, stays the hard bound. A failed read counts as +//! zero, the same fallback the Unix clock uses. + +use std::time::Duration; + +/// The calling thread's kernel plus user time. +#[cfg(windows)] +pub fn thread_cpu_time() -> Duration { + use windows_sys::Win32::Foundation::FILETIME; + use windows_sys::Win32::System::Threading::{GetCurrentThread, GetThreadTimes}; + + let zero = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let (mut creation, mut exit, mut kernel, mut user) = (zero, zero, zero, zero); + // SAFETY: the pseudo-handle names the calling thread, and the four + // out-pointers are valid FILETIMEs for the duration of the call. + let ok = unsafe { + GetThreadTimes( + GetCurrentThread(), + &mut creation, + &mut exit, + &mut kernel, + &mut user, + ) + } != 0; + let ticks = + |time: FILETIME| u64::from(time.dwHighDateTime) << 32 | u64::from(time.dwLowDateTime); + cpu_time(ok.then(|| (ticks(kernel), ticks(user)))) +} + +/// CPU time from one `GetThreadTimes` sample of (kernel, user) time in +/// 100-nanosecond units, or zero when the read failed. +pub fn cpu_time(sample: Option<(u64, u64)>) -> Duration { + match sample { + None => Duration::ZERO, + Some((kernel, user)) => { + let units = kernel.saturating_add(user); + Duration::new(units / 10_000_000, (units % 10_000_000) as u32 * 100) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kernel_and_user_time_add_up_in_100_nanosecond_units() { + // One second of kernel time and one scheduler tick (156,250 units) of + // user time. + assert_eq!( + cpu_time(Some((10_000_000, 156_250))), + Duration::from_secs(1) + Duration::from_micros(15_625) + ); + assert_eq!(cpu_time(Some((0, 1))), Duration::from_nanos(100)); + assert_eq!(cpu_time(Some((0, 0))), Duration::ZERO); + } + + #[test] + fn a_failed_read_is_zero() { + assert_eq!(cpu_time(None), Duration::ZERO); + } + + #[test] + fn the_largest_sample_saturates_instead_of_wrapping() { + let largest = cpu_time(Some((u64::MAX, u64::MAX))); + assert_eq!(largest, cpu_time(Some((u64::MAX, 0)))); + assert!(largest > cpu_time(Some((u64::MAX - 1, 0)))); + } + + /// The real clock advances while the thread burns CPU. Several ticks are + /// burned so the reading moves even at scheduler-tick resolution. The + /// loop is bounded by work done, not by the clock under test, so a clock + /// stuck at zero fails instead of hanging. + #[cfg(windows)] + #[test] + fn the_real_clock_advances_with_work() { + let start = thread_cpu_time(); + let mut x = 0u64; + for round in 0..10_000u32 { + if thread_cpu_time().saturating_sub(start) >= Duration::from_millis(100) { + break; + } + for _ in 0..100_000u32 { + x = x.wrapping_mul(31).wrapping_add(u64::from(round)); + std::hint::black_box(x); + } + } + assert!(thread_cpu_time() >= start + Duration::from_millis(100)); + } +} diff --git a/crates/basal-worker/src/confinement.rs b/crates/basal-worker/src/confinement.rs index 5e39765..77d4e19 100644 --- a/crates/basal-worker/src/confinement.rs +++ b/crates/basal-worker/src/confinement.rs @@ -16,6 +16,11 @@ use basal_proto::Confinement; #[cfg(target_os = "linux")] pub mod linux; +// The pure checks under `windows` are also built for unit tests elsewhere, +// so their accept and reject cases run on every system. +#[cfg(any(windows, test))] +pub mod windows; + /// The Seatbelt profile, checked in beside the crate and embedded at build /// time so the binary cannot be pointed at a different one. pub const SEATBELT_PROFILE: &str = include_str!("../sandbox/worker.sb"); @@ -33,6 +38,9 @@ pub enum ConfinementError { /// A Linux startup precondition or confinement layer failed. #[cfg(target_os = "linux")] Linux(&'static str), + /// A Windows startup check failed, with its reason token. + #[cfg(windows)] + Windows(windows::Refusal), } impl fmt::Display for ConfinementError { @@ -43,6 +51,8 @@ impl fmt::Display for ConfinementError { Self::Unsupported => write!(f, "no supported OS sandbox on this platform"), #[cfg(target_os = "linux")] Self::Linux(token) => f.write_str(token), + #[cfg(windows)] + Self::Windows(refusal) => write!(f, "{refusal}"), } } } diff --git a/crates/basal-worker/src/confinement/windows.rs b/crates/basal-worker/src/confinement/windows.rs new file mode 100644 index 0000000..a2e79d4 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows.rs @@ -0,0 +1,290 @@ +//! The worker's own confinement checks on Windows. +//! +//! On Windows the parent fixes most of the confinement when it creates the +//! worker (see the `basal-launch` crate): a Less Privileged AppContainer +//! token with no capabilities, every group deny-only, no privileges and the +//! NULL SID as the only restricting SID; a job; mitigation policies; and an +//! explicit list of the three inherited pipes. The loader then runs on the +//! main thread under a start-up impersonation token, because the restricted +//! primary allows too little for it to map system DLLs. +//! +//! Before reading its first frame the worker finishes the job, in this order, +//! and refuses to run (exit 70, the reason on stderr) at the first failure: +//! +//! 1. it drops the start-up token and checks the thread has none left +//! (`thread-token-present`); +//! 2. it lowers its primary token from Low to Untrusted integrity and closes +//! the handle it used (`integrity-lower-failed`); +//! 3. it closes the connection to the session's client/server runtime port +//! and any device handle the loader left that it did not inherit; +//! 4. it reads its actual primary token back and checks every property +//! (`not-lpac`, `capabilities-present`, `restricting-sid-mismatch`, +//! `group-not-deny-only`, `privileges-present`, `integrity-not-untrusted`); +//! 5. it reads its mitigation policies back (`mitigation-mismatch`); +//! 6. it checks its whole handle table against an allowlist +//! (`handle-not-allowed`). +//! +//! The checks themselves ([`attest`], [`mitigation`], [`allowlist`]) are pure +//! functions over what was read, so their accept and reject cases are tested +//! on every system. The reads and the startup sequence are Windows-only. + +pub mod allowlist; +pub mod attest; +pub mod mitigation; + +#[cfg(windows)] +mod native; +#[cfg(windows)] +mod startup; + +#[cfg(windows)] +pub use native::PackageSid; +#[cfg(windows)] +pub use startup::enter; + +/// The reason tokens the worker exits with. Each names the check that +/// failed; tests and the parent match on them. +pub mod reason { + /// No `--package-sid=` argument was given. + pub const PACKAGE_SID_ARGUMENT_MISSING: &str = "package-sid-argument-missing"; + /// The main thread still has an impersonation token after reverting. + pub const THREAD_TOKEN_PRESENT: &str = "thread-token-present"; + /// The primary token could not be lowered to Untrusted integrity, or the + /// handle used for it could not be closed. + pub const INTEGRITY_LOWER_FAILED: &str = "integrity-lower-failed"; + /// The primary is not a Less Privileged AppContainer token of the + /// expected package. + pub const NOT_LPAC: &str = "not-lpac"; + /// The primary holds a capability. + pub const CAPABILITIES_PRESENT: &str = "capabilities-present"; + /// The primary's restricting SIDs are not exactly the NULL SID. + pub const RESTRICTING_SID_MISMATCH: &str = "restricting-sid-mismatch"; + /// An access group of the primary is not deny-only. + pub const GROUP_NOT_DENY_ONLY: &str = "group-not-deny-only"; + /// The primary holds a privilege. + pub const PRIVILEGES_PRESENT: &str = "privileges-present"; + /// The primary's integrity is not Untrusted. + pub const INTEGRITY_NOT_UNTRUSTED: &str = "integrity-not-untrusted"; + /// A required mitigation policy is off, or a forbidden opt-out is on. + pub const MITIGATION_MISMATCH: &str = "mitigation-mismatch"; + /// The handle table holds a handle outside the allowlist, or the table + /// could not be read or cleaned. + pub const HANDLE_NOT_ALLOWED: &str = "handle-not-allowed"; +} + +/// A failed check: its reason token and what was found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refusal { + pub reason: &'static str, + pub detail: String, +} + +impl Refusal { + pub fn new(reason: &'static str, detail: impl Into) -> Self { + Self { + reason, + detail: detail.into(), + } + } +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.reason, self.detail) + } +} + +/// The package SID argument the launcher passes on every Windows spawn. +pub const PACKAGE_SID_ARGUMENT: &str = "--package-sid="; + +/// The argument basal-launch passes for a worker check it cannot break from +/// the parent. Only a worker built with the `deviations` feature accepts it. +pub const DEVIATION_ARGUMENT: &str = "--confinement-deviation="; + +/// A startup check the parent asks a test worker to break. +#[cfg(feature = "deviations")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Deviation { + /// Keep the start-up thread token: step 1 must refuse. + ThreadTokenPresent, + /// Open the primary without the right to adjust it, so lowering fails: + /// step 2 must refuse. + IntegrityLowerFailed, + /// Skip lowering the primary: step 4 must refuse it as still Low. + IntegrityNotUntrusted, +} + +#[cfg(feature = "deviations")] +impl Deviation { + fn parse(name: &str) -> Option { + match name { + "thread-token-present" => Some(Self::ThreadTokenPresent), + "integrity-lower-failed" => Some(Self::IntegrityLowerFailed), + "integrity-not-untrusted" => Some(Self::IntegrityNotUntrusted), + _ => None, + } + } +} + +/// The engine-mode command line on Windows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + /// The package SID, as text. It is parsed with the system's SID parser + /// before confinement starts. + pub package_sid: String, + /// The startup check to break, in a test worker. + #[cfg(feature = "deviations")] + pub deviation: Option, +} + +impl Arguments { + /// Whether step 1 leaves the start-up token in place. + #[cfg(windows)] + fn keeps_thread_token(&self) -> bool { + #[cfg(feature = "deviations")] + if self.deviation == Some(Deviation::ThreadTokenPresent) { + return true; + } + false + } + + /// Whether step 2 opens the primary without the right to adjust it. + #[cfg(windows)] + fn opens_primary_read_only(&self) -> bool { + #[cfg(feature = "deviations")] + if self.deviation == Some(Deviation::IntegrityLowerFailed) { + return true; + } + false + } + + /// Whether step 2 leaves the primary at Low, the integrity the process + /// was created with. + #[cfg(windows)] + fn skips_lowering(&self) -> bool { + #[cfg(feature = "deviations")] + if self.deviation == Some(Deviation::IntegrityNotUntrusted) { + return true; + } + false + } +} + +/// Why the engine-mode command line was not accepted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArgumentError { + /// No `--package-sid=` argument: exit 70 `package-sid-argument-missing`. + Missing, + /// An unknown, repeated or malformed argument: exit 64. + Usage(String), +} + +/// Parses the engine-mode arguments: exactly one `--package-sid=`, and +/// in a test worker at most one `--confinement-deviation=`. Anything +/// else is a usage error, checked before a missing SID is reported. +pub fn engine_arguments(args: &[String]) -> Result { + let mut package_sid: Option<&str> = None; + #[cfg(feature = "deviations")] + let mut deviation: Option = None; + for arg in args { + if let Some(sid) = arg.strip_prefix(PACKAGE_SID_ARGUMENT) { + if package_sid.replace(sid).is_some() { + return Err(ArgumentError::Usage(format!("repeated argument {arg}"))); + } + continue; + } + #[cfg(feature = "deviations")] + if let Some(name) = arg.strip_prefix(DEVIATION_ARGUMENT) { + let parsed = Deviation::parse(name) + .ok_or_else(|| ArgumentError::Usage(format!("unknown deviation {name}")))?; + if deviation.replace(parsed).is_some() { + return Err(ArgumentError::Usage(format!("repeated argument {arg}"))); + } + continue; + } + return Err(ArgumentError::Usage(format!("unknown argument {arg}"))); + } + let package_sid = package_sid.ok_or(ArgumentError::Missing)?; + if package_sid.is_empty() { + return Err(ArgumentError::Usage("empty --package-sid".into())); + } + Ok(Arguments { + package_sid: package_sid.to_owned(), + #[cfg(feature = "deviations")] + deviation, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(list: &[&str]) -> Vec { + list.iter().map(|arg| (*arg).to_owned()).collect() + } + + const SID: &str = "--package-sid=S-1-15-2-1-2-3-4-5-6-7"; + + #[test] + fn exactly_one_package_sid_is_accepted() { + let parsed = engine_arguments(&args(&[SID])).expect("one package SID"); + assert_eq!(parsed.package_sid, "S-1-15-2-1-2-3-4-5-6-7"); + } + + #[test] + fn a_missing_package_sid_is_its_own_error() { + assert_eq!(engine_arguments(&[]), Err(ArgumentError::Missing)); + } + + #[test] + fn other_command_lines_are_usage_errors() { + for line in [ + &[SID, SID][..], + &["--package-sid="], + &[SID, "--landlock=required"], + &["--bogus"], + &["--bogus", SID], + &["--package-sid"], + ] { + assert!( + matches!(engine_arguments(&args(line)), Err(ArgumentError::Usage(_))), + "{line:?}" + ); + } + } + + /// A worker built without the `deviations` feature has no way to skip a + /// startup check: the request is an unknown argument like any other. + #[cfg(not(feature = "deviations"))] + #[test] + fn a_production_worker_refuses_every_deviation_request() { + for name in [ + "thread-token-present", + "integrity-lower-failed", + "integrity-not-untrusted", + ] { + let request = format!("{DEVIATION_ARGUMENT}{name}"); + assert_eq!( + engine_arguments(&args(&[SID, &request])), + Err(ArgumentError::Usage(format!("unknown argument {request}"))) + ); + } + } + + #[cfg(feature = "deviations")] + #[test] + fn a_test_worker_accepts_the_three_deviations_once() { + for (name, deviation) in [ + ("thread-token-present", Deviation::ThreadTokenPresent), + ("integrity-lower-failed", Deviation::IntegrityLowerFailed), + ("integrity-not-untrusted", Deviation::IntegrityNotUntrusted), + ] { + let request = format!("{DEVIATION_ARGUMENT}{name}"); + let parsed = engine_arguments(&args(&[SID, &request])).expect("accepted"); + assert_eq!(parsed.deviation, Some(deviation)); + assert!(engine_arguments(&args(&[SID, &request, &request])).is_err()); + } + let other = format!("{DEVIATION_ARGUMENT}not-lpac"); + assert!(engine_arguments(&args(&[SID, &other])).is_err()); + } +} diff --git a/crates/basal-worker/src/confinement/windows/allowlist.rs b/crates/basal-worker/src/confinement/windows/allowlist.rs new file mode 100644 index 0000000..e395810 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/allowlist.rs @@ -0,0 +1,472 @@ +//! The handle allowlist (startup step 6). +//! +//! Before it reads input the worker lists every handle in its own table and +//! accepts the table only if it fits one of two measured profiles, one per +//! supported Windows image. Some rows are exact: the three standard pipes and +//! the loader's read-only `\KnownDlls` directory must each be there. Every +//! other row is a ceiling on handles the system's own start-up code creates +//! for the process's thread pool, loader and crypto initialisation: unnamed, +//! private objects that give no file, network, process or persistent reach. +//! Anything else (a file that is not a standard pipe, a section, process, +//! thread, token, job, key, port, another directory, any inheritable handle +//! that is not a standard pipe) is refused. +//! +//! The table must fit a single profile on its own. The two profiles are never +//! combined, so a table holding Windows 11's SchedulerSharedData handle and +//! Server 2022's two CNG semaphores fits neither and is refused. + +use std::collections::BTreeMap; + +/// Which standard handle an entry is, by its handle value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stdio { + Input, + Output, + Error, +} + +/// One handle in the worker's table, as identified without dereferencing it: +/// its type comes from the table's type index and the system's type list, +/// and only the single Directory handle has its name queried. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandleEntry { + /// The handle value. + pub value: usize, + /// The object type name, or `unknown type `. + pub type_name: String, + /// The granted access mask. + pub access: u32, + /// Whether the handle is marked inheritable (`OBJ_INHERIT`). + pub inheritable: bool, + /// Which standard handle it is, if its value is one of them. + pub stdio: Option, + /// The object name, read for the Directory handle only. + pub name: Option, +} + +/// Read access to the stdin pipe, as `CreatePipe` grants it. +pub const STDIN_ACCESS: u32 = 0x0012_0189; +/// Write access to the stdout and stderr pipes, as `CreatePipe` grants it. +pub const STDOUT_ACCESS: u32 = 0x0012_0196; +/// Query and traverse on the `\KnownDlls` object directory. +pub const KNOWN_DLLS_ACCESS: u32 = 0x3; +/// The one object directory the loader keeps open. +pub const KNOWN_DLLS: &str = "\\KnownDlls"; + +/// At most `count` handles of this type with exactly this access. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Ceiling { + pub type_name: &'static str, + pub access: u32, + pub count: usize, +} + +/// The measured handle table of one Windows image. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Profile { + pub name: &'static str, + /// The ceiling rows. A row with count 0 is a type this image never has. + pub ceilings: [Ceiling; 7], + /// The largest number of handles in the whole table, the exact rows + /// included. + pub total: usize, +} + +const fn ceiling(type_name: &'static str, access: u32, count: usize) -> Ceiling { + Ceiling { + type_name, + access, + count, + } +} + +/// Windows 11 (the `windows-latest` runner image). +pub const WINDOWS_LATEST: Profile = Profile { + name: "windows-latest", + ceilings: [ + ceiling("Event", 0x001f_0003, 7), + ceiling("IoCompletion", 0x001f_0003, 2), + ceiling("TpWorkerFactory", 0x000f_00ff, 2), + ceiling("IRTimer", 0x0010_0002, 4), + ceiling("WaitCompletionPacket", 0x1, 6), + ceiling("Semaphore", 0x0010_0003, 0), + ceiling("SchedulerSharedData", 0x1, 1), + ], + total: 26, +}; + +/// Windows Server 2022 (the `windows-2022` runner image). +pub const WINDOWS_2022: Profile = Profile { + name: "windows-2022", + ceilings: [ + ceiling("Event", 0x001f_0003, 7), + ceiling("IoCompletion", 0x001f_0003, 2), + ceiling("TpWorkerFactory", 0x000f_00ff, 2), + ceiling("IRTimer", 0x0010_0002, 4), + ceiling("WaitCompletionPacket", 0x1, 5), + ceiling("Semaphore", 0x0010_0003, 2), + ceiling("SchedulerSharedData", 0x1, 0), + ], + total: 26, +}; + +/// Every profile a table may fit. +pub const PROFILES: [&Profile; 2] = [&WINDOWS_LATEST, &WINDOWS_2022]; + +/// Whether startup step 3 closes this handle before the allowlist runs. +/// +/// It closes the loader's connection to the session's client/server runtime +/// port (every ALPC Port) and every File handle that is neither a standard +/// handle nor inheritable, which on Server 2022 is the crypto driver device +/// the CNG start-up code opens. Only the three pipes are inherited; any other +/// inheritable handle came from the parent by mistake and is left open, so +/// that the allowlist refuses it instead of the worker hiding it. +pub fn closed_at_startup(type_name: &str, stdio: bool, inheritable: bool) -> bool { + match type_name { + "ALPC Port" => true, + "File" => !stdio && !inheritable, + _ => false, + } +} + +fn describe(entry: &HandleEntry) -> String { + format!( + "handle {:#x} {} access {:#x}{}{}", + entry.value, + entry.type_name, + entry.access, + if entry.inheritable { + " inheritable" + } else { + "" + }, + entry + .name + .as_deref() + .map(|name| format!(" named {name}")) + .unwrap_or_default() + ) +} + +/// Checks a handle table. Returns the profile it fits, or every reason it +/// fits none. +pub fn check(entries: &[HandleEntry]) -> Result<&'static Profile, String> { + let mut refused: Vec = Vec::new(); + let mut stdio = [0usize; 3]; + let mut known_dlls = 0usize; + // Handles counted against a ceiling row, by (type, access). + let mut counted: BTreeMap<(&str, u32), usize> = BTreeMap::new(); + for entry in entries { + if let Some(slot) = entry.stdio { + let (index, access) = match slot { + Stdio::Input => (0, STDIN_ACCESS), + Stdio::Output => (1, STDOUT_ACCESS), + Stdio::Error => (2, STDOUT_ACCESS), + }; + if entry.type_name == "File" && entry.access == access { + stdio[index] += 1; + } else { + refused.push(format!("standard {slot:?} is {}", describe(entry))); + } + continue; + } + if entry.inheritable { + refused.push(format!("inheritable {}", describe(entry))); + continue; + } + if entry.type_name == "Directory" { + if entry.name.as_deref() == Some(KNOWN_DLLS) && entry.access == KNOWN_DLLS_ACCESS { + known_dlls += 1; + } else { + refused.push(describe(entry)); + } + continue; + } + let row = PROFILES + .iter() + .flat_map(|profile| profile.ceilings.iter()) + .find(|row| row.type_name == entry.type_name.as_str() && row.access == entry.access); + match row { + Some(row) => *counted.entry((row.type_name, row.access)).or_default() += 1, + None => refused.push(describe(entry)), + } + } + for (index, name) in ["stdin", "stdout", "stderr"].into_iter().enumerate() { + if stdio[index] != 1 { + refused.push(format!("{} {name} pipe handles, expected 1", stdio[index])); + } + } + if known_dlls != 1 { + refused.push(format!( + "{known_dlls} {KNOWN_DLLS} directory handles, expected 1" + )); + } + if !refused.is_empty() { + return Err(refused.join("; ")); + } + + let mut misfits: Vec = Vec::new(); + for profile in PROFILES { + let mut over: Vec = Vec::new(); + for row in &profile.ceilings { + let count = counted + .get(&(row.type_name, row.access)) + .copied() + .unwrap_or(0); + if count > row.count { + over.push(format!( + "{count} {} {:#x}, at most {}", + row.type_name, row.access, row.count + )); + } + } + if entries.len() > profile.total { + over.push(format!( + "{} handles, at most {}", + entries.len(), + profile.total + )); + } + if over.is_empty() { + return Ok(profile); + } + misfits.push(format!("not {}: {}", profile.name, over.join(", "))); + } + Err(misfits.join("; ")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(type_name: &str, access: u32) -> HandleEntry { + HandleEntry { + value: 0, + type_name: type_name.into(), + access, + inheritable: false, + stdio: None, + name: None, + } + } + + fn rows(type_name: &str, access: u32, count: usize) -> Vec { + (0..count).map(|_| entry(type_name, access)).collect() + } + + /// The exact rows: the three inherited pipes and `\KnownDlls`. + fn exact() -> Vec { + let pipe = |stdio, access| HandleEntry { + inheritable: true, + stdio: Some(stdio), + ..entry("File", access) + }; + vec![ + pipe(Stdio::Input, STDIN_ACCESS), + pipe(Stdio::Output, STDOUT_ACCESS), + pipe(Stdio::Error, STDOUT_ACCESS), + HandleEntry { + name: Some(KNOWN_DLLS.into()), + ..entry("Directory", KNOWN_DLLS_ACCESS) + }, + ] + } + + /// The 26-handle table measured on Windows 11. + fn latest() -> Vec { + let mut table = exact(); + table.extend(rows("Event", 0x1f0003, 7)); + table.extend(rows("IoCompletion", 0x1f0003, 2)); + table.extend(rows("TpWorkerFactory", 0xf00ff, 2)); + table.extend(rows("IRTimer", 0x100002, 4)); + table.extend(rows("WaitCompletionPacket", 0x1, 6)); + table.extend(rows("SchedulerSharedData", 0x1, 1)); + table + } + + /// The 26-handle table measured on Windows Server 2022. + fn server_2022() -> Vec { + let mut table = exact(); + table.extend(rows("Event", 0x1f0003, 7)); + table.extend(rows("IoCompletion", 0x1f0003, 2)); + table.extend(rows("TpWorkerFactory", 0xf00ff, 2)); + table.extend(rows("IRTimer", 0x100002, 4)); + table.extend(rows("WaitCompletionPacket", 0x1, 5)); + table.extend(rows("Semaphore", 0x100003, 2)); + table + } + + #[test] + fn each_images_measured_table_fits_its_own_profile() { + assert_eq!(latest().len(), 26); + assert_eq!(check(&latest()), Ok(&WINDOWS_LATEST)); + assert_eq!(server_2022().len(), 26); + assert_eq!(check(&server_2022()), Ok(&WINDOWS_2022)); + } + + #[test] + fn a_smaller_table_fits() { + assert_eq!(check(&exact()), Ok(&WINDOWS_LATEST)); + let mut table = exact(); + table.extend(rows("Semaphore", 0x100003, 1)); + assert_eq!(check(&table), Ok(&WINDOWS_2022)); + } + + /// Windows 11's SchedulerSharedData handle together with Server 2022's + /// semaphores: 28 handles that fit neither profile alone, and would pass + /// only if the two profiles were combined. + #[test] + fn the_mixed_table_fits_no_profile() { + let mut mixed = latest(); + mixed.extend(rows("Semaphore", 0x100003, 2)); + assert_eq!(mixed.len(), 28); + let error = check(&mixed).expect_err("the mixed table must be refused"); + assert!(error.contains("not windows-latest: 2 Semaphore"), "{error}"); + assert!( + error.contains("1 SchedulerSharedData 0x1, at most 0"), + "{error}" + ); + // Even within the total, one row of each image is refused. + let mut small = exact(); + small.extend(rows("Semaphore", 0x100003, 1)); + small.extend(rows("SchedulerSharedData", 0x1, 1)); + assert!(check(&small).is_err()); + } + + #[test] + fn an_excess_count_is_refused() { + let mut table = latest(); + table.pop(); + table.push(entry("Event", 0x1f0003)); + assert_eq!(table.len(), 26); + let error = check(&table).expect_err("eight events"); + assert!(error.contains("8 Event 0x1f0003, at most 7"), "{error}"); + + let mut table = server_2022(); + table.push(entry("WaitCompletionPacket", 0x1)); + assert!( + check(&table).is_err(), + "six wait packets on 2022 and 27 in all" + ); + } + + #[test] + fn a_table_over_the_total_is_refused() { + // Every ceiling of windows-latest filled, plus one Semaphore that only + // windows-2022 allows: 27 handles. + let mut table = latest(); + table.extend(rows("Semaphore", 0x100003, 1)); + let error = check(&table).expect_err("27 handles"); + assert!(error.contains("27 handles, at most 26"), "{error}"); + } + + #[test] + fn startup_closes_ports_and_private_files_only() { + assert!(closed_at_startup("ALPC Port", false, false)); + assert!(closed_at_startup("File", false, false)); + // The standard pipes stay, and so does an inheritable file, which the + // allowlist must see and refuse. + assert!(!closed_at_startup("File", true, true)); + assert!(!closed_at_startup("File", false, true)); + for kept in [ + "Directory", + "Event", + "IoCompletion", + "TpWorkerFactory", + "IRTimer", + "WaitCompletionPacket", + "Semaphore", + "SchedulerSharedData", + "Key", + ] { + assert!(!closed_at_startup(kept, false, false), "{kept}"); + } + } + + #[test] + fn an_unknown_row_is_refused() { + for (type_name, access) in [ + ("Key", 0x20019), + ("Section", 0x4), + ("Process", 0x1000), + ("Thread", 0x1fffff), + ("Token", 0x8), + ("Job", 0x1f001f), + ("ALPC Port", 0x1f0001), + ("File", 0x100003), + ("Mutant", 0x1f0001), + ("unknown type 99", 0x1), + // A known type with an access mask the table does not list. + ("Event", 0x1f0001), + ("TpWorkerFactory", 0xf003f), + ] { + let mut table = exact(); + table.push(entry(type_name, access)); + let error = check(&table).expect_err(type_name); + assert!(error.contains(type_name), "{error}"); + } + } + + #[test] + fn a_missing_pipe_or_known_dlls_is_refused() { + for missing in 0..4 { + let mut table = latest(); + table.remove(missing); + assert!(check(&table).is_err(), "row {missing} missing"); + } + // A duplicate stdin pipe is not a second accepted pipe. + let mut table = latest(); + table.push(exact().remove(0)); + assert!(check(&table).is_err()); + } + + #[test] + fn a_standard_handle_must_be_its_own_pipe() { + // stdout with read access, stdin with write access. + let mut table = exact(); + table[0].access = STDOUT_ACCESS; + assert!(check(&table).is_err()); + let mut table = exact(); + table[1].access = STDIN_ACCESS; + assert!(check(&table).is_err()); + // A standard handle that is not a File. + let mut table = exact(); + table[2].type_name = "Event".into(); + assert!(check(&table).is_err()); + } + + #[test] + fn an_inheritable_handle_that_is_not_stdio_is_refused() { + let mut table = exact(); + table.push(HandleEntry { + inheritable: true, + ..entry("Event", 0x1f0003) + }); + let error = check(&table).expect_err("inheritable event"); + assert!(error.contains("inheritable"), "{error}"); + // A file handle the parent leaked, with pipe access but not one of + // the standard handles. + let mut table = exact(); + table.push(HandleEntry { + inheritable: true, + ..entry("File", STDOUT_ACCESS) + }); + assert!(check(&table).is_err()); + } + + #[test] + fn only_the_known_dlls_directory_is_allowed() { + let mut table = exact(); + table[3].name = Some("\\Sessions\\1\\BaseNamedObjects".into()); + assert!(check(&table).is_err()); + let mut table = exact(); + table[3].name = None; + assert!(check(&table).is_err()); + let mut table = exact(); + table[3].access = 0xf; + assert!(check(&table).is_err()); + let mut table = exact(); + table.push(table[3].clone()); + assert!(check(&table).is_err(), "two directory handles"); + } +} diff --git a/crates/basal-worker/src/confinement/windows/attest.rs b/crates/basal-worker/src/confinement/windows/attest.rs new file mode 100644 index 0000000..c5ff246 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/attest.rs @@ -0,0 +1,321 @@ +//! The check of the worker's actual primary token (startup step 4). +//! +//! The parent builds the primary and checks it on the suspended process, but +//! the system changes a token at creation (process creation makes an +//! AppContainer primary Low whatever integrity was supplied), and the worker +//! lowers its own integrity after load. So the worker reads its token back +//! once more, after every change, and judges what it reads rather than what +//! anyone meant to build. +//! +//! The readers report each property separately, and a property that could +//! not be read fails the check that needed it. The checks run in a fixed +//! order and the first failure is the reason. + +use super::{Refusal, reason}; + +/// The NULL SID, the only restricting SID the primary may have. +pub const NULL_SID: &str = "S-1-0-0"; +/// The Untrusted mandatory label the worker lowers itself to. +pub const UNTRUSTED_INTEGRITY: &str = "S-1-16-0"; +/// The claim that marks a Less Privileged AppContainer token. +pub const LPAC_CLAIM: &str = "WIN://NOALLAPPPKG"; +/// `SE_GROUP_INTEGRITY`: marks the integrity label, which is not an access +/// group. +pub const SE_GROUP_INTEGRITY: u32 = 0x20; +/// `SE_GROUP_USE_FOR_DENY_ONLY`: the group matches only deny entries. +pub const SE_GROUP_USE_FOR_DENY_ONLY: u32 = 0x10; + +/// The `WIN://NOALLAPPPKG` security attribute as read from the token. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Claim { + /// The token has no attribute of that name. + Absent, + /// The attribute holds unsigned 64-bit values. + Unsigned(Vec), + /// The attribute holds values of another type, by its type number. + OtherType(u16), +} + +/// One access group: its SID and attribute flags. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Group { + pub sid: String, + pub attributes: u32, +} + +/// What was read from the primary. Each field is the property, or why it +/// could not be read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrimaryFacts { + pub lpac_claim: Result, + /// The token's AppContainer SID, if it is an AppContainer token, and + /// whether it is equal (`EqualSid`) to the `--package-sid` argument. + pub package: Result, String>, + pub capabilities: Result, String>, + pub restricting_sids: Result, String>, + pub groups: Result, String>, + pub privileges: Result, + pub integrity: Result, +} + +/// The property, or the named refusal of the check that could not read it. +fn read<'a, T>(reason: &'static str, result: &'a Result) -> Result<&'a T, Refusal> { + result + .as_ref() + .map_err(|error| Refusal::new(reason, format!("could not read: {error}"))) +} + +/// Runs the six token checks in order and returns the first failure. +pub fn check(facts: &PrimaryFacts) -> Result<(), Refusal> { + let claim = read(reason::NOT_LPAC, &facts.lpac_claim)?; + if !matches!(claim, Claim::Unsigned(values) if values.as_slice() == [1]) { + return Err(Refusal::new( + reason::NOT_LPAC, + format!("{LPAC_CLAIM} is {claim:?}, not [1]"), + )); + } + match read(reason::NOT_LPAC, &facts.package)? { + Some((_, true)) => {} + Some((sid, false)) => { + return Err(Refusal::new( + reason::NOT_LPAC, + format!("AppContainer SID {sid} is not the --package-sid"), + )); + } + None => return Err(Refusal::new(reason::NOT_LPAC, "not an AppContainer token")), + } + + let capabilities = read(reason::CAPABILITIES_PRESENT, &facts.capabilities)?; + if !capabilities.is_empty() { + return Err(Refusal::new( + reason::CAPABILITIES_PRESENT, + format!("capabilities {capabilities:?}"), + )); + } + + let sids = read(reason::RESTRICTING_SID_MISMATCH, &facts.restricting_sids)?; + if sids.as_slice() != [NULL_SID] { + return Err(Refusal::new( + reason::RESTRICTING_SID_MISMATCH, + format!("restricting SIDs {sids:?}, not [{NULL_SID}]"), + )); + } + + let groups = read(reason::GROUP_NOT_DENY_ONLY, &facts.groups)?; + let enabled: Vec<&str> = groups + .iter() + .filter(|group| group.attributes & SE_GROUP_INTEGRITY == 0) + .filter(|group| group.attributes & SE_GROUP_USE_FOR_DENY_ONLY == 0) + .map(|group| group.sid.as_str()) + .collect(); + if !enabled.is_empty() { + return Err(Refusal::new( + reason::GROUP_NOT_DENY_ONLY, + format!("groups not deny-only {enabled:?}"), + )); + } + + let privileges = *read(reason::PRIVILEGES_PRESENT, &facts.privileges)?; + if privileges != 0 { + return Err(Refusal::new( + reason::PRIVILEGES_PRESENT, + format!("{privileges} privileges"), + )); + } + + let integrity = read(reason::INTEGRITY_NOT_UNTRUSTED, &facts.integrity)?; + if integrity != UNTRUSTED_INTEGRITY { + return Err(Refusal::new( + reason::INTEGRITY_NOT_UNTRUSTED, + format!("integrity {integrity}, not {UNTRUSTED_INTEGRITY}"), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PACKAGE: &str = "S-1-15-2-1-2-3-4-5-6-7"; + + /// The primary measured in a fully confined worker after it lowered + /// itself: deny-only groups (logon included) plus the integrity label. + fn confined() -> PrimaryFacts { + PrimaryFacts { + lpac_claim: Ok(Claim::Unsigned(vec![1])), + package: Ok(Some((PACKAGE.into(), true))), + capabilities: Ok(vec![]), + restricting_sids: Ok(vec![NULL_SID.into()]), + groups: Ok(vec![ + Group { + sid: "S-1-1-0".into(), + attributes: SE_GROUP_USE_FOR_DENY_ONLY, + }, + Group { + sid: "S-1-5-5-0-1".into(), + attributes: SE_GROUP_USE_FOR_DENY_ONLY | 0xc000_0000, + }, + Group { + sid: UNTRUSTED_INTEGRITY.into(), + attributes: SE_GROUP_INTEGRITY | 0x40, + }, + ]), + privileges: Ok(0), + integrity: Ok(UNTRUSTED_INTEGRITY.into()), + } + } + + fn reason_of(facts: &PrimaryFacts) -> &'static str { + check(facts).expect_err("must be refused").reason + } + + #[test] + fn the_confined_primary_passes() { + assert_eq!(check(&confined()), Ok(())); + // No group count is pinned: a token with fewer groups passes too. + let mut fewer = confined(); + fewer.groups = Ok(vec![]); + assert_eq!(check(&fewer), Ok(())); + } + + /// One way to break the confined primary, and the reason it must get. + type Case = (&'static str, Box); + + #[test] + fn each_broken_property_is_refused_with_its_reason() { + let cases: Vec = vec![ + ( + reason::NOT_LPAC, + Box::new(|f| f.lpac_claim = Ok(Claim::Absent)), + ), + ( + reason::NOT_LPAC, + Box::new(|f| f.lpac_claim = Ok(Claim::Unsigned(vec![0]))), + ), + ( + reason::NOT_LPAC, + Box::new(|f| f.lpac_claim = Ok(Claim::Unsigned(vec![1, 1]))), + ), + ( + reason::NOT_LPAC, + Box::new(|f| f.lpac_claim = Ok(Claim::OtherType(1))), + ), + ( + reason::NOT_LPAC, + Box::new(|f| f.package = Ok(Some(("S-1-15-2-9".into(), false)))), + ), + (reason::NOT_LPAC, Box::new(|f| f.package = Ok(None))), + ( + reason::CAPABILITIES_PRESENT, + Box::new(|f| f.capabilities = Ok(vec!["S-1-15-3-1".into()])), + ), + ( + reason::RESTRICTING_SID_MISMATCH, + Box::new(|f| f.restricting_sids = Ok(vec![])), + ), + ( + reason::RESTRICTING_SID_MISMATCH, + Box::new(|f| f.restricting_sids = Ok(vec![NULL_SID.into(), "S-1-1-0".into()])), + ), + ( + reason::RESTRICTING_SID_MISMATCH, + Box::new(|f| f.restricting_sids = Ok(vec!["S-1-1-0".into()])), + ), + ( + reason::GROUP_NOT_DENY_ONLY, + Box::new(|f| { + f.groups.as_mut().unwrap().push(Group { + sid: "S-1-5-5-0-1".into(), + attributes: 0xc000_0007, + }) + }), + ), + ( + reason::PRIVILEGES_PRESENT, + Box::new(|f| f.privileges = Ok(1)), + ), + ( + reason::INTEGRITY_NOT_UNTRUSTED, + Box::new(|f| f.integrity = Ok("S-1-16-4096".into())), + ), + ]; + for (expected, breaks) in cases { + let mut facts = confined(); + breaks(&mut facts); + assert_eq!(reason_of(&facts), expected, "{facts:?}"); + } + } + + #[test] + fn an_unreadable_property_fails_the_check_that_needs_it() { + fn error() -> Result { + Err("Win32 5".to_owned()) + } + let mut facts = confined(); + facts.lpac_claim = error(); + assert_eq!(reason_of(&facts), reason::NOT_LPAC); + let mut facts = confined(); + facts.package = error(); + assert_eq!(reason_of(&facts), reason::NOT_LPAC); + let mut facts = confined(); + facts.capabilities = error(); + assert_eq!(reason_of(&facts), reason::CAPABILITIES_PRESENT); + let mut facts = confined(); + facts.restricting_sids = error(); + assert_eq!(reason_of(&facts), reason::RESTRICTING_SID_MISMATCH); + let mut facts = confined(); + facts.groups = error(); + assert_eq!(reason_of(&facts), reason::GROUP_NOT_DENY_ONLY); + let mut facts = confined(); + facts.privileges = error(); + assert_eq!(reason_of(&facts), reason::PRIVILEGES_PRESENT); + let mut facts = confined(); + facts.integrity = error(); + assert_eq!(reason_of(&facts), reason::INTEGRITY_NOT_UNTRUSTED); + } + + /// The checks run in the spec's order: with every property broken, the + /// first one is reported, and fixing it reveals the next. + #[test] + fn the_first_failing_check_in_order_is_reported() { + let mut facts = PrimaryFacts { + lpac_claim: Ok(Claim::Absent), + package: Ok(None), + capabilities: Ok(vec!["S-1-15-3-1".into()]), + restricting_sids: Ok(vec![]), + groups: Ok(vec![Group { + sid: "S-1-1-0".into(), + attributes: 7, + }]), + privileges: Ok(3), + integrity: Ok("S-1-16-4096".into()), + }; + let fixed = confined(); + let order = [ + reason::NOT_LPAC, + reason::CAPABILITIES_PRESENT, + reason::RESTRICTING_SID_MISMATCH, + reason::GROUP_NOT_DENY_ONLY, + reason::PRIVILEGES_PRESENT, + reason::INTEGRITY_NOT_UNTRUSTED, + ]; + for expected in order { + assert_eq!(reason_of(&facts), expected); + match expected { + reason::NOT_LPAC => { + facts.lpac_claim = fixed.lpac_claim.clone(); + facts.package = fixed.package.clone(); + } + reason::CAPABILITIES_PRESENT => facts.capabilities = fixed.capabilities.clone(), + reason::RESTRICTING_SID_MISMATCH => { + facts.restricting_sids = fixed.restricting_sids.clone() + } + reason::GROUP_NOT_DENY_ONLY => facts.groups = fixed.groups.clone(), + reason::PRIVILEGES_PRESENT => facts.privileges = fixed.privileges.clone(), + _ => facts.integrity = fixed.integrity.clone(), + } + } + assert_eq!(check(&facts), Ok(())); + } +} diff --git a/crates/basal-worker/src/confinement/windows/mitigation.rs b/crates/basal-worker/src/confinement/windows/mitigation.rs new file mode 100644 index 0000000..ca1d974 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/mitigation.rs @@ -0,0 +1,226 @@ +//! The mitigation policies the worker requires of its own process. +//! +//! The parent sets them as a creation attribute; the worker reads each policy +//! word back with `GetProcessMitigationPolicy` and checks single bits. Only +//! the listed bits are judged. Whole words are never compared: they differ +//! between Windows releases (Windows 11 reports an extra Win32k bit that +//! Server 2022 does not), and a bit added by a later release must not make a +//! correctly confined worker refuse to start. + +/// One policy word per mitigation policy the worker reads. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct MitigationWords { + /// `PROCESS_MITIGATION_DYNAMIC_CODE_POLICY`. + pub dynamic_code: u32, + /// `PROCESS_MITIGATION_BINARY_SIGNATURE_POLICY`. + pub signature: u32, + /// `PROCESS_MITIGATION_IMAGE_LOAD_POLICY`. + pub image_load: u32, + /// `PROCESS_MITIGATION_SYSTEM_CALL_DISABLE_POLICY`. + pub system_call: u32, + /// `PROCESS_MITIGATION_STRICT_HANDLE_CHECK_POLICY`. + pub strict_handle: u32, + /// `PROCESS_MITIGATION_EXTENSION_POINT_DISABLE_POLICY`. + pub extension_point: u32, + /// `PROCESS_MITIGATION_CHILD_PROCESS_POLICY`. + pub child_process: u32, +} + +/// Which policy word a bit lives in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Policy { + DynamicCode, + Signature, + ImageLoad, + SystemCall, + StrictHandle, + ExtensionPoint, + ChildProcess, +} + +impl Policy { + fn word(self, words: &MitigationWords) -> u32 { + match self { + Self::DynamicCode => words.dynamic_code, + Self::Signature => words.signature, + Self::ImageLoad => words.image_load, + Self::SystemCall => words.system_call, + Self::StrictHandle => words.strict_handle, + Self::ExtensionPoint => words.extension_point, + Self::ChildProcess => words.child_process, + } + } +} + +/// One named bit of a policy word. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bit { + pub policy: Policy, + pub mask: u32, + pub name: &'static str, +} + +const fn bit(policy: Policy, index: u32, name: &'static str) -> Bit { + Bit { + policy, + mask: 1 << index, + name, + } +} + +/// The bits that must be set. The positions are those of the bit fields in +/// the `PROCESS_MITIGATION_*_POLICY` structures. +pub const REQUIRED: [Bit; 10] = [ + // No executable memory can be created, or made executable, after load. + bit(Policy::DynamicCode, 0, "ProhibitDynamicCode"), + // Only Microsoft-signed DLLs load. + bit(Policy::Signature, 0, "MicrosoftSignedOnly"), + bit(Policy::ImageLoad, 0, "NoRemoteImages"), + bit(Policy::ImageLoad, 1, "NoLowMandatoryLabelImages"), + bit(Policy::ImageLoad, 2, "PreferSystem32Images"), + // No system calls into the GUI kernel component. + bit(Policy::SystemCall, 0, "DisallowWin32kSystemCalls"), + // Using an invalid handle raises an exception, and that cannot be undone. + bit( + Policy::StrictHandle, + 0, + "RaiseExceptionOnInvalidHandleReference", + ), + bit( + Policy::StrictHandle, + 1, + "HandleExceptionsPermanentlyEnabled", + ), + // No legacy extension points (AppInit DLLs, hooks and the like). + bit(Policy::ExtensionPoint, 0, "DisableExtensionPoints"), + bit(Policy::ChildProcess, 0, "NoChildProcessCreation"), +]; + +/// The bits that must be clear: each lets code in the process turn the +/// dynamic-code prohibition off again, for one thread or from outside. +pub const FORBIDDEN: [Bit; 2] = [ + bit(Policy::DynamicCode, 1, "AllowThreadOptOut"), + bit(Policy::DynamicCode, 2, "AllowRemoteDowngrade"), +]; + +/// Checks the words read back: every required bit set and every forbidden +/// bit clear. Any other bit is ignored. The error names every bit that is +/// wrong. +pub fn validate(words: &MitigationWords) -> Result<(), String> { + let mut wrong: Vec = Vec::new(); + for required in REQUIRED { + if required.policy.word(words) & required.mask == 0 { + wrong.push(format!("{} is off", required.name)); + } + } + for forbidden in FORBIDDEN { + if forbidden.policy.word(words) & forbidden.mask != 0 { + wrong.push(format!("{} is on", forbidden.name)); + } + } + if wrong.is_empty() { + Ok(()) + } else { + Err(format!("{} (read back {words:?})", wrong.join(", "))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The words measured in a fully confined child. Windows 11 reports + /// Win32k word 5 and Server 2022 word 1; both must pass. + fn measured(system_call: u32) -> MitigationWords { + MitigationWords { + dynamic_code: 1, + signature: 5, + image_load: 7, + system_call, + strict_handle: 3, + extension_point: 1, + child_process: 1, + } + } + + fn set(words: &mut MitigationWords, bit: Bit, on: bool) { + let word = match bit.policy { + Policy::DynamicCode => &mut words.dynamic_code, + Policy::Signature => &mut words.signature, + Policy::ImageLoad => &mut words.image_load, + Policy::SystemCall => &mut words.system_call, + Policy::StrictHandle => &mut words.strict_handle, + Policy::ExtensionPoint => &mut words.extension_point, + Policy::ChildProcess => &mut words.child_process, + }; + if on { + *word |= bit.mask; + } else { + *word &= !bit.mask; + } + } + + #[test] + fn the_measured_words_of_both_images_pass() { + assert_eq!(validate(&measured(5)), Ok(())); + assert_eq!(validate(&measured(1)), Ok(())); + } + + #[test] + fn exactly_the_required_bits_pass_and_other_bits_are_ignored() { + let mut words = MitigationWords::default(); + for required in REQUIRED { + set(&mut words, required, true); + } + assert_eq!(validate(&words), Ok(())); + // Bits nobody listed (audit modes, store signing, later additions) + // change nothing, in any word but the dynamic-code one, whose other + // bits include the forbidden opt-outs. + let mut noisy = words; + noisy.signature |= !1; + noisy.image_load |= !7; + noisy.system_call |= !1; + noisy.strict_handle |= !3; + noisy.extension_point |= !1; + noisy.child_process |= !1; + noisy.dynamic_code |= 1 << 3; + assert_eq!(validate(&noisy), Ok(())); + } + + #[test] + fn each_missing_required_bit_is_refused_by_name() { + for required in REQUIRED { + let mut words = measured(5); + set(&mut words, required, false); + let error = validate(&words).expect_err(required.name); + assert!( + error.starts_with(&format!("{} is off", required.name)), + "{error}" + ); + } + assert!(validate(&MitigationWords::default()).is_err()); + } + + /// Every required bit set does not excuse an opt-out: each forbidden bit + /// alone, together with all the required ones, is refused. + #[test] + fn every_required_bit_with_a_forbidden_opt_out_is_refused() { + let mut all_required = MitigationWords::default(); + for required in REQUIRED { + set(&mut all_required, required, true); + } + for forbidden in FORBIDDEN { + let mut words = all_required; + set(&mut words, forbidden, true); + let error = validate(&words).expect_err(forbidden.name); + assert!( + error.starts_with(&format!("{} is on", forbidden.name)), + "{error}" + ); + } + assert_eq!(FORBIDDEN[0].name, "AllowThreadOptOut"); + assert_eq!(FORBIDDEN[0].mask, 0x2); + assert_eq!(FORBIDDEN[1].name, "AllowRemoteDowngrade"); + assert_eq!(FORBIDDEN[1].mask, 0x4); + } +} diff --git a/crates/basal-worker/src/confinement/windows/native.rs b/crates/basal-worker/src/confinement/windows/native.rs new file mode 100644 index 0000000..1f75506 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/native.rs @@ -0,0 +1,623 @@ +//! The Win32 and native calls behind the worker's startup checks. +//! +//! Each reader reports what it found and leaves judging it to the pure +//! checks in [`super::attest`], [`super::mitigation`] and +//! [`super::allowlist`]. Native structure layouts are those of 64-bit +//! Windows, the only Windows target basal builds for. + +use super::allowlist::{HandleEntry, Stdio}; +use super::attest::{Claim, Group, LPAC_CLAIM, PrimaryFacts}; +use super::mitigation::MitigationWords; +use std::collections::BTreeMap; +use std::ffi::c_void; +use std::mem::size_of; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::{ + CloseHandle, DUPLICATE_SAME_ACCESS, DuplicateHandle, GetLastError, HANDLE, LocalFree, +}; +use windows_sys::Win32::Security::Authorization::{ConvertSidToStringSidW, ConvertStringSidToSidW}; +use windows_sys::Win32::Security::{ + CreateWellKnownSid, EqualSid, GetLengthSid, GetTokenInformation, PSID, SID_AND_ATTRIBUTES, + SetTokenInformation, TOKEN_APPCONTAINER_INFORMATION, TOKEN_GROUPS, TOKEN_INFORMATION_CLASS, + TOKEN_MANDATORY_LABEL, TOKEN_PRIVILEGES, TokenAppContainerSid, TokenCapabilities, TokenGroups, + TokenIntegrityLevel, TokenIsAppContainer, TokenPrivileges, TokenRestrictedSids, + TokenSecurityAttributes, WinUntrustedLabelSid, +}; +use windows_sys::Win32::System::Console::{ + GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, +}; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, GetProcessMitigationPolicy, PROCESS_MITIGATION_POLICY, + ProcessChildProcessPolicy, ProcessDynamicCodePolicy, ProcessExtensionPointDisablePolicy, + ProcessImageLoadPolicy, ProcessSignaturePolicy, ProcessStrictHandleCheckPolicy, + ProcessSystemCallDisablePolicy, +}; + +pub(super) type Result = std::result::Result; + +/// The pseudo-handle for this process's primary token +/// (`GetCurrentProcessToken`). Querying through it opens no handle, so the +/// token check leaves nothing behind in the handle table. +pub(super) const CURRENT_PROCESS_TOKEN: HANDLE = -4isize as HANDLE; + +/// `SE_GROUP_INTEGRITY`, the attribute of the integrity label in a token's +/// group list. +const SE_GROUP_INTEGRITY: u32 = 0x20; +/// `OBJ_INHERIT` in a handle-table entry's attributes. +pub(super) const OBJ_INHERIT: u32 = 0x2; +/// `STATUS_INFO_LENGTH_MISMATCH`: the buffer was too small. +const STATUS_INFO_LENGTH_MISMATCH: i32 = 0xc000_0004_u32 as i32; +/// `STATUS_BUFFER_OVERFLOW`: the buffer was too small for the whole result. +const STATUS_BUFFER_OVERFLOW: i32 = 0x8000_0005_u32 as i32; +/// `ProcessHandleInformation`, the process's own handle table. +const PROCESS_HANDLE_INFORMATION: u32 = 51; +/// `ObjectNameInformation`. +const OBJECT_NAME_INFORMATION: u32 = 1; +/// `ObjectTypesInformation`: every object type and its index. +const OBJECT_TYPES_INFORMATION: u32 = 3; +/// `TOKEN_SECURITY_ATTRIBUTE_TYPE_UINT64`. +const ATTRIBUTE_TYPE_UINT64: u16 = 2; + +#[link(name = "ntdll", kind = "raw-dylib")] +unsafe extern "system" { + fn NtQueryInformationProcess( + process: HANDLE, + class: u32, + buffer: *mut c_void, + length: u32, + returned: *mut u32, + ) -> i32; + fn NtQueryObject( + handle: HANDLE, + class: u32, + buffer: *mut c_void, + length: u32, + returned: *mut u32, + ) -> i32; + fn NtQueryInformationToken( + token: HANDLE, + class: TOKEN_INFORMATION_CLASS, + buffer: *mut c_void, + length: u32, + returned: *mut u32, + ) -> i32; +} + +/// The calling thread's last Win32 error, named after the call that failed. +pub(super) fn last(api: &str) -> String { + let code = unsafe { GetLastError() }; + format!( + "{api}: Win32 {code} ({})", + std::io::Error::from_raw_os_error(code as i32) + ) +} + +fn status(api: &str, status: i32) -> String { + format!("{api}: NTSTATUS {:#010x}", status as u32) +} + +/// Closes a handle this process owns and reports a failure. +pub(super) fn close(handle: HANDLE, what: &str) -> Result<()> { + if unsafe { CloseHandle(handle) } == 0 { + Err(last(&format!("CloseHandle({what})"))) + } else { + Ok(()) + } +} + +/// The package SID from `--package-sid=`, parsed by the system's own SID +/// parser. +pub struct PackageSid(PSID); + +impl PackageSid { + /// Parses SID text. Fails for text the system does not accept as a SID. + pub fn parse(text: &str) -> Result { + let wide: Vec = text.encode_utf16().chain(Some(0)).collect(); + let mut sid: PSID = null_mut(); + if unsafe { ConvertStringSidToSidW(wide.as_ptr(), &mut sid) } == 0 { + return Err(last(&format!("ConvertStringSidToSidW({text})"))); + } + Ok(Self(sid)) + } +} + +impl Drop for PackageSid { + fn drop(&mut self) { + // The SID was allocated by ConvertStringSidToSidW with LocalAlloc. + unsafe { LocalFree(self.0) }; + } +} + +/// A SID as text. +/// +/// # Safety +/// +/// `sid` must point to a valid SID. +unsafe fn sid_string(sid: PSID) -> Result { + unsafe { + let mut text = null_mut(); + if ConvertSidToStringSidW(sid, &mut text) == 0 { + return Err(last("ConvertSidToStringSidW")); + } + let mut length = 0; + while *text.add(length) != 0 { + length += 1; + } + let result = String::from_utf16_lossy(std::slice::from_raw_parts(text, length)); + LocalFree(text.cast()); + Ok(result) + } +} + +/// Reads one variable-length token information class into an aligned +/// buffer. +fn token_buffer(token: HANDLE, class: TOKEN_INFORMATION_CLASS) -> Result> { + unsafe { + let mut bytes = 0; + GetTokenInformation(token, class, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(last(&format!("GetTokenInformation(class {class}, size)"))); + } + // A list with no entries can be shorter than the C declaration, which + // reserves one entry. Keep room for it so a reference to the + // declaration never reaches past the allocation. + let allocation = (bytes as usize) + .max(size_of::()) + .max(size_of::()); + let mut data = vec![0usize; allocation.div_ceil(size_of::())]; + if GetTokenInformation(token, class, data.as_mut_ptr().cast(), bytes, &mut bytes) == 0 { + return Err(last(&format!("GetTokenInformation(class {class})"))); + } + Ok(data) + } +} + +/// The entries of a `TOKEN_GROUPS` buffer. +/// +/// # Safety +/// +/// `data` must hold a `TOKEN_GROUPS` structure returned by the system. +unsafe fn groups(data: &[usize]) -> &[SID_AND_ATTRIBUTES] { + unsafe { + let list = &*data.as_ptr().cast::(); + std::slice::from_raw_parts(list.Groups.as_ptr(), list.GroupCount as usize) + } +} + +fn sid_list(token: HANDLE, class: TOKEN_INFORMATION_CLASS) -> Result> { + let data = token_buffer(token, class)?; + unsafe { groups(&data) } + .iter() + .map(|entry| unsafe { sid_string(entry.Sid) }) + .collect() +} + +/// Reads every property the token check judges, each on its own. +pub(super) fn primary_facts(token: HANDLE, package: &PackageSid) -> PrimaryFacts { + PrimaryFacts { + lpac_claim: lpac_claim(token), + package: appcontainer(token, package), + capabilities: sid_list(token, TokenCapabilities), + restricting_sids: sid_list(token, TokenRestrictedSids), + groups: token_buffer(token, TokenGroups).and_then(|data| { + unsafe { groups(&data) } + .iter() + .map(|group| { + Ok(Group { + sid: unsafe { sid_string(group.Sid) }?, + attributes: group.Attributes, + }) + }) + .collect() + }), + privileges: token_buffer(token, TokenPrivileges).map(|data| unsafe { + (*data.as_ptr().cast::()).PrivilegeCount as usize + }), + integrity: token_buffer(token, TokenIntegrityLevel).and_then(|data| unsafe { + sid_string((*data.as_ptr().cast::()).Label.Sid) + }), + } +} + +/// The token's AppContainer SID, and whether it equals the package SID. +fn appcontainer(token: HANDLE, package: &PackageSid) -> Result> { + let is_app = token_buffer(token, TokenIsAppContainer)?; + if unsafe { *is_app.as_ptr().cast::() } == 0 { + return Ok(None); + } + let info = token_buffer(token, TokenAppContainerSid)?; + let sid = + unsafe { (*info.as_ptr().cast::()).TokenAppContainer }; + if sid.is_null() { + return Ok(None); + } + let equal = unsafe { EqualSid(sid, package.0) } != 0; + Ok(Some((unsafe { sid_string(sid) }?, equal))) +} + +// Token security attributes have no Win32 reader, so the claim is read with +// the native call. The layouts are those of +// TOKEN_SECURITY_ATTRIBUTES_INFORMATION and TOKEN_SECURITY_ATTRIBUTE_V1. +#[repr(C)] +struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *mut u16, +} + +impl UnicodeString { + /// # Safety + /// + /// `buffer` must hold `length` bytes of UTF-16, or be null. + unsafe fn text(&self) -> String { + if self.buffer.is_null() { + return String::new(); + } + String::from_utf16_lossy(unsafe { + std::slice::from_raw_parts(self.buffer, usize::from(self.length) / 2) + }) + } +} + +#[repr(C)] +struct SecurityAttribute { + name: UnicodeString, + value_type: u16, + reserved: u16, + flags: u32, + value_count: u32, + values: *mut c_void, +} + +#[repr(C)] +struct SecurityAttributes { + version: u16, + reserved: u16, + count: u32, + attributes: *mut SecurityAttribute, +} + +/// The `WIN://NOALLAPPPKG` attribute. The dedicated information class, +/// `TokenIsLessPrivilegedAppContainer`, fails with "invalid information +/// class" on Windows 11 and Server 2022, so the claim is read instead. +fn lpac_claim(token: HANDLE) -> Result { + unsafe { + let mut bytes = 0; + let size_status = + NtQueryInformationToken(token, TokenSecurityAttributes, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(status( + "NtQueryInformationToken(TokenSecurityAttributes, size)", + size_status, + )); + } + let mut data = vec![0usize; (bytes as usize).div_ceil(size_of::())]; + let read = NtQueryInformationToken( + token, + TokenSecurityAttributes, + data.as_mut_ptr().cast(), + bytes, + &mut bytes, + ); + if read != 0 { + return Err(status( + "NtQueryInformationToken(TokenSecurityAttributes)", + read, + )); + } + let header = &*data.as_ptr().cast::(); + if header.version != 1 { + return Err(format!( + "unsupported token security attributes version {}", + header.version + )); + } + if header.count == 0 { + return Ok(Claim::Absent); + } + let entries = std::slice::from_raw_parts(header.attributes, header.count as usize); + for entry in entries { + if entry.name.text() != LPAC_CLAIM { + continue; + } + if entry.value_type != ATTRIBUTE_TYPE_UINT64 { + return Ok(Claim::OtherType(entry.value_type)); + } + let values = if entry.value_count == 0 { + Vec::new() + } else { + std::slice::from_raw_parts(entry.values.cast::(), entry.value_count as usize) + .to_vec() + }; + return Ok(Claim::Unsigned(values)); + } + Ok(Claim::Absent) + } +} + +/// Sets the token's integrity label to Untrusted, `S-1-16-0`. The handle +/// needs `TOKEN_ADJUST_DEFAULT`. +pub(super) fn set_untrusted(token: HANDLE) -> Result<()> { + unsafe { + // SECURITY_MAX_SID_SIZE is 68 bytes. + let mut sid = [0usize; 68usize.div_ceil(size_of::())]; + let mut bytes = size_of_val(&sid) as u32; + if CreateWellKnownSid( + WinUntrustedLabelSid, + null_mut(), + sid.as_mut_ptr().cast(), + &mut bytes, + ) == 0 + { + return Err(last("CreateWellKnownSid(Untrusted)")); + } + let label = TOKEN_MANDATORY_LABEL { + Label: SID_AND_ATTRIBUTES { + Sid: sid.as_mut_ptr().cast(), + Attributes: SE_GROUP_INTEGRITY, + }, + }; + let length = size_of::() + GetLengthSid(label.Label.Sid) as usize; + if SetTokenInformation( + token, + TokenIntegrityLevel, + (&label as *const TOKEN_MANDATORY_LABEL).cast(), + length as u32, + ) == 0 + { + return Err(last("SetTokenInformation(TokenIntegrityLevel)")); + } + Ok(()) + } +} + +/// Reads this process's seven mitigation policy words. +pub(super) fn mitigation_words() -> Result { + let read = |policy: PROCESS_MITIGATION_POLICY, name: &str| -> Result { + let mut word = 0u32; + let ok = unsafe { + GetProcessMitigationPolicy( + GetCurrentProcess(), + policy, + (&mut word as *mut u32).cast(), + size_of::(), + ) + }; + if ok == 0 { + return Err(last(&format!("GetProcessMitigationPolicy({name})"))); + } + Ok(word) + }; + Ok(MitigationWords { + dynamic_code: read(ProcessDynamicCodePolicy, "dynamic code")?, + signature: read(ProcessSignaturePolicy, "signature")?, + image_load: read(ProcessImageLoadPolicy, "image load")?, + system_call: read(ProcessSystemCallDisablePolicy, "system call disable")?, + strict_handle: read(ProcessStrictHandleCheckPolicy, "strict handle check")?, + extension_point: read(ProcessExtensionPointDisablePolicy, "extension point")?, + child_process: read(ProcessChildProcessPolicy, "child process")?, + }) +} + +/// The values of this process's standard input, output and error handles. +pub(super) fn standard_handles() -> [usize; 3] { + [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] + .map(|which| unsafe { GetStdHandle(which) } as usize) +} + +/// One entry of `PROCESS_HANDLE_SNAPSHOT_INFORMATION`. +#[repr(C)] +#[derive(Clone, Copy)] +pub(super) struct SnapshotEntry { + pub(super) handle: HANDLE, + handle_count: usize, + pointer_count: usize, + pub(super) access: u32, + pub(super) type_index: u32, + pub(super) attributes: u32, + reserved: u32, +} + +/// Lists this process's handle table. Only the values, types and flags are +/// read: no snapshot handle is used as a query target, because another +/// thread can close it meanwhile, and strict handle checks make a call on a +/// closed handle end the process. +pub(super) fn handle_snapshot() -> Result> { + let mut bytes = 64 * 1024u32; + for _ in 0..8 { + let mut buffer = vec![0usize; bytes as usize / size_of::()]; + let mut returned = 0; + let result = unsafe { + NtQueryInformationProcess( + GetCurrentProcess(), + PROCESS_HANDLE_INFORMATION, + buffer.as_mut_ptr().cast(), + bytes, + &mut returned, + ) + }; + if result == STATUS_INFO_LENGTH_MISMATCH { + // Handles can be created between the two calls; leave room. + bytes = returned.max(bytes).saturating_mul(2); + continue; + } + if result < 0 { + return Err(status( + "NtQueryInformationProcess(ProcessHandleInformation)", + result, + )); + } + // The header is two pointer-sized fields: the count and a reserved + // field. The entries follow. + let count = buffer[0]; + let capacity = (bytes as usize - 2 * size_of::()) / size_of::(); + if count > capacity { + return Err(format!("handle snapshot count {count} exceeds its buffer")); + } + let entries = unsafe { + std::slice::from_raw_parts(buffer.as_ptr().add(2).cast::(), count) + }; + return Ok(entries.to_vec()); + } + Err("the handle snapshot kept growing".into()) +} + +/// One entry of `OBJECT_TYPES_INFORMATION`, the fixed part of +/// `OBJECT_TYPE_INFORMATION`. The type's name follows it. +#[repr(C)] +struct ObjectTypeInformation { + name: UnicodeString, + counters: [u32; 13], + generic_mapping: [u32; 4], + valid_access_mask: u32, + security_required: u8, + maintain_handle_count: u8, + type_index: u8, + reserved: u8, + pool: [u32; 3], +} + +/// Every object type's name by its index, the index handle tables use. +pub(super) fn object_types() -> Result> { + let mut bytes = 64 * 1024u32; + for _ in 0..8 { + let mut buffer = vec![0usize; bytes as usize / size_of::()]; + let mut returned = 0; + let result = unsafe { + NtQueryObject( + null_mut(), + OBJECT_TYPES_INFORMATION, + buffer.as_mut_ptr().cast(), + bytes, + &mut returned, + ) + }; + if result == STATUS_INFO_LENGTH_MISMATCH { + bytes = returned.max(bytes).saturating_mul(2); + continue; + } + if result < 0 { + return Err(status("NtQueryObject(ObjectTypesInformation)", result)); + } + let base = buffer.as_ptr().cast::(); + let end = (returned as usize).min(bytes as usize); + let count = unsafe { *base.cast::() }; + // The first entry is aligned to a pointer after the 4-byte count; each + // later one follows the previous entry's name, aligned the same way. + let mut offset = size_of::(); + let mut types = BTreeMap::new(); + for _ in 0..count { + if offset + size_of::() > end { + return Err("truncated ObjectTypesInformation".into()); + } + let info = unsafe { &*base.add(offset).cast::() }; + types.insert(u32::from(info.type_index), unsafe { info.name.text() }); + offset = (offset + + size_of::() + + usize::from(info.name.maximum_length)) + .next_multiple_of(size_of::()); + } + return Ok(types); + } + Err("the object type list kept growing".into()) +} + +/// The object name behind a handle, read through a duplicate so that the +/// table's own handle is never a query target. +pub(super) fn object_name_via_duplicate(handle: HANDLE) -> Result { + unsafe { + let process = GetCurrentProcess(); + let mut duplicate = null_mut(); + if DuplicateHandle( + process, + handle, + process, + &mut duplicate, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) == 0 + { + return Err(last("DuplicateHandle(directory)")); + } + let name = object_name(duplicate); + close(duplicate, "directory duplicate")?; + name + } +} + +fn object_name(handle: HANDLE) -> Result { + let mut bytes = 1024u32; + for _ in 0..2 { + let mut buffer = vec![0usize; bytes as usize / size_of::()]; + let mut returned = 0; + let result = unsafe { + NtQueryObject( + handle, + OBJECT_NAME_INFORMATION, + buffer.as_mut_ptr().cast(), + bytes, + &mut returned, + ) + }; + if (result == STATUS_INFO_LENGTH_MISMATCH || result == STATUS_BUFFER_OVERFLOW) + && returned > bytes + { + bytes = returned.next_multiple_of(size_of::() as u32); + continue; + } + if result < 0 { + return Err(status("NtQueryObject(ObjectNameInformation)", result)); + } + // The name's characters are in the same buffer, after the header. + let name = unsafe { &*buffer.as_ptr().cast::() }; + let start = name.buffer as usize; + let base = buffer.as_ptr() as usize; + if !name.buffer.is_null() + && (start < base || start + usize::from(name.length) > base + bytes as usize) + { + return Err("object name outside its buffer".into()); + } + return Ok(unsafe { name.text() }); + } + Err("the object name kept growing".into()) +} + +/// Which standard handle `value` is. +pub(super) fn stdio_slot(value: usize, standard: &[usize; 3]) -> Option { + [Stdio::Input, Stdio::Output, Stdio::Error] + .into_iter() + .zip(standard) + .find(|(_, handle)| **handle == value) + .map(|(slot, _)| slot) +} + +/// The handle table as the allowlist judges it. +pub(super) fn handle_table() -> Result> { + let types = object_types()?; + let standard = standard_handles(); + let snapshot = handle_snapshot()?; + let mut table: Vec = snapshot + .iter() + .map(|entry| HandleEntry { + value: entry.handle as usize, + type_name: types + .get(&entry.type_index) + .cloned() + .unwrap_or_else(|| format!("unknown type {}", entry.type_index)), + access: entry.access, + inheritable: entry.attributes & OBJ_INHERIT != 0, + stdio: stdio_slot(entry.handle as usize, &standard), + name: None, + }) + .collect(); + // Only a single Directory handle is named. With two or more the table is + // refused anyway, and nothing is queried. + let directories: Vec = (0..table.len()) + .filter(|&index| table[index].type_name == "Directory") + .collect(); + if let [index] = directories[..] { + table[index].name = Some(object_name_via_duplicate(snapshot[index].handle)?); + } + Ok(table) +} diff --git a/crates/basal-worker/src/confinement/windows/startup.rs b/crates/basal-worker/src/confinement/windows/startup.rs new file mode 100644 index 0000000..a71f228 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/startup.rs @@ -0,0 +1,133 @@ +//! The startup sequence: the six checks in order, before the first frame +//! read. See the parent module for what each step guards. + +use super::allowlist::{self, closed_at_startup}; +use super::native::{self, CURRENT_PROCESS_TOKEN, OBJ_INHERIT, PackageSid, close, last}; +use super::{Arguments, Refusal, attest, mitigation, reason}; +use crate::confinement::{ConfinementError, Entered}; +use basal_proto::Confinement; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::{ERROR_NO_TOKEN, GetLastError, HANDLE}; +use windows_sys::Win32::Security::{RevertToSelf, TOKEN_ADJUST_DEFAULT, TOKEN_QUERY}; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, GetCurrentThread, OpenProcessToken, OpenThreadToken, +}; + +/// Runs the six startup checks. On success the worker is fully confined and +/// may read input; the report it sends in its welcome is all true, because a +/// worker that failed any check has already exited. +pub fn enter(package: &PackageSid, arguments: &Arguments) -> Result { + run(package, arguments).map_err(ConfinementError::Windows)?; + Ok(Entered { + confinement: Confinement::Windows { + lpac: true, + untrusted: true, + no_thread_token: true, + mitigations: true, + handle_table: true, + }, + descriptors: Vec::new(), + }) +} + +fn run(package: &PackageSid, arguments: &Arguments) -> Result<(), Refusal> { + // The handle used to lower the primary token is opened first, while the + // main thread still impersonates the start-up token. Once the thread has + // reverted, access checks use the restricted primary, which is not + // allowed to open its own token for adjustment. + // A failure here is reported by step 2, after step 1 has run. + let adjustment = open_primary(if arguments.opens_primary_read_only() { + TOKEN_QUERY + } else { + TOKEN_QUERY | TOKEN_ADJUST_DEFAULT + }); + drop_thread_token(arguments.keeps_thread_token())?; + lower_integrity(adjustment, arguments.skips_lowering())?; + close_startup_leftovers()?; + attest::check(&native::primary_facts(CURRENT_PROCESS_TOKEN, package))?; + let words = native::mitigation_words() + .map_err(|error| Refusal::new(reason::MITIGATION_MISMATCH, error))?; + mitigation::validate(&words) + .map_err(|error| Refusal::new(reason::MITIGATION_MISMATCH, error))?; + let table = + native::handle_table().map_err(|error| Refusal::new(reason::HANDLE_NOT_ALLOWED, error))?; + allowlist::check(&table).map_err(|error| Refusal::new(reason::HANDLE_NOT_ALLOWED, error))?; + Ok(()) +} + +/// A token handle closed when dropped, unless closed explicitly first. +struct Token(HANDLE); + +impl Drop for Token { + fn drop(&mut self) { + if !self.0.is_null() { + let _ = close(self.0, "token"); + } + } +} + +fn open_primary(access: u32) -> Result { + let mut token = null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), access, &mut token) } == 0 { + return Err(last("OpenProcessToken(own primary)")); + } + Ok(Token(token)) +} + +/// Step 1: stop impersonating the start-up token, then require that the +/// thread has no token at all (`ERROR_NO_TOKEN`). +fn drop_thread_token(keep: bool) -> Result<(), Refusal> { + let refuse = |detail: String| Refusal::new(reason::THREAD_TOKEN_PRESENT, detail); + if !keep && unsafe { RevertToSelf() } == 0 { + return Err(refuse(last("RevertToSelf"))); + } + let mut token = null_mut(); + // Open-as-self checks access against the process's primary token rather + // than against whatever token the thread may still carry. + if unsafe { OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, 1, &mut token) } != 0 { + let _ = close(token, "thread token"); + return Err(refuse("the main thread still has a token".into())); + } + let error = unsafe { GetLastError() }; + if error != ERROR_NO_TOKEN { + return Err(refuse(format!( + "OpenThreadToken: Win32 {error}, not ERROR_NO_TOKEN" + ))); + } + Ok(()) +} + +/// Step 2: lower the actual primary from Low to Untrusted, then close the +/// handle that could adjust it, so none survives into the input phase. +fn lower_integrity(adjustment: Result, skip: bool) -> Result<(), Refusal> { + let refuse = |detail: String| Refusal::new(reason::INTEGRITY_LOWER_FAILED, detail); + let mut token = adjustment.map_err(refuse)?; + if !skip { + native::set_untrusted(token.0).map_err(refuse)?; + } + let handle = std::mem::replace(&mut token.0, null_mut()); + close(handle, "primary adjustment handle").map_err(refuse) +} + +/// Step 3: close the ALPC port and private File handles the loader left (see +/// [`closed_at_startup`]). A handle that cannot be listed or closed refuses +/// startup as `handle-not-allowed`, the check that would otherwise see it. +fn close_startup_leftovers() -> Result<(), Refusal> { + let refuse = |detail: String| Refusal::new(reason::HANDLE_NOT_ALLOWED, detail); + let types = native::object_types().map_err(refuse)?; + let standard = native::standard_handles(); + for entry in native::handle_snapshot().map_err(refuse)? { + let Some(type_name) = types.get(&entry.type_index) else { + continue; + }; + let value = entry.handle as usize; + if closed_at_startup( + type_name, + standard.contains(&value), + entry.attributes & OBJ_INHERIT != 0, + ) { + close(entry.handle, &format!("{type_name} {value:#x}")).map_err(refuse)?; + } + } + Ok(()) +} diff --git a/crates/basal-worker/src/main.rs b/crates/basal-worker/src/main.rs index 0382b3b..cb86981 100644 --- a/crates/basal-worker/src/main.rs +++ b/crates/basal-worker/src/main.rs @@ -1,9 +1,16 @@ //! Entry point for `ck-basal-worker`. //! -//! Started by basal with an explicit Landlock policy on Linux, the worker confines itself and then -//! serves frames on stdin and stdout. Logs go to stderr. The other modes are -//! `--confinement-probe`, which reports what the sandbox denies, and -//! `--version`, which prints the version and the build revision. +//! Started by basal with an explicit Landlock policy on Linux, and through +//! basal-launch with `--package-sid=` on Windows, the worker confines +//! itself and then serves frames on stdin and stdout. Logs go to stderr. The +//! other modes are `--confinement-probe`, which reports what the sandbox +//! denies, and `--version`, which prints the version and the build revision. +//! +//! On Windows the image is a GUI-subsystem one, so that no console host is +//! started for it: under the confined token, console initialisation fails in +//! the loader (`STATUS_DLL_INIT_FAILED`) before the worker's first +//! instruction. The worker talks only through its three inherited pipes. +#![cfg_attr(windows, windows_subsystem = "windows")] use std::io::{self, BufWriter}; use std::process::ExitCode; @@ -30,6 +37,10 @@ fn main() -> ExitCode { } #[cfg(target_os = "linux")] Some("--landlock=required" | "--landlock=optional") if args.len() == 1 => {} + // The Windows engine arguments are checked in full below. + #[cfg(windows)] + Some(_) => {} + #[cfg(not(windows))] Some(other) => { eprintln!("ck-basal-worker: unknown argument {other}"); return ExitCode::from(64); @@ -49,7 +60,30 @@ fn main() -> ExitCode { ), Err(confinement::linux::ArgumentError::Usage) => return ExitCode::from(64), }; - #[cfg(not(target_os = "linux"))] + #[cfg(windows)] + let result = { + use confinement::windows::{self, ArgumentError, PackageSid, Refusal, reason}; + match windows::engine_arguments(&args) { + Ok(arguments) => match PackageSid::parse(&arguments.package_sid) { + Ok(package) => windows::enter(&package, &arguments), + Err(error) => { + eprintln!("ck-basal-worker: unparsable --package-sid: {error}"); + return ExitCode::from(64); + } + }, + Err(ArgumentError::Missing) => { + Err(confinement::ConfinementError::Windows(Refusal::new( + reason::PACKAGE_SID_ARGUMENT_MISSING, + "no --package-sid= argument", + ))) + } + Err(ArgumentError::Usage(detail)) => { + eprintln!("ck-basal-worker: {detail}"); + return ExitCode::from(64); + } + } + }; + #[cfg(not(any(target_os = "linux", windows)))] let result = confinement::enter(); let entered = match result { Ok(entered) => entered, diff --git a/crates/basal-worker/tests/codemode.rs b/crates/basal-worker/tests/codemode.rs index c748e61..b9d4cf2 100644 --- a/crates/basal-worker/tests/codemode.rs +++ b/crates/basal-worker/tests/codemode.rs @@ -1,4 +1,5 @@ //! Codemode's confined wire contract, without the flow parent's journal or hosts. + mod common; use std::time::Duration; diff --git a/crates/basal-worker/tests/inherited_descriptors.rs b/crates/basal-worker/tests/inherited_descriptors.rs index a34d6cf..c86f7e8 100644 --- a/crates/basal-worker/tests/inherited_descriptors.rs +++ b/crates/basal-worker/tests/inherited_descriptors.rs @@ -1,4 +1,5 @@ //! The real worker must discard inherited authority even in probe mode. +#![cfg(unix)] mod common; diff --git a/crates/basal-worker/tests/ipc.rs b/crates/basal-worker/tests/ipc.rs index 3ea67c9..ed86ef1 100644 --- a/crates/basal-worker/tests/ipc.rs +++ b/crates/basal-worker/tests/ipc.rs @@ -85,6 +85,17 @@ fn handshake_reports_version_engine_and_confinement() { assert_eq!(welcome.protocol_version, PROTOCOL_VERSION); #[cfg(target_os = "macos")] assert_eq!(welcome.confinement, Confinement::Seatbelt); + #[cfg(windows)] + assert_eq!( + welcome.confinement, + Confinement::Windows { + lpac: true, + untrusted: true, + no_thread_token: true, + mitigations: true, + handle_table: true, + } + ); #[cfg(target_os = "linux")] assert!(matches!( welcome.confinement, diff --git a/crates/basal-worker/tests/sandbox_integrity.rs b/crates/basal-worker/tests/sandbox_integrity.rs index 4870a2b..2cb6173 100644 --- a/crates/basal-worker/tests/sandbox_integrity.rs +++ b/crates/basal-worker/tests/sandbox_integrity.rs @@ -1,4 +1,5 @@ //! Adversarial scripts at the sandbox's function and exception boundaries. + mod common; use basal_proto::{ActivationResult, Failure}; diff --git a/crates/basal-worker/tests/windows_common/mod.rs b/crates/basal-worker/tests/windows_common/mod.rs new file mode 100644 index 0000000..0602c35 --- /dev/null +++ b/crates/basal-worker/tests/windows_common/mod.rs @@ -0,0 +1,33 @@ +//! Shared by the Windows worker tests. +//! +//! Test processes never run under a production executable name (`ck-*`), so +//! a test process can't be mistaken for the placed production fleet. The +//! worker is copied to a `ckdev-` name before it is run or inspected, as +//! basal-testkit's `dev_binary` does on the other systems. + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// A `ckdev-` named copy of the built worker, in a directory of its own so +/// that granting the worker's package access changes no other ACL. A build +/// with the `deviations` feature gets its own directory, so the two test runs +/// never replace each other's image. +pub fn dev_binary(built: &str) -> &'static Path { + static COPY: OnceLock = OnceLock::new(); + COPY.get_or_init(|| { + let built = Path::new(built); + let directory = built.parent().expect("the worker has a directory").join( + if cfg!(feature = "deviations") { + "ckdev-placed-deviations" + } else { + "ckdev-placed" + }, + ); + std::fs::create_dir_all(&directory).expect("create the placement directory"); + let copy = directory.join("ckdev-basal-worker.exe"); + std::fs::copy(built, ©).expect("copy the worker"); + copy + }) +} diff --git a/crates/basal-worker/tests/windows_image.rs b/crates/basal-worker/tests/windows_image.rs new file mode 100644 index 0000000..9c20365 --- /dev/null +++ b/crates/basal-worker/tests/windows_image.rs @@ -0,0 +1,213 @@ +//! The built worker image: a GUI-subsystem executable that imports no DLL +//! which would load User32, GDI or COM into the confined process, and no C +//! runtime DLL. +//! +//! A static Userenv or Ole32 import once pulled User32, GDI and Win32u into +//! the confined child and left it with over a hundred handles before input; +//! those DLLs are loaded at run time in the parent only. The C runtime is +//! linked statically (`+crt-static` for this target in `.cargo/config.toml`), +//! so the worker never depends on a separately installed runtime DLL. The +//! console subsystem would start a console host the confined token cannot +//! initialise. +#![cfg(windows)] + +use std::path::Path; + +mod windows_common; + +/// `IMAGE_SUBSYSTEM_WINDOWS_GUI`. +const GUI_SUBSYSTEM: u16 = 2; + +/// DLLs whose import would load GUI or COM code into the worker. +const FORBIDDEN: [&str; 5] = [ + "userenv.dll", + "ole32.dll", + "user32.dll", + "gdi32.dll", + "win32u.dll", +]; + +/// What the import test reads from a PE image. +struct Image { + subsystem: u16, + imports: Vec, + delay_imports: Vec, +} + +fn u16_at(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes(bytes[offset..offset + 2].try_into().expect("two bytes")) +} + +fn u32_at(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("four bytes")) +} + +fn u64_at(bytes: &[u8], offset: usize) -> u64 { + u64::from_le_bytes(bytes[offset..offset + 8].try_into().expect("eight bytes")) +} + +/// Reads the subsystem and the import and delay-import DLL names of a +/// 64-bit PE image. +fn parse(bytes: &[u8]) -> Image { + assert_eq!(&bytes[..2], b"MZ", "not a PE image"); + let pe = u32_at(bytes, 0x3c) as usize; + assert_eq!(&bytes[pe..pe + 4], b"PE\0\0", "no PE signature"); + let coff = pe + 4; + let sections = u16_at(bytes, coff + 2) as usize; + let optional_size = u16_at(bytes, coff + 16) as usize; + let optional = coff + 20; + assert_eq!(u16_at(bytes, optional), 0x20b, "not a PE32+ image"); + let subsystem = u16_at(bytes, optional + 68); + let image_base = u64_at(bytes, optional + 24); + let directories = u32_at(bytes, optional + 108) as usize; + let directory = |index: usize| -> (u32, u32) { + if index >= directories { + return (0, 0); + } + let entry = optional + 112 + index * 8; + (u32_at(bytes, entry), u32_at(bytes, entry + 4)) + }; + + // (virtual address, virtual size, raw size, raw offset) per section. + let table = optional + optional_size; + let sections: Vec<(u32, u32, u32, u32)> = (0..sections) + .map(|index| { + let header = table + index * 40; + ( + u32_at(bytes, header + 12), + u32_at(bytes, header + 8), + u32_at(bytes, header + 16), + u32_at(bytes, header + 20), + ) + }) + .collect(); + let offset = |rva: u32| -> usize { + let (address, _, _, raw) = sections + .iter() + .copied() + .find(|&(address, virtual_size, raw_size, _)| { + rva >= address && rva < address + virtual_size.max(raw_size) + }) + .unwrap_or_else(|| panic!("RVA {rva:#x} is in no section")); + (rva - address + raw) as usize + }; + let name = |rva: u32| -> String { + let start = offset(rva); + let end = bytes[start..] + .iter() + .position(|&byte| byte == 0) + .expect("a NUL-terminated name"); + String::from_utf8_lossy(&bytes[start..start + end]).into_owned() + }; + + let mut imports = Vec::new(); + let (import_rva, import_size) = directory(1); + if import_rva != 0 && import_size != 0 { + // IMAGE_IMPORT_DESCRIPTOR, 20 bytes, the name RVA at offset 12; the + // list ends with an all-zero descriptor. + let mut descriptor = offset(import_rva); + while bytes[descriptor..descriptor + 20] + .iter() + .any(|&byte| byte != 0) + { + imports.push(name(u32_at(bytes, descriptor + 12))); + descriptor += 20; + } + } + let mut delay_imports = Vec::new(); + let (delay_rva, delay_size) = directory(13); + if delay_rva != 0 && delay_size != 0 { + // IMAGE_DELAYLOAD_DESCRIPTOR, 32 bytes: attributes, then the name. + // Attribute bit 0 clear means the name is a virtual address. + let mut descriptor = offset(delay_rva); + while bytes[descriptor..descriptor + 32] + .iter() + .any(|&byte| byte != 0) + { + let attributes = u32_at(bytes, descriptor); + let mut rva = u32_at(bytes, descriptor + 4); + if attributes & 1 == 0 { + rva = (u64::from(rva) - image_base) as u32; + } + delay_imports.push(name(rva)); + descriptor += 32; + } + } + Image { + subsystem, + imports, + delay_imports, + } +} + +fn is_c_runtime(name: &str) -> bool { + (name.starts_with("vcruntime") && name.ends_with(".dll")) + || (name.starts_with("msvcp") && name.ends_with(".dll")) + || name == "ucrtbase.dll" + || name.starts_with("api-ms-win-crt-") +} + +#[test] +fn the_worker_is_a_gui_image_without_gui_com_or_c_runtime_imports() { + let path = windows_common::dev_binary(env!("CARGO_BIN_EXE_ck-basal-worker")); + let image = parse(&std::fs::read(path).expect("read the worker image")); + println!("subsystem: {}", image.subsystem); + println!("imports: {:?}", image.imports); + println!("delay imports: {:?}", image.delay_imports); + assert_eq!(image.subsystem, GUI_SUBSYSTEM); + + let all: Vec = image + .imports + .iter() + .chain(&image.delay_imports) + .map(|name| name.to_ascii_lowercase()) + .collect(); + // The parse found the import table: every Windows program imports + // kernel32, so an empty or misread table cannot pass the checks below. + assert!(all.iter().any(|name| name == "kernel32.dll"), "{all:?}"); + for name in &all { + assert!(!FORBIDDEN.contains(&name.as_str()), "imports {name}"); + assert!(!is_c_runtime(name), "imports the C runtime DLL {name}"); + } + + // Once the import list read from a production build of the worker is + // committed at this path, every import must be on it, so a new DLL + // dependency is a reviewed change. API-set names (`api-ms-win-*`) are names, not paths. + let listed = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/windows-imports.txt"); + if let Ok(text) = std::fs::read_to_string(&listed) { + let allowed: Vec = text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_ascii_lowercase) + .collect(); + for name in &all { + assert!( + allowed.contains(name), + "{name} is not in {}", + listed.display() + ); + } + } +} + +#[test] +fn the_c_runtime_names_are_recognised() { + for name in [ + "vcruntime140.dll", + "vcruntime140_1.dll", + "msvcp140.dll", + "ucrtbase.dll", + "api-ms-win-crt-runtime-l1-1-0.dll", + ] { + assert!(is_c_runtime(name), "{name}"); + } + for name in [ + "kernel32.dll", + "ntdll.dll", + "advapi32.dll", + "api-ms-win-core-synch-l1-2-0.dll", + ] { + assert!(!is_c_runtime(name), "{name}"); + } +} diff --git a/crates/basal-worker/tests/windows_startup.rs b/crates/basal-worker/tests/windows_startup.rs new file mode 100644 index 0000000..f0ad376 --- /dev/null +++ b/crates/basal-worker/tests/windows_startup.rs @@ -0,0 +1,373 @@ +//! The worker's Windows startup, run for real: every spawn but two goes +//! through basal-launch under the full confinement, or under one of its test +//! variants that breaks exactly one property a startup check guards. +//! +//! The two exceptions start the worker directly, without a package SID or +//! with an unparsable one. The worker refuses those before it confines +//! itself or reads anything, and the launcher always passes a valid SID, so +//! it cannot produce them. +#![cfg(windows)] + +use basal_launch::{ + ConfinedProcess, Deviation, LaunchOptions, create_or_open_profile, grant_test_binary_directory, + launch, +}; +use basal_proto::{ + ActivationRequest, ActivationResult, Budgets, CallKind, Confinement, FLOW_JOB_COMMIT_BYTES, + JsonText, Outcome, PROTOCOL_VERSION, ParentMessage, Primitive, Profile, Settlement, + WorkerMessage, read_worker_message, write_parent_message, +}; +use std::fs::File; +use std::io::Read; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::OnceLock; + +mod windows_common; + +/// The worker cargo built for these tests, under a development name. +fn worker() -> &'static Path { + windows_common::dev_binary(env!("CARGO_BIN_EXE_ck-basal-worker")) +} + +/// The worker, with its directory readable and executable by the worker's +/// package, so that the confined process can load its own image. +fn placed_worker() -> &'static Path { + static GRANTED: OnceLock<()> = OnceLock::new(); + GRANTED.get_or_init(|| { + let package = create_or_open_profile().expect("worker profile"); + grant_test_binary_directory(worker(), &package) + .expect("grant the package read and execute"); + }); + worker() +} + +fn start(deviation: Deviation) -> ConfinedProcess { + let options = LaunchOptions::new(placed_worker(), FLOW_JOB_COMMIT_BYTES).deviation(deviation); + launch(&options).unwrap_or_else(|error| panic!("launch {deviation}: {error}")) +} + +/// How a worker ended: its exit code and everything it wrote to stderr. +struct Ended { + code: u32, + stderr: String, +} + +impl Ended { + /// The reason token in a startup refusal line, + /// `ck-basal-worker: refusing to run unconfined: : `. + fn reason(&self) -> Option<&str> { + let rest = self.stderr.split("refusing to run unconfined: ").nth(1)?; + rest.split(':').next() + } +} + +/// Closes the worker's stdin and waits for it to end. A worker that refused +/// to start has already exited; one that started anyway reads end of input +/// and exits 0, so a missing refusal shows up as a wrong exit code rather +/// than a test that never finishes. +fn end(mut child: ConfinedProcess) -> Ended { + drop(child.stdin.take()); + let mut stderr = String::new(); + child + .stderr + .take() + .expect("stderr") + .read_to_string(&mut stderr) + .expect("read the worker's stderr"); + let code = child.wait().expect("wait for the worker"); + Ended { code, stderr } +} + +/// Ends the worker and fails the test with what it wrote to stderr. +fn fail(child: ConfinedProcess, what: String) -> ! { + let ended = end(child); + panic!( + "{what}; worker exit {:#x}, stderr: {}", + ended.code, ended.stderr + ) +} + +/// Launches the worker under `deviation` and requires it to refuse at the +/// startup check that variant breaks: exit 70, that reason on stderr. +fn assert_refused(deviation: Deviation) { + let expected = deviation + .worker_reason() + .unwrap_or_else(|| panic!("{deviation} is not a worker check")); + let ended = end(start(deviation)); + println!( + "{deviation}: exit {} stderr {}", + ended.code, + ended.stderr.trim() + ); + assert_eq!(ended.code, 70, "{deviation}: {}", ended.stderr); + assert_eq!(ended.reason(), Some(expected), "{}", ended.stderr); +} + +/// A Windows report with every property true: the only report a worker that +/// passed its startup checks sends. +const CONFINED: Confinement = Confinement::Windows { + lpac: true, + untrusted: true, + no_thread_token: true, + mitigations: true, + handle_table: true, +}; + +/// The time the test parent answers a clock read with: 2026-01-01T00:00:00Z. +const NOW_MS: &str = "1767225600000"; + +/// A real activation under the full confinement: the worker passes all six +/// startup checks with the launcher's environment, reports the Windows +/// confinement, runs a script that makes a synchronous host call, returns its +/// result and shuts down cleanly. The parent also reads the worker's primary +/// back and finds it Untrusted, which only the worker's own lowering makes it. +#[test] +fn an_activation_completes_under_the_full_confinement() { + let mut child = start(Deviation::Full); + let mut stdin = child.stdin.take().expect("stdin"); + let mut stdout = child.stdout.take().expect("stdout"); + + write_parent_message( + &mut stdin, + &ParentMessage::Hello { + protocol_version: PROTOCOL_VERSION, + }, + ) + .expect("send hello"); + let welcome = match read_worker_message(&mut stdout) { + Ok(WorkerMessage::Welcome(welcome)) => welcome, + other => fail(child, format!("no welcome: {other:?}")), + }; + println!("full: welcome {welcome:?}"); + assert_eq!(welcome.confinement, CONFINED); + + let token = child.primary_token().expect("read the worker's primary"); + println!("full: primary after startup {token:?}"); + assert_eq!(token.integrity, "S-1-16-0", "the worker lowered itself"); + + let script = r#" + const now = Date.now(); + const total = [1, 2, 3, 4].map((n) => n * n).reduce((a, b) => a + b, 0); + const text = JSON.stringify({ nested: [total, "x"] }); + return { now, total, text, parsed: JSON.parse(text).nested[1] }; + "#; + let request = ActivationRequest { + activation_id: 7, + profile: Profile::Flow, + tools: vec![], + prelude_hash: welcome.prelude_hash, + script: script.into(), + trigger: JsonText::null(), + self_input: JsonText::null(), + budgets: Budgets::default(), + prefix: vec![], + }; + write_parent_message(&mut stdin, &ParentMessage::Activate(Box::new(request))) + .expect("send the activation"); + let mut host_calls = Vec::new(); + let result = loop { + match read_worker_message(&mut stdout) { + Ok(WorkerMessage::HostCall(call)) => { + assert_eq!(call.kind, CallKind::Primitive(Primitive::Now), "{call:?}"); + let reply = ParentMessage::Deliver(Outcome { + position: call.position, + settlement: Settlement::Fulfilled, + value: JsonText::new(NOW_MS).expect("small"), + delivery_order: host_calls.len() as u64, + }); + host_calls.push(call); + write_parent_message(&mut stdin, &reply).expect("deliver the clock read"); + } + Ok(WorkerMessage::Finished { + activation_id, + result, + }) => { + assert_eq!(activation_id, 7); + break result; + } + other => fail(child, format!("unexpected reply: {other:?}")), + } + }; + println!("full: {} host call(s), result {result:?}", host_calls.len()); + assert_eq!(host_calls.len(), 1); + let value = match result { + ActivationResult::Completed { value } => value, + other => fail(child, format!("the activation did not complete: {other:?}")), + }; + let value: serde_json::Value = serde_json::from_str(value.as_str()).expect("JSON result"); + assert_eq!( + value, + serde_json::json!({ + "now": 1767225600000u64, + "total": 30, + "text": "{\"nested\":[30,\"x\"]}", + "parsed": "x", + }) + ); + + write_parent_message(&mut stdin, &ParentMessage::Shutdown).expect("send shutdown"); + drop(stdin); + drop(stdout); + let ended = end(child); + println!("full: shut down with exit {}", ended.code); + assert_eq!(ended.code, 0, "{}", ended.stderr); +} + +// Worker checks of token, mitigation and handle properties the launcher can +// break when it creates the process. In these test variants the parent's own +// pre-resume checks expect the broken property, so the worker is resumed and +// must notice and refuse by itself. + +#[test] +fn a_primary_without_the_lpac_claim_is_refused_as_not_lpac() { + assert_refused(Deviation::NotLpac); +} + +#[test] +fn a_primary_with_a_capability_is_refused_as_capabilities_present() { + assert_refused(Deviation::CapabilitiesPresent); +} + +#[test] +fn a_second_restricting_sid_is_refused_as_restricting_sid_mismatch() { + assert_refused(Deviation::RestrictingSidMismatch); +} + +#[test] +fn an_enabled_logon_group_is_refused_as_group_not_deny_only() { + assert_refused(Deviation::GroupNotDenyOnly); +} + +#[test] +fn a_primary_with_privileges_is_refused_as_privileges_present() { + assert_refused(Deviation::PrivilegesPresent); +} + +#[test] +fn allowed_dynamic_code_is_refused_as_mitigation_mismatch() { + assert_refused(Deviation::MitigationMismatch); +} + +/// Without the explicit inherited-handle list the worker inherits every +/// inheritable handle of this process. One is planted here, so the worker's +/// table holds an inheritable file that is not one of its pipes. +#[test] +fn an_inherited_handle_beyond_the_pipes_is_refused_as_handle_not_allowed() { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::{HANDLE_FLAG_INHERIT, SetHandleInformation}; + + let fixture = worker() + .parent() + .expect("the worker has a directory") + .join(format!("basal-worker-planted-{}", std::process::id())); + let planted = File::create(&fixture).expect("create the planted file"); + let ok = unsafe { + SetHandleInformation( + planted.as_raw_handle(), + HANDLE_FLAG_INHERIT, + HANDLE_FLAG_INHERIT, + ) + }; + assert_ne!( + ok, + 0, + "SetHandleInformation: {}", + std::io::Error::last_os_error() + ); + assert_refused(Deviation::HandleNotAllowed); + drop(planted); + let _ = std::fs::remove_file(&fixture); +} + +/// A production worker has no way to skip a startup check: it refuses the +/// request the launcher's test variants add as an unknown argument, exit 64, +/// before any check runs. +#[cfg(not(feature = "deviations"))] +#[test] +fn a_production_worker_refuses_the_deviation_argument() { + for deviation in [ + Deviation::ThreadTokenPresent, + Deviation::IntegrityLowerFailed, + Deviation::IntegrityNotUntrusted, + ] { + let argument = deviation.child_argument().expect("a worker argument"); + let ended = end(start(deviation)); + println!( + "{deviation}: exit {} stderr {}", + ended.code, + ended.stderr.trim() + ); + assert_eq!(ended.code, 64, "{deviation}: {}", ended.stderr); + assert!( + ended + .stderr + .contains(&format!("unknown argument {argument}")), + "{}", + ended.stderr + ); + } +} + +// Worker checks of steps the worker performs itself (dropping the start-up +// thread token, lowering its integrity), which the parent cannot break when +// it creates the process. A worker built with the `deviations` feature skips +// or breaks that step when the launcher's command line asks it to. + +#[cfg(feature = "deviations")] +#[test] +fn a_kept_start_up_token_is_refused_as_thread_token_present() { + assert_refused(Deviation::ThreadTokenPresent); +} + +#[cfg(feature = "deviations")] +#[test] +fn a_failed_lowering_is_refused_as_integrity_lower_failed() { + assert_refused(Deviation::IntegrityLowerFailed); +} + +#[cfg(feature = "deviations")] +#[test] +fn a_skipped_lowering_is_refused_as_integrity_not_untrusted() { + assert_refused(Deviation::IntegrityNotUntrusted); +} + +/// Started without a package SID, the worker refuses before confining +/// itself, as it does without its Landlock argument on Linux. +#[test] +fn a_missing_package_sid_exits_70_package_sid_argument_missing() { + let output = Command::new(worker()) + .stdin(Stdio::null()) + .output() + .expect("run the worker"); + let ended = Ended { + code: output.status.code().expect("an exit code") as u32, + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }; + println!( + "missing: exit {} stderr {}", + ended.code, + ended.stderr.trim() + ); + assert_eq!(ended.code, 70, "{}", ended.stderr); + assert_eq!( + ended.reason(), + Some("package-sid-argument-missing"), + "{}", + ended.stderr + ); +} + +/// A package SID the system's SID parser rejects is a usage error. +#[test] +fn an_unparsable_package_sid_exits_64() { + let output = Command::new(worker()) + .arg("--package-sid=S-1-15-2-not-a-sid") + .stdin(Stdio::null()) + .output() + .expect("run the worker"); + let stderr = String::from_utf8_lossy(&output.stderr); + println!("unparsable: {:?} stderr {}", output.status, stderr.trim()); + assert_eq!(output.status.code(), Some(64), "{stderr}"); + assert!(stderr.contains("unparsable --package-sid"), "{stderr}"); +} diff --git a/evidence/windows-build-failures.md b/evidence/windows-build-failures.md index 6da2b7b..5355f9e 100644 --- a/evidence/windows-build-failures.md +++ b/evidence/windows-build-failures.md @@ -154,3 +154,27 @@ the failures above are fixed. dev-depend on `basal-host`. - **Tests:** every test in the workspace. Both test commands stopped while compiling `basal-host`. + +## Test-kit and rig portability + +The original measured baseline above is retained. The following tests, or the +explicitly named fixture subcases of mixed tests, require POSIX semantics. The +remaining assertions in mixed tests still run on Windows. + +- `crates/basal-worker/tests/inherited_descriptors.rs::worker_closes_extra_inherited_descriptors_at_startup` — POSIX descriptor numbers, `dup2`, and close-on-exec flags; Windows inherited-handle checks run in the launcher and Windows confinement suites. +- `crates/basal-testkit/src/git.rs::tests::fixture_git_commands_ignore_injected_global_hooks` — executable `/bin/sh` hook fixture; Windows built-in config/hook isolation has native coverage in basal-host. +- `crates/basal-testkit/tests/builtins_git.rs::a_repository_config_cannot_make_a_built_in_run_a_program` — executable `/bin/sh` fsmonitor, pager, diff and hook fixtures; Windows built-in isolation has native coverage in basal-host. +- `crates/basal-testkit/tests/builtins_fs.rs::a_symlink_resolving_outside_the_root_is_refused` — symlink creation requiring Windows privilege, and Unix in-root-link traversal semantics. +- `crates/basal-testkit/tests/builtins_fs.rs::a_symlink_swapped_into_the_last_component_after_the_check_is_not_followed` — privilege-dependent symlink fixture. +- `crates/basal-testkit/tests/builtins_fs.rs::a_directory_swapped_for_a_symlink_after_the_check_is_caught_after_the_open` — privilege-dependent symlink fixture. +- `crates/basal-testkit/tests/builtins_fs.rs::list_names_entries_and_their_kinds` (symlink entry only) — privilege-dependent symlink creation; ordinary entries and the listing cap remain tested on Windows. +- `crates/basal-testkit/tests/builtins_fs.rs::write_replaces_the_whole_file_inside_a_write_root_only` (symlink paths only) — privilege-dependent symlink creation; writes, outside-root refusal, relative-component refusal, size cap and temp cleanup remain tested on Windows. +- `crates/basal-testkit/tests/fs_write_temps.rs::a_crash_mid_write_is_cleaned_up_by_recovery_without_following_a_swapped_path` (three symlink tamper variants only) — privilege-dependent symlink creation; untampered crash recovery runs on Windows. +- `crates/basal-testkit/tests/fs_write_temps.rs::a_crash_record_whose_directory_left_its_root_is_not_acted_on` — privilege-dependent symlink creation and retargeting a granted Unix symlink root. +- `crates/basal-testkit/tests/fs_write_temps.rs::legacy_temporary_files_are_removed_once_from_written_directories` (symlink temp only) — privilege-dependent symlink creation; regular temp cleanup, decoy names, outside files and once-per-store behavior remain tested on Windows. +- `crates/basal-rig/src/bin/basal-rig-contract/tests.rs::seeded_fixture_rejects_foreign_paths_and_symlinked_stores` (symlinked store only) — privilege-dependent symlink creation; foreign-path and module refusal assertions remain tested on Windows. +- `crates/basal-host/tests/builtin_host_regressions.rs::escaped_listing_exceeds_encoded_cap_as_a_typed_refusal` — Unix control-character filenames, which NTFS refuses; escaped-file dispatch limits remain tested on Windows. +- `crates/basal-host/tests/builtin_host_regressions.rs::atomic_write_drops_special_permission_bits` — POSIX setuid/setgid/mode bits; Windows replacement-DACL behavior has native basal-host coverage. +- `crates/basal-host/tests/builtin_host_regressions.rs::git_reaps_descendants_that_keep_its_output_pipe_open` — `/bin/sh`/`sleep` fixture and POSIX process-group cleanup; native Windows job/descendant tests run in basal-host and basal-testkit. +- `crates/basal-testkit/src/https.rs::tests::idle_https_accept_blocks_and_shutdown_wakes_it_without_a_request` (`fcntl` flag readback only) — POSIX socket flags; shutdown wakeup and absence of HTTPS requests remain tested on Windows. +- `crates/basal-testkit/src/process.rs::tests::subprocess_deadline_reaps_descendants_holding_output_pipes` — `/bin/sh`/`sleep` process-group fixture; `crates/basal-testkit/tests/windows_process.rs::subprocess_deadline_reaps_descendants_holding_output_pipes` tests the Windows job equivalent by the same test name. diff --git a/mutations.toml b/mutations.toml index 543b6bc..65bd603 100644 --- a/mutations.toml +++ b/mutations.toml @@ -547,7 +547,7 @@ edits = [ { file = "Cargo.lock", old = " \"serde\",\n \"serde_json\",\n \"sha2\",\n \"subc-client-rs\",\n \"subc-os\",\n \"subc-protocol\",\n \"subc-transport\",\n \"tokio\",\n \"tracing\",\n", new = " \"serde\",\n \"serde_json\",\n \"sha2\",\n \"subc-client-rs\",\n \"subc-os\",\n \"subc-protocol 0.30.0\",\n \"subc-transport\",\n \"tokio\",\n \"tracing\",\n" }, { file = "Cargo.lock", old = " \"rcgen\",\n \"rusqlite\",\n \"rustls\",\n \"serde_json\",\n \"subc-protocol\",\n", new = " \"rcgen\",\n \"rusqlite\",\n \"rustls\",\n \"serde_json\",\n \"subc-protocol 0.30.0\",\n" }, { file = "Cargo.lock", old = " \"rusqlite\",\n \"serde_json\",\n \"sha2\",\n \"subc-client-rs\",\n \"subc-protocol\",\n \"tokio\",\n]\n", new = " \"rusqlite\",\n \"serde_json\",\n \"sha2\",\n \"subc-client-rs\",\n \"subc-protocol 0.30.0\",\n \"tokio\",\n]\n" }, - { file = "Cargo.lock", old = " \"rquickjs\",\n \"seccompiler\",\n \"serde_json\",\n]\n\n[[package]]\n", new = " \"rquickjs\",\n \"seccompiler\",\n \"serde_json\",\n \"subc-protocol 0.24.1\",\n]\n\n[[package]]\n" }, + { file = "Cargo.lock", old = " \"rquickjs\",\n \"seccompiler\",\n \"serde_json\",\n \"windows-sys 0.61.2\",\n]\n\n[[package]]\n", new = " \"rquickjs\",\n \"seccompiler\",\n \"serde_json\",\n \"subc-protocol 0.24.1\",\n \"windows-sys 0.61.2\",\n]\n\n[[package]]\n" }, { file = "Cargo.lock", old = " \"serde_json\",\n \"subc-control\",\n \"subc-os\",\n \"subc-protocol\",\n \"subc-transport\",\n \"tokio\",\n \"tokio-util\",\n", new = " \"serde_json\",\n \"subc-control\",\n \"subc-os\",\n \"subc-protocol 0.30.0\",\n \"subc-transport\",\n \"tokio\",\n \"tokio-util\",\n" }, { file = "Cargo.lock", old = "dependencies = [\n \"serde\",\n \"serde_json\",\n \"subc-protocol\",\n]\n", new = "dependencies = [\n \"serde\",\n \"serde_json\",\n \"subc-protocol 0.30.0\",\n]\n" }, { file = "Cargo.lock", old = "dependencies = [\n \"libc\",\n \"rustix\",\n]\n\n[[package]]\n", new = "dependencies = [\n \"libc\",\n \"rustix\",\n]\n\n[[package]]\nname = \"subc-protocol\"\nversion = \"0.24.1\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"3b0fb1bc9305afe14d1202970b7e361253dba6108525b356a7e10274436bc80d\"\ndependencies = [\n \"serde\",\n \"serde_json\",\n]\n\n[[package]]\n" }, @@ -8221,13 +8221,7 @@ let mut x = 0u64; // Live draws use OS entropy, not shared predictable process state. The // runtime journals each draw and replay serves that saved value instead. // There is deliberately no fallback when the OS cannot supply entropy. - let rc = unsafe { libc::getentropy((&mut x as *mut u64).cast(), std::mem::size_of::()) }; - assert_eq!( - rc, - 0, - "OS entropy unavailable: {}", - std::io::Error::last_os_error() - );""" + fill_entropy(&mut x);""" new = """ // NON-VACUITY BREAK use std::sync::atomic::{AtomicU64, Ordering};