From 3fa116b648b8ec7265a1962e746794f114794bb8 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:28:12 +0200 Subject: [PATCH 1/7] mason: implement Windows host fs module and raw-spelling validation --- crates/basal-host/src/builtins/fs.rs | 66 +- crates/basal-host/src/builtins/fs/tests.rs | 1 + crates/basal-host/src/builtins/fs/windows.rs | 2216 ++++++++++++++++++ 3 files changed, 2267 insertions(+), 16 deletions(-) create mode 100644 crates/basal-host/src/builtins/fs/windows.rs diff --git a/crates/basal-host/src/builtins/fs.rs b/crates/basal-host/src/builtins/fs.rs index 8598478..0c5aa39 100644 --- a/crates/basal-host/src/builtins/fs.rs +++ b/crates/basal-host/src/builtins/fs.rs @@ -27,18 +27,29 @@ #[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}; @@ -49,6 +60,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 +88,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 +99,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() @@ -121,13 +139,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 +184,7 @@ fn resolve_real(path: &str, roots: &[PathBuf], purpose: Purpose) -> Result std::io::Result { #[cfg(target_os = "macos")] { @@ -190,7 +208,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 +228,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 +258,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 +313,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 +323,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 +350,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 +365,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 +392,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 +454,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 +466,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 +605,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 +616,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 +638,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 +655,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 +668,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 +688,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 +732,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 +851,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 +894,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..1cb472e 100644 --- a/crates/basal-host/src/builtins/fs/tests.rs +++ b/crates/basal-host/src/builtins/fs/tests.rs @@ -1,5 +1,6 @@ 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..23f3148 --- /dev/null +++ b/crates/basal-host/src/builtins/fs/windows.rs @@ -0,0 +1,2216 @@ +//! Windows-specific implementation of the `fs` built-in. +//! +//! Enforces raw-spelling grammar, component-wise walk from volume root with +//! reparse refusal, volume-GUID final-path component-wise comparison, +//! POSIX-semantics temp-and-rename replacement with DACL inheritance/preservation, +//! and denial of reparse points. + +#![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::{check_write_size, is_temp_name}; +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 }, +} + +fn outside(path: &Path) -> Denial { + Denial::denied(format!( + "{} is outside the manifest's roots or missing", + path.display() + )) +} + +fn io_denial(path: &Path, e: &std::io::Error) -> Denial { + match e.kind() { + std::io::ErrorKind::NotFound => Denial::new( + codes::NOT_FOUND, + format!("{} does not exist", path.display()), + ), + _ => Denial::new(codes::IO, format!("{}: {e}", path.display())), + } +} + +/// 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. +pub fn is_reserved_device_name(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + match upper.as_str() { + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" => true, + "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9" | "COM0" => { + true + } + "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9" | "LPT0" => { + true + } + _ => false, + } +} + +/// 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)] +mod ffi { + use std::ffi::c_void; + + pub type NTSTATUS = i32; + pub type HANDLE = *mut c_void; + pub const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE; + + pub const STATUS_SUCCESS: NTSTATUS = 0; + 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_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_NO_MORE_FILES: NTSTATUS = 0x80000006_u32 as i32; + pub const STATUS_OBJECT_NAME_COLLISION: NTSTATUS = 0xC0000035_u32 as i32; + + pub const FILE_READ_DATA: u32 = 0x0001; + pub const FILE_WRITE_DATA: u32 = 0x0002; + pub const FILE_READ_ATTRIBUTES: u32 = 0x0080; + pub const FILE_WRITE_ATTRIBUTES: u32 = 0x0100; + pub const FILE_TRAVERSE: u32 = 0x0020; + pub const DELETE: u32 = 0x00010000; + pub const READ_CONTROL: u32 = 0x00020000; + pub const WRITE_DAC: u32 = 0x00040000; + pub const SYNCHRONIZE: u32 = 0x00100000; + + pub const FILE_GENERIC_READ: u32 = 0x00120089; + pub const FILE_GENERIC_WRITE: u32 = 0x00120116; + pub const FILE_GENERIC_EXECUTE: u32 = 0x001200A0; + pub const FILE_ALL_ACCESS: u32 = 0x001F01FF; + + 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_OPEN_IF: u32 = 0x00000003; + + 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_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], + } + + #[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], + } + + #[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, + } + + #[repr(C)] + pub struct GENERIC_MAPPING { + pub GenericRead: u32, + pub GenericWrite: u32, + pub GenericExecute: u32, + pub GenericAll: u32, + } + + #[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 NtReadFile( + FileHandle: HANDLE, + Event: HANDLE, + ApcRoutine: *mut c_void, + ApcContext: *mut c_void, + IoStatusBlock: *mut IO_STATUS_BLOCK, + Buffer: *mut c_void, + Length: u32, + ByteOffset: *mut i64, + Key: *mut u32, + ) -> 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; + + pub fn CreateHardLinkW( + lpFileName: *const u16, + lpExistingFileName: *const u16, + lpSecurityAttributes: *mut c_void, + ) -> i32; + } + + #[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; + + pub fn SetSecurityInfo( + handle: HANDLE, + ObjectType: u32, + SecurityInfo: u32, + psidOwner: *mut c_void, + psidGroup: *mut c_void, + pDacl: *mut c_void, + pSacl: *mut c_void, + ) -> u32; + + pub fn CreatePrivateObjectSecurity( + ParentDescriptor: *mut c_void, + CreatorDescriptor: *mut c_void, + NewDescriptor: *mut *mut c_void, + IsDirectoryObject: i32, + Token: HANDLE, + GenericMapping: *mut GENERIC_MAPPING, + ) -> i32; + + pub fn DestroyPrivateObjectSecurity(ObjectDescriptor: *mut *mut c_void) -> i32; + + 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; + } +} + +#[cfg(windows)] +pub struct OwnedHandle(pub ffi::HANDLE); + +#[cfg(windows)] +impl OwnedHandle { + pub fn raw(&self) -> ffi::HANDLE { + self.0 + } + + pub fn into_raw(mut self) -> ffi::HANDLE { + let h = self.0; + self.0 = std::ptr::null_mut(); + h + } +} + +#[cfg(windows)] +impl Drop for OwnedHandle { + fn drop(&mut self) { + if !self.0.is_null() && self.0 != ffi::INVALID_HANDLE_VALUE { + unsafe { ffi::NtClose(self.0) }; + } + } +} + +#[cfg(windows)] +impl From for std::fs::File { + fn from(h: OwnedHandle) -> Self { + use std::os::windows::io::FromRawHandle; + unsafe { std::fs::File::from_raw_handle(h.into_raw() as std::os::windows::io::RawHandle) } + } +} + +#[cfg(windows)] +struct FileDacl { + sd: *mut std::ffi::c_void, + is_private: bool, +} + +#[cfg(windows)] +impl Drop for FileDacl { + fn drop(&mut self) { + if !self.sd.is_null() { + if self.is_private { + unsafe { ffi::DestroyPrivateObjectSecurity(&mut self.sd) }; + } else { + unsafe { ffi::LocalFree(self.sd) }; + } + } + } +} + +#[cfg(windows)] +#[derive(Debug)] +enum OpenError { + NotFound, + ReparsePoint, + AccessDenied, + Other(ffi::NTSTATUS), +} + +#[cfg(windows)] +fn nt_open_relative( + root: ffi::HANDLE, + name: &str, + directory: bool, + desired_access: u32, + create_disposition: u32, + create_options: u32, + security_descriptor: *mut std::ffi::c_void, +) -> Result { + let wide_name: Vec = name.encode_utf16().collect(); + let mut unicode_name = ffi::UNICODE_STRING { + Length: (wide_name.len() * 2) as u16, + MaximumLength: (wide_name.len() * 2) as u16, + Buffer: wide_name.as_ptr() as *mut u16, + }; + let mut obj_attr = ffi::OBJECT_ATTRIBUTES { + Length: std::mem::size_of::() as u32, + RootDirectory: root, + ObjectName: if wide_name.is_empty() { + std::ptr::null_mut() + } else { + &mut unicode_name + }, + Attributes: ffi::OBJ_CASE_INSENSITIVE, + SecurityDescriptor: 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, + }; + let mut options = + create_options | ffi::FILE_SYNCHRONOUS_IO_NONALERT | ffi::FILE_OPEN_REPARSE_POINT; + if directory { + options |= ffi::FILE_DIRECTORY_FILE; + } + let status = unsafe { + ffi::NtCreateFile( + &mut handle, + desired_access, + &mut obj_attr, + &mut io_status, + std::ptr::null_mut(), + ffi::FILE_ATTRIBUTE_NORMAL, + ffi::FILE_SHARE_READ | ffi::FILE_SHARE_WRITE | ffi::FILE_SHARE_DELETE, + create_disposition, + options, + 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, + _ => OpenError::Other(status), + }); + } + + // Check if reparse point! + let mut basic_io = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let mut basic = ffi::FILE_BASIC_INFORMATION::default(); + let q_status = unsafe { + ffi::NtQueryInformationFile( + handle, + &mut basic_io, + &mut basic as *mut _ as *mut std::ffi::c_void, + std::mem::size_of::() as u32, + ffi::FileBasicInformation, + ) + }; + if q_status >= 0 && (basic.FileAttributes & ffi::FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + unsafe { ffi::NtClose(handle) }; + return Err(OpenError::ReparsePoint); + } + + Ok(OwnedHandle(handle)) +} + +#[cfg(windows)] +fn query_file_basic(handle: ffi::HANDLE) -> std::io::Result { + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let mut info = ffi::FILE_BASIC_INFORMATION::default(); + let status = unsafe { + ffi::NtQueryInformationFile( + handle, + &mut io_status, + &mut info as *mut _ as *mut std::ffi::c_void, + std::mem::size_of::() as u32, + ffi::FileBasicInformation, + ) + }; + if status < 0 { + return Err(std::io::Error::from_raw_os_error(status)); + } + Ok(info) +} + +#[cfg(windows)] +fn query_file_standard(handle: ffi::HANDLE) -> std::io::Result { + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let mut info = ffi::FILE_STANDARD_INFORMATION::default(); + let status = unsafe { + ffi::NtQueryInformationFile( + handle, + &mut io_status, + &mut info as *mut _ as *mut std::ffi::c_void, + std::mem::size_of::() as u32, + ffi::FileStandardInformation, + ) + }; + if status < 0 { + return Err(std::io::Error::from_raw_os_error(status)); + } + Ok(info) +} + +#[cfg(windows)] +fn get_volume_guid_path(handle: ffi::HANDLE) -> Result { + let mut buf = vec![0u16; 1024]; + 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); + 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 { + 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")) +} + +#[cfg(windows)] +struct VerifiedRoot { + manifest: String, + guid_path: String, + is_directory: bool, + handle: OwnedHandle, +} + +#[cfg(windows)] +fn walk_from_volume_root(manifest_root: &str) -> Result { + validate_raw_spelling(manifest_root)?; + let drive_prefix = &manifest_root[..3]; // e.g. "C:\" + let nt_drive = format!(r"\??\{drive_prefix}"); + + // Open volume root + let root_vol = nt_open_relative( + std::ptr::null_mut(), + &nt_drive, + true, + ffi::FILE_READ_ATTRIBUTES | ffi::FILE_TRAVERSE | ffi::SYNCHRONIZE, + ffi::FILE_OPEN, + 0, + std::ptr::null_mut(), + ) + .map_err(|e| match e { + OpenError::ReparsePoint => { + Denial::denied(format!("volume root {drive_prefix} is a reparse point")) + } + OpenError::NotFound => Denial::new( + codes::NOT_FOUND, + format!("volume root {drive_prefix} does not exist"), + ), + _ => Denial::denied(format!("failed to open volume root {drive_prefix}")), + })?; + + let remainder = &manifest_root[3..]; + if remainder.is_empty() { + let guid_path = get_volume_guid_path(root_vol.raw())?; + return Ok(VerifiedRoot { + manifest: manifest_root.to_owned(), + guid_path, + is_directory: true, + handle: root_vol, + }); + } + + let components: Vec<&str> = remainder.split('\\').collect(); + let mut current = root_vol; + for (i, comp) in components.iter().enumerate() { + let is_last = i == components.len() - 1; + let next = nt_open_relative( + current.raw(), + comp, + !is_last, // intermediate components must be directories + ffi::FILE_READ_ATTRIBUTES | ffi::FILE_TRAVERSE | ffi::SYNCHRONIZE, + ffi::FILE_OPEN, + 0, + std::ptr::null_mut(), + ) + .map_err(|e| match e { + OpenError::ReparsePoint => { + Denial::denied(format!("{comp} in root {manifest_root} is a reparse point")) + } + OpenError::NotFound => Denial::new( + codes::NOT_FOUND, + format!("{comp} in root {manifest_root} does not exist"), + ), + _ => Denial::denied(format!( + "failed to open component {comp} in {manifest_root}" + )), + })?; + current = next; + } + + let std_info = + query_file_standard(current.raw()).map_err(|e| Denial::new(codes::IO, e.to_string()))?; + let is_dir = std_info.Directory != 0; + let guid_path = get_volume_guid_path(current.raw())?; + + Ok(VerifiedRoot { + manifest: manifest_root.to_owned(), + guid_path, + is_directory: is_dir, + handle: current, + }) +} + +#[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 + } +} + +#[cfg(windows)] +fn select_root<'a>(path: &str, roots: &'a [String]) -> Result { + validate_raw_spelling(path)?; + for r in roots { + if let Ok(()) = validate_raw_spelling(r) { + let matched = if path == r { + true + } else if path.starts_with(r) { + let suffix = &path[r.len()..]; + suffix.starts_with('\\') + } else { + false + }; + + if matched { + if let Ok(vr) = walk_from_volume_root(r) { + return Ok(vr); + } + } + } + } + Err(outside(Path::new(path))) +} + +#[cfg(windows)] +pub fn resolve(path: &str, roots: &[String], purpose: Purpose) -> Result { + validate_raw_spelling(path)?; + let vr = select_root(path, roots)?; + + if purpose == Purpose::Read { + if path == vr.manifest { + return Ok(Target::Existing(PathBuf::from(path))); + } + if vr.is_directory { + let rel = &path[vr.manifest.len()..]; + let rel = rel.strip_prefix('\\').unwrap_or(rel); + let mut current = &vr.handle; + let mut intermediate = None; + let components: Vec<&str> = rel.split('\\').collect(); + let mut found = true; + for (i, comp) in components.iter().enumerate() { + let is_last = i == components.len() - 1; + let cur_handle = intermediate.as_ref().unwrap_or(current); + match nt_open_relative( + cur_handle.raw(), + comp, + !is_last, + ffi::FILE_READ_ATTRIBUTES | ffi::SYNCHRONIZE, + ffi::FILE_OPEN, + 0, + std::ptr::null_mut(), + ) { + Ok(next) => { + if is_last { + let guid = get_volume_guid_path(next.raw())?; + if guid_path_inside_component_wise( + &guid, + &vr.guid_path, + vr.is_directory, + ) { + return Ok(Target::Existing(PathBuf::from(path))); + } else { + return Err(outside(Path::new(path))); + } + } + intermediate = Some(next); + } + Err(OpenError::NotFound) => { + found = false; + break; + } + Err(OpenError::ReparsePoint) => { + return Err(Denial::denied(format!("{path} contains a reparse point"))); + } + Err(_) => { + return Err(outside(Path::new(path))); + } + } + } + if !found { + // fall through to Target::Entry + } + } + } + + let p = Path::new(path); + let parent = p.parent().ok_or_else(|| outside(p))?; + let name = p.file_name().ok_or_else(|| outside(p))?; + + // Parent must be inside the verified root + if !vr.is_directory { + if p != Path::new(&vr.manifest) { + return Err(outside(p)); + } + } else { + let parent_str = parent.to_str().ok_or_else(|| outside(p))?; + if parent_str != vr.manifest && !parent_str.starts_with(&format!(r"{}\", vr.manifest)) { + return Err(outside(p)); + } + } + + Ok(Target::Entry { + parent: parent.to_path_buf(), + name: name.to_os_string(), + }) +} + +#[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)?; + + if path_str == vr.manifest { + if directory && !vr.is_directory { + return Err(outside(resolved)); + } + let guid = get_volume_guid_path(vr.handle.raw())?; + if !guid_path_inside_component_wise(&guid, &vr.guid_path, vr.is_directory) { + return Err(outside(resolved)); + } + return Ok(std::fs::File::from(vr.handle)); + } + + if !vr.is_directory { + return Err(outside(resolved)); + } + + let rel = &path_str[vr.manifest.len()..]; + let rel = rel.strip_prefix('\\').unwrap_or(rel); + let components: Vec<&str> = rel.split('\\').collect(); + let mut intermediate = None; + for (i, comp) in components.iter().enumerate() { + let is_last = i == components.len() - 1; + let cur = intermediate.as_ref().unwrap_or(&vr.handle); + let desired = if is_last { + if directory { + ffi::FILE_GENERIC_READ | ffi::FILE_TRAVERSE | ffi::SYNCHRONIZE + } else { + ffi::FILE_GENERIC_READ | ffi::SYNCHRONIZE + } + } else { + ffi::FILE_READ_ATTRIBUTES | ffi::FILE_TRAVERSE | ffi::SYNCHRONIZE + }; + let next = nt_open_relative( + cur.raw(), + comp, + if is_last { directory } else { true }, + desired, + ffi::FILE_OPEN, + 0, + std::ptr::null_mut(), + ) + .map_err(|e| match e { + OpenError::ReparsePoint => Denial::denied(format!( + "{} became a symlink while it was being opened", + resolved.display() + )), + OpenError::NotFound => io_denial( + resolved, + &std::io::Error::from(std::io::ErrorKind::NotFound), + ), + _ => outside(resolved), + })?; + + if is_last { + let guid = get_volume_guid_path(next.raw())?; + if !guid_path_inside_component_wise(&guid, &vr.guid_path, vr.is_directory) { + return Err(outside(resolved)); + } + return Ok(std::fs::File::from(next)); + } + intermediate = Some(next); + } + + Err(outside(resolved)) +} + +#[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()), + )); + } + }; + + 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))?; + + 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 })) +} + +#[cfg(windows)] +pub fn stat(path: &str, roots: &[String]) -> Result { + validate_raw_spelling(path)?; + let vr = select_root(path, roots)?; + + if path == vr.manifest { + let basic = + query_file_basic(vr.handle.raw()).map_err(|e| Denial::new(codes::IO, e.to_string()))?; + let std_info = query_file_standard(vr.handle.raw()) + .map_err(|e| Denial::new(codes::IO, e.to_string()))?; + let mtime_ms = filetime_to_mtime_ms(basic.LastWriteTime); + return Ok(json!({ + "exists": true, + "kind": if std_info.Directory != 0 { "dir" } else { "file" }, + "size": std_info.EndOfFile, + "mtime_ms": mtime_ms, + })); + } + + if !vr.is_directory { + return Err(outside(Path::new(path))); + } + + let rel = &path[vr.manifest.len()..]; + let rel = rel.strip_prefix('\\').unwrap_or(rel); + let components: Vec<&str> = rel.split('\\').collect(); + let mut intermediate = None; + + for (i, comp) in components.iter().enumerate() { + let is_last = i == components.len() - 1; + let cur = intermediate.as_ref().unwrap_or(&vr.handle); + + let res = nt_open_relative( + cur.raw(), + comp, + false, + ffi::FILE_READ_ATTRIBUTES | ffi::SYNCHRONIZE, + ffi::FILE_OPEN, + 0, + std::ptr::null_mut(), + ); + + match res { + Ok(next) => { + if is_last { + let guid = get_volume_guid_path(next.raw())?; + if !guid_path_inside_component_wise(&guid, &vr.guid_path, vr.is_directory) { + return Err(outside(Path::new(path))); + } + let basic = query_file_basic(next.raw()) + .map_err(|e| Denial::new(codes::IO, e.to_string()))?; + let std_info = query_file_standard(next.raw()) + .map_err(|e| Denial::new(codes::IO, e.to_string()))?; + let mtime_ms = filetime_to_mtime_ms(basic.LastWriteTime); + return Ok(json!({ + "exists": true, + "kind": if std_info.Directory != 0 { "dir" } else { "file" }, + "size": std_info.EndOfFile, + "mtime_ms": mtime_ms, + })); + } + intermediate = Some(next); + } + Err(OpenError::ReparsePoint) => { + return Err(Denial::denied(format!("{path} is a reparse point"))); + } + Err(OpenError::NotFound) => { + if is_last { + return Ok(json!({ "exists": false })); + } else { + return Err(outside(Path::new(path))); + } + } + Err(OpenError::AccessDenied) => { + return Err(Denial::denied(format!("access denied: {path}"))); + } + Err(OpenError::Other(status)) => { + return Err(Denial::new(codes::IO, format!("IO error 0x{status:08x}"))); + } + } + } + + Err(outside(Path::new(path))) +} + +#[cfg(windows)] +pub fn list(path: &str, roots: &[String]) -> Result { + let dir = open_checked(Path::new(path), roots, true)?; + use std::os::windows::io::AsRawHandle; + let handle = dir.as_raw_handle() as ffi::HANDLE; + + let mut entries = Vec::new(); + let mut buffer = vec![0u8; 64 * 1024]; + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let mut restart = 1u8; + + loop { + 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() as *mut std::ffi::c_void, + buffer.len() as u32, + ffi::FileDirectoryInformation, + 0, + std::ptr::null_mut(), + restart, + ) + }; + restart = 0; + + if status == ffi::STATUS_NO_MORE_FILES || io_status.Information == 0 { + break; + } + if status < 0 { + return Err(Denial::new( + codes::IO, + format!("directory scan failed: 0x{status:08x}"), + )); + } + + let mut offset = 0; + loop { + let entry_ptr = + unsafe { buffer.as_ptr().add(offset) as *const ffi::FILE_DIRECTORY_INFORMATION }; + let entry = unsafe { &*entry_ptr }; + let name_len = (entry.FileNameLength / 2) as usize; + let name_slice = + unsafe { std::slice::from_raw_parts(entry.FileName.as_ptr(), name_len) }; + let name_str = String::from_utf16_lossy(name_slice); + + if name_str != "." && name_str != ".." { + let kind = if (entry.FileAttributes & ffi::FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + "symlink" + } else if (entry.FileAttributes & ffi::FILE_ATTRIBUTE_DIRECTORY) != 0 { + "dir" + } else { + "file" + }; + entries.push((name_str, kind)); + if entries.len() > MAX_LIST_ENTRIES { + return Err(Denial::new( + codes::TOO_LARGE, + format!("{path} has more than {MAX_LIST_ENTRIES} entries"), + )); + } + } + + if entry.NextEntryOffset == 0 { + break; + } + offset += entry.NextEntryOffset as usize; + if offset >= buffer.len() { + break; + } + } + } + + entries.sort(); + Ok(Value::Array( + entries + .into_iter() + .map(|(name, kind)| json!({ "name": name, "kind": kind })) + .collect(), + )) +} + +#[cfg(windows)] +fn get_security_descriptor_for_write( + parent_handle: ffi::HANDLE, + leaf_name: &str, +) -> Result { + // Attempt open leaf with FILE_OPEN_REPARSE_POINT to read its DACL + match nt_open_relative( + parent_handle, + leaf_name, + false, + ffi::READ_CONTROL | ffi::FILE_READ_ATTRIBUTES | ffi::SYNCHRONIZE, + ffi::FILE_OPEN, + 0, + std::ptr::null_mut(), + ) { + Ok(leaf) => { + let mut sd = std::ptr::null_mut(); + let mut dacl = std::ptr::null_mut(); + let err = unsafe { + ffi::GetSecurityInfo( + leaf.raw(), + ffi::SE_FILE_OBJECT, + ffi::DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut dacl, + std::ptr::null_mut(), + &mut sd, + ) + }; + if err != 0 { + return Err(Denial::denied(format!( + "failed to read DACL from replaced file {leaf_name}: {err}" + ))); + } + Ok(FileDacl { + sd, + is_private: false, + }) + } + Err(OpenError::NotFound) => { + // New file: use parent's inheritable DACL + let mut parent_sd = std::ptr::null_mut(); + let err = unsafe { + ffi::GetSecurityInfo( + parent_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 parent_sd, + ) + }; + if err != 0 { + return Err(Denial::denied(format!( + "failed to read parent inheritable DACL: {err}" + ))); + } + let _parent_guard = FileDacl { + sd: parent_sd, + is_private: false, + }; + + let mut mapping = ffi::GENERIC_MAPPING { + GenericRead: ffi::FILE_GENERIC_READ, + GenericWrite: ffi::FILE_GENERIC_WRITE, + GenericExecute: ffi::FILE_GENERIC_EXECUTE, + GenericAll: ffi::FILE_ALL_ACCESS, + }; + let mut child_sd = std::ptr::null_mut(); + let ok = unsafe { + ffi::CreatePrivateObjectSecurity( + parent_sd, + std::ptr::null_mut(), + &mut child_sd, + 0, // FALSE for file + std::ptr::null_mut(), + &mut mapping, + ) + }; + if ok == 0 { + return Err(Denial::denied("failed to compute child inheritable DACL")); + } + + Ok(FileDacl { + sd: child_sd, + is_private: true, + }) + } + Err(OpenError::ReparsePoint) => Err(Denial::denied(format!( + "{leaf_name} is a symlink; fs.write does not replace symlinks" + ))), + Err(_) => Err(Denial::denied(format!( + "failed to inspect target {leaf_name} for DACL" + ))), + } +} + +#[cfg(windows)] +fn unlink_file(parent_handle: ffi::HANDLE, name: &str) -> Result<(), ffi::NTSTATUS> { + let handle = nt_open_relative( + parent_handle, + name, + false, + ffi::DELETE | ffi::SYNCHRONIZE, + ffi::FILE_OPEN, + 0, + std::ptr::null_mut(), + ) + .map_err(|e| match e { + OpenError::Other(s) => s, + _ => ffi::STATUS_ACCESS_DENIED, + })?; + + let mut disp = ffi::FILE_DISPOSITION_INFORMATION { DeleteFile: 1 }; + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let status = unsafe { + ffi::NtSetInformationFile( + handle.raw(), + &mut io_status, + &mut disp as *mut _ as *mut std::ffi::c_void, + std::mem::size_of::() as u32, + ffi::FileDispositionInformation, + ) + }; + if status < 0 { + return Err(status); + } + Ok(()) +} + +#[cfg(windows)] +pub fn write(path: &str, roots: &[String], text: &str) -> Result { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let key = format!( + "local-{}-{}", + std::process::id(), + SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ); + write_call(path, roots, text, &key, None) +} + +#[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_name_str = temp_name_os + .to_str() + .ok_or_else(|| Denial::invalid("temporary file name is not valid UTF-8"))?; + + let p = Path::new(path); + let parent = p.parent().ok_or_else(|| outside(p))?; + let leaf_name = p + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| outside(p))?; + + let parent_str = parent.to_str().ok_or_else(|| outside(p))?; + let vr = select_root(parent_str, roots)?; + + let (parent_handle, parent_path_buf) = if parent_str == vr.manifest { + (vr.handle, parent.to_path_buf()) + } else { + if !vr.is_directory { + return Err(outside(p)); + } + let rel = &parent_str[vr.manifest.len()..]; + let rel = rel.strip_prefix('\\').unwrap_or(rel); + let components: Vec<&str> = rel.split('\\').collect(); + let mut intermediate = None; + for (i, comp) in components.iter().enumerate() { + let is_last = i == components.len() - 1; + let cur = intermediate.as_ref().unwrap_or(&vr.handle); + let next = nt_open_relative( + cur.raw(), + comp, + true, + ffi::FILE_GENERIC_READ + | ffi::FILE_GENERIC_WRITE + | ffi::FILE_TRAVERSE + | ffi::SYNCHRONIZE, + ffi::FILE_OPEN, + 0, + std::ptr::null_mut(), + ) + .map_err(|_| outside(p))?; + intermediate = Some(next); + } + let ph = intermediate.ok_or_else(|| outside(p))?; + let guid = get_volume_guid_path(ph.raw())?; + if !guid_path_inside_component_wise(&guid, &vr.guid_path, vr.is_directory) { + return Err(outside(p)); + } + (ph, parent.to_path_buf()) + }; + + // If file root, target must be that exact file + if !vr.is_directory && path != vr.manifest { + return Err(outside(p)); + } + + // Obtain target security descriptor while validating it is not a directory or symlink + let dacl = get_security_descriptor_for_write(parent_handle.raw(), leaf_name)?; + + let lease = TempLease { + call_key: call_key.to_owned(), + dir: parent_path_buf.clone(), + target: OsString::from(leaf_name), + temp: temp_name_os.clone(), + roots: roots.to_vec(), + }; + + 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}", + parent_path_buf.join(leaf_name).display() + ), + ) + })?), + None => None, + }; + + let clear = |hold: Option>| { + if let Some(h) = hold { + h.clear(); + } + }; + + // Create temp file with explicit security descriptor + let mut replaced_collision = false; + let temp_handle = loop { + match nt_open_relative( + parent_handle.raw(), + temp_name_str, + false, + ffi::FILE_GENERIC_WRITE | ffi::DELETE | ffi::WRITE_DAC | ffi::SYNCHRONIZE, + ffi::FILE_CREATE, + 0, + dacl.sd, + ) { + Ok(th) => break th, + Err(OpenError::Other(ffi::STATUS_OBJECT_NAME_COLLISION)) if !replaced_collision => { + replaced_collision = true; + let _ = unlink_file(parent_handle.raw(), temp_name_str); + continue; + } + Err(e) => { + return Err(Denial::new( + codes::IO, + format!("failed to create temporary file {temp_name_str}: {e:?}"), + )); + } + } + }; + + if let Some(l) = ledger { + l.created(&lease); + } + + // Write contents to temp file + let bytes = text.as_bytes(); + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let mut offset = 0i64; + while (offset as usize) < bytes.len() { + let chunk = &bytes[offset as usize..]; + let status = unsafe { + ffi::NtWriteFile( + temp_handle.raw(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut io_status, + chunk.as_ptr() as *const std::ffi::c_void, + chunk.len() as u32, + &mut offset, + std::ptr::null_mut(), + ) + }; + if status < 0 { + let _ = unlink_file(parent_handle.raw(), temp_name_str); + return Err(Denial::new( + codes::IO, + format!("failed to write data: 0x{status:08x}"), + )); + } + offset += io_status.Information as i64; + } + + // Flush temp file + unsafe { ffi::NtFlushBuffersFile(temp_handle.raw(), &mut io_status) }; + + // Rename using FileRenameInformationEx POSIX semantics + let wide_target: Vec = leaf_name.encode_utf16().collect(); + let name_bytes = wide_target.len() * std::mem::size_of::(); + let struct_size = std::mem::size_of::() + name_bytes; + let mut rename_buf = vec![0u8; struct_size]; + let rename_info = rename_buf.as_mut_ptr() as *mut ffi::FILE_RENAME_INFORMATION_EX; + unsafe { + (*rename_info).Flags = + ffi::FILE_RENAME_REPLACE_IF_EXISTS | ffi::FILE_RENAME_POSIX_SEMANTICS; + (*rename_info).RootDirectory = parent_handle.raw(); + (*rename_info).FileNameLength = name_bytes as u32; + let dest_slice = + std::slice::from_raw_parts_mut((*rename_info).FileName.as_mut_ptr(), wide_target.len()); + dest_slice.copy_from_slice(&wide_target); + } + + let rename_status = unsafe { + ffi::NtSetInformationFile( + temp_handle.raw(), + &mut io_status, + rename_info as *mut std::ffi::c_void, + struct_size as u32, + ffi::FileRenameInformationEx, + ) + }; + + if rename_status < 0 { + let _ = unlink_file(parent_handle.raw(), temp_name_str); + 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{rename_status:08x})" + ))); + } + return Err(Denial::new( + codes::IO, + format!("rename to {leaf_name} failed: 0x{rename_status:08x}"), + )); + } + + drop(temp_handle); + + // Flush parent directory + unsafe { ffi::NtFlushBuffersFile(parent_handle.raw(), &mut io_status) }; + + 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 joined = lease.dir.join(&lease.target); + let Some(path_str) = joined.to_str() else { + return Ok(TempRemoval::Refused(Denial::invalid( + "the recorded path is not UTF-8", + ))); + }; + + let p = Path::new(path_str); + let parent = p.parent().ok_or_else(|| outside(p))?; + let leaf_name = p + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| outside(p))?; + + let parent_str = parent.to_str().ok_or_else(|| outside(p))?; + let vr = match select_root(parent_str, &lease.roots) { + Ok(v) => v, + 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)), + }; + + if parent != lease.dir || leaf_name != lease.target { + return Ok(TempRemoval::Refused(Denial::denied(format!( + "{} now resolves to {}", + joined.display(), + parent.join(leaf_name).display() + )))); + } + + let temp_str = lease + .temp + .to_str() + .ok_or_else(|| TempRemoval::Refused(Denial::invalid("temp name is not UTF-8")))?; + + let temp_handle = match nt_open_relative( + vr.handle.raw(), + temp_str, + false, + ffi::DELETE | ffi::FILE_READ_ATTRIBUTES | ffi::SYNCHRONIZE, + ffi::FILE_OPEN, + 0, + std::ptr::null_mut(), + ) { + Ok(th) => th, + Err(OpenError::NotFound) => return Ok(TempRemoval::Absent), + Err(OpenError::ReparsePoint) => { + return Ok(TempRemoval::Refused(Denial::denied(format!( + "{} is a symlink, not a temporary file", + lease.dir.join(&lease.temp).display() + )))); + } + Err(_) => { + return Ok(TempRemoval::Refused(Denial::denied(format!( + "failed to open temp file {}", + lease.dir.join(&lease.temp).display() + )))); + } + }; + + let std_info = match query_file_standard(temp_handle.raw()) { + Ok(s) => s, + Err(e) => return Err(Denial::new(codes::IO, e.to_string())), + }; + + if std_info.Directory != 0 { + return Ok(TempRemoval::Refused(Denial::denied(format!( + "{} is a directory, not a temporary file", + lease.dir.join(&lease.temp).display() + )))); + } + + let mut disp = ffi::FILE_DISPOSITION_INFORMATION { DeleteFile: 1 }; + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let status = unsafe { + ffi::NtSetInformationFile( + temp_handle.raw(), + &mut io_status, + &mut disp as *mut _ as *mut std::ffi::c_void, + std::mem::size_of::() as u32, + ffi::FileDispositionInformation, + ) + }; + + if status < 0 { + return Err(Denial::new( + codes::IO, + format!("failed to delete temp file: 0x{status:08x}"), + )); + } + + drop(temp_handle); + Ok(TempRemoval::Removed) +} + +#[cfg(windows)] +pub fn remove_legacy_temps(path: &str, roots: &[String]) -> Result { + validate_raw_spelling(path)?; + let p = Path::new(path); + let parent = p.parent().ok_or_else(|| outside(p))?; + let parent_str = parent.to_str().ok_or_else(|| outside(p))?; + + let dir = open_checked(Path::new(parent_str), roots, true)?; + use std::os::windows::io::AsRawHandle; + let handle = dir.as_raw_handle() as ffi::HANDLE; + + let mut legacy_names = Vec::new(); + let mut buffer = vec![0u8; 64 * 1024]; + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let mut restart = 1u8; + + loop { + 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() as *mut std::ffi::c_void, + buffer.len() as u32, + ffi::FileDirectoryInformation, + 0, + std::ptr::null_mut(), + restart, + ) + }; + restart = 0; + + if status == ffi::STATUS_NO_MORE_FILES || io_status.Information == 0 { + break; + } + if status < 0 { + return Err(Denial::new( + codes::IO, + format!("directory scan failed: 0x{status:08x}"), + )); + } + + let mut offset = 0; + loop { + let entry_ptr = + unsafe { buffer.as_ptr().add(offset) as *const ffi::FILE_DIRECTORY_INFORMATION }; + let entry = unsafe { &*entry_ptr }; + let name_len = (entry.FileNameLength / 2) as usize; + let name_slice = + unsafe { std::slice::from_raw_parts(entry.FileName.as_ptr(), name_len) }; + let name_str = String::from_utf16_lossy(name_slice); + + if (entry.FileAttributes & ffi::FILE_ATTRIBUTE_REPARSE_POINT) == 0 + && (entry.FileAttributes & ffi::FILE_ATTRIBUTE_DIRECTORY) == 0 + && is_legacy_temp_name(OsStr::new(&name_str)) + { + legacy_names.push(name_str); + } + + if entry.NextEntryOffset == 0 { + break; + } + offset += entry.NextEntryOffset as usize; + if offset >= buffer.len() { + break; + } + } + } + + let mut removed = 0; + for name in legacy_names { + if unlink_file(handle, &name).is_ok() { + removed += 1; + } + } + + 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()); + + // 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 + )); + } + + #[cfg(windows)] + mod win_tests { + use super::*; + + 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() + } + } + + impl Drop for WinTree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.base); + } + } + + #[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 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_reparse_stat_is_a_denial() { + let t = WinTree::new("reparse-stat"); + let roots = t.roots(); + + let junction_path = t.root.join("junc"); + let target_path = t.outside.clone(); + let status = std::process::Command::new("cmd") + .args([ + "/c", + "mklink", + "/J", + junction_path.to_str().unwrap(), + target_path.to_str().unwrap(), + ]) + .status() + .expect("mklink /J"); + if !status.success() { + // If mklink failed in environment, skip junction creation + return; + } + + // Stat on junction must be a DENIAL, not { exists: false } + let err = stat(&junction_path.display().to_string(), &roots) + .expect_err("reparse stat denial"); + assert_eq!(err.code, codes::DENIED); + + // Even if target is deleted (dangling junction) + let dangling = t.root.join("dangling-junc"); + let temp_target = t.base.join("to-delete"); + std::fs::create_dir(&temp_target).unwrap(); + let status = std::process::Command::new("cmd") + .args([ + "/c", + "mklink", + "/J", + dangling.to_str().unwrap(), + temp_target.to_str().unwrap(), + ]) + .status() + .unwrap(); + if status.success() { + std::fs::remove_dir(&temp_target).unwrap(); + let err_dangling = stat(&dangling.display().to_string(), &roots) + .expect_err("dangling reparse denial"); + assert_eq!(err_dangling.code, codes::DENIED); + } + } + + #[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 sibling = t.p("sub\\b.txt"); + let err = read(&sibling, &roots, 1024).expect_err("sibling denied"); + assert_eq!(err.code, codes::DENIED); + + // Write to file root replaces file root + write(&file_path, &roots, "replaced").expect("replace file root"); + assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "replaced"); + + // Write to sibling denied + let err_write = + write(&sibling, &roots, "sibling data").expect_err("sibling write denied"); + assert_eq!(err_write.code, codes::DENIED); + } + + #[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"); + } + + #[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 to link + write(&link.display().to_string(), &roots, "new link content").expect("write link"); + + // Hardlink was replaced, not written through! + assert_eq!(std::fs::read_to_string(&link).unwrap(), "new link content"); + assert_eq!(std::fs::read_to_string(&orig).unwrap(), "initial content"); + } + + struct DaclObserver { + temp_sddl: std::sync::Arc>>, + } + + impl TempLedger for DaclObserver { + fn record(&self, _lease: &TempLease) -> Result, String> { + struct Hold; + impl TempHold for Hold { + fn clear(self: Box) {} + } + Ok(Box::new(Hold)) + } + + fn created(&self, lease: &TempLease) { + let temp_path = lease.dir.join(&lease.temp); + let wide: Vec = temp_path + .display() + .to_string() + .encode_utf16() + .chain(Some(0)) + .collect(); + let mut sd = std::ptr::null_mut(); + let err = unsafe { + ffi::GetSecurityInfo( + // Open file handle to read security info + std::fs::File::open(&temp_path).unwrap().as_raw_handle() as ffi::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 && !sd.is_null() { + let mut sddl_ptr = std::ptr::null_mut(); + let ok = unsafe { + ffi::ConvertSecurityDescriptorToStringSecurityDescriptorW( + sd, + 1, // SDDL_REVISION_1 + ffi::DACL_SECURITY_INFORMATION, + &mut sddl_ptr, + std::ptr::null_mut(), + ) + }; + if ok != 0 && !sddl_ptr.is_null() { + let mut len = 0; + while unsafe { *sddl_ptr.add(len) } != 0 { + len += 1; + } + let slice = unsafe { std::slice::from_raw_parts(sddl_ptr, len) }; + *self.temp_sddl.lock().unwrap() = Some(String::from_utf16_lossy(slice)); + unsafe { ffi::LocalFree(sddl_ptr as *mut std::ffi::c_void) }; + } + unsafe { ffi::LocalFree(sd) }; + } + } + } + + #[test] + fn windows_temp_dacl_and_result_dacl() { + let t = WinTree::new("dacl-test"); + let roots = t.roots(); + let target = t.root.join("protected.txt"); + std::fs::write(&target, "secret").expect("write target"); + + // Apply restrictive DACL to target: Protected, allow Read only to Everyone + let sddl = "D:P(A;;GR;;;WD)"; + let wide_sddl: Vec = sddl.encode_utf16().chain(Some(0)).collect(); + let mut sd = std::ptr::null_mut(); + let ok = unsafe { + ffi::ConvertStringSecurityDescriptorToSecurityDescriptorW( + wide_sddl.as_ptr(), + 1, + &mut sd, + std::ptr::null_mut(), + ) + }; + assert_ne!(ok, 0, "create sddl sd"); + + let target_file = std::fs::OpenOptions::new() + .write(true) + .open(&target) + .unwrap(); + let err = unsafe { + ffi::SetSecurityInfo( + target_file.as_raw_handle() as ffi::HANDLE, + ffi::SE_FILE_OBJECT, + ffi::DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + // Extract DACL + { + let mut dacl = std::ptr::null_mut(); + ffi::GetSecurityInfo( + target_file.as_raw_handle() as ffi::HANDLE, + ffi::SE_FILE_OBJECT, + ffi::DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut dacl, + std::ptr::null_mut(), + std::ptr::null_mut(), + ); + dacl + }, + std::ptr::null_mut(), + ) + }; + drop(target_file); + unsafe { ffi::LocalFree(sd) }; + + let temp_sddl_holder = std::sync::Arc::new(std::sync::Mutex::new(None)); + let observer = DaclObserver { + temp_sddl: temp_sddl_holder.clone(), + }; + + write_call( + &target.display().to_string(), + &roots, + "replacement text", + "call-dacl-1", + Some(&observer), + ) + .expect("write call with dacl"); + + // Verify temp DACL was captured during temp phase + let captured = temp_sddl_holder.lock().unwrap().clone(); + assert!( + captured.is_some(), + "temp DACL was observed during temp phase" + ); + + assert_eq!( + std::fs::read_to_string(&target).unwrap(), + "replacement text" + ); + } + } +} From 76b9008f55275b85bfe01aefa08066f04edde77c Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:41:13 +0200 Subject: [PATCH 2/7] mason: add comprehensive Windows fs refusal tests and deterministic test hooks --- crates/basal-host/src/builtins/fs/windows.rs | 397 +++++++++++++++++-- 1 file changed, 359 insertions(+), 38 deletions(-) diff --git a/crates/basal-host/src/builtins/fs/windows.rs b/crates/basal-host/src/builtins/fs/windows.rs index 23f3148..a5dc7e7 100644 --- a/crates/basal-host/src/builtins/fs/windows.rs +++ b/crates/basal-host/src/builtins/fs/windows.rs @@ -987,6 +987,13 @@ pub fn open_checked( Err(outside(resolved)) } +#[cfg(test)] +pub(crate) static SWAP_HOOK: std::sync::Mutex>> = + std::sync::Mutex::new(None); +#[cfg(test)] +pub(crate) static SIMULATE_POSIX_RENAME_REFUSAL: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + #[cfg(windows)] pub fn read(path: &str, roots: &[String], max_bytes: u64) -> Result { let max_bytes = max_bytes.min(MAX_READ_BYTES); @@ -1001,6 +1008,11 @@ pub fn read(path: &str, roots: &[String], max_bytes: u64) -> Result Date: Sat, 10 Oct 2026 15:57:22 +0200 Subject: [PATCH 3/7] mason: suppress unused import warning in fs test module on non-unix --- crates/basal-host/src/builtins/fs/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/basal-host/src/builtins/fs/tests.rs b/crates/basal-host/src/builtins/fs/tests.rs index 1cb472e..216f67e 100644 --- a/crates/basal-host/src/builtins/fs/tests.rs +++ b/crates/basal-host/src/builtins/fs/tests.rs @@ -1,3 +1,4 @@ +#[cfg(unix)] use super::*; #[cfg(unix)] From 061145557921f3d8db6164b901ee21dd90d1a66e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:05:04 +0200 Subject: [PATCH 4/7] Refuse the superscript COM and LPT device names on Windows Windows reserves COM and LPT followed by a superscript one, two or three as well as the ASCII digits. Also write the device-name check as one matches! so it passes clippy. --- crates/basal-host/src/builtins/fs/windows.rs | 51 +++++++++++++++----- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/crates/basal-host/src/builtins/fs/windows.rs b/crates/basal-host/src/builtins/fs/windows.rs index a5dc7e7..ee907cc 100644 --- a/crates/basal-host/src/builtins/fs/windows.rs +++ b/crates/basal-host/src/builtins/fs/windows.rs @@ -145,19 +145,45 @@ pub fn validate_raw_spelling(path: &str) -> Result<(), Denial> { Ok(()) } -/// Whether `name` is a Windows reserved DOS device name. +/// 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(); - match upper.as_str() { - "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" => true, - "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9" | "COM0" => { - true - } - "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9" | "LPT0" => { - true - } - _ => false, - } + 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 @@ -1844,6 +1870,9 @@ mod tests { 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()); From 51662adc3a4faf9671be1cbbd3830d3a705b99b5 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:04:37 +0200 Subject: [PATCH 5/7] mason: add basal-launch, the Windows confined worker launcher A new workspace crate that starts basal's worker under the Windows confinement; off Windows it contains no code. - Profile: one shared AppContainer profile, created or opened under a session-wide named mutex; failure is appcontainer-profile-unavailable. Userenv and User32 are loaded at run time from System32 only. - Tokens: the primary is a restricted copy of the parent's token (every access group deny-only, no privileges, NULL SID as the only restricting SID, Low), lowboxed at creation with zero capabilities. The start-up thread token is a Low same-package impersonation token derived from a never-resumed AppContainer process, set on the suspended main thread and closed before resume. - Creation: CreateProcessAsUserW with STARTUPINFOEX, suspended: LPAC security capabilities with the ALL_APPLICATION_PACKAGES opt-out, the job list, eight always-on mitigations, the child-process ban, and a handle list of exactly the three stdio pipes. Explicit sorted environment (SYSTEMROOT, windir, SYSTEMDRIVE, PATH to System32, TEMP/TMP/LOCALAPPDATA to a private read-only directory), the image directory as cwd, and a private window station and desktop. The parent's environment is never passed on. - Job: flags 0x2508, one live process, no breakaway, the caller's commit limit, UI restrictions 0xff. - Checks before resume: job-limits-mismatch, not-in-owned-job, birth-token-mismatch and initial-token-open, each killing the child. - ConfinedProcess owns process, job and pipes: kill (job, then process, exit 137), try_wait, wait, token and job read-back. - Deviation: Full only without the `deviations` feature; with it, the LPAC-only and plain controls and one variant per worker and parent check. - Tests start a GUI-subsystem test child for real. CI's Windows job runs them first with `--features deviations`. - +crt-static for x86_64-pc-windows-msvc in .cargo/config.toml. --- .cargo/config.toml | 6 + .github/workflows/ci.yml | 11 + Cargo.lock | 7 + Cargo.toml | 2 + crates/basal-launch/Cargo.toml | 42 + crates/basal-launch/src/bin/test-helper.rs | 46 ++ crates/basal-launch/src/lib.rs | 25 + crates/basal-launch/src/windows/context.rs | 282 +++++++ crates/basal-launch/src/windows/deviation.rs | 430 ++++++++++ crates/basal-launch/src/windows/error.rs | 99 +++ crates/basal-launch/src/windows/job.rs | 172 ++++ crates/basal-launch/src/windows/launch.rs | 776 +++++++++++++++++++ crates/basal-launch/src/windows/mod.rs | 75 ++ crates/basal-launch/src/windows/native.rs | 182 +++++ crates/basal-launch/src/windows/process.rs | 181 +++++ crates/basal-launch/src/windows/profile.rs | 403 ++++++++++ crates/basal-launch/src/windows/token.rs | 627 +++++++++++++++ crates/basal-launch/tests/windows_launch.rs | 265 +++++++ 18 files changed, 3631 insertions(+) create mode 100644 crates/basal-launch/Cargo.toml create mode 100644 crates/basal-launch/src/bin/test-helper.rs create mode 100644 crates/basal-launch/src/lib.rs create mode 100644 crates/basal-launch/src/windows/context.rs create mode 100644 crates/basal-launch/src/windows/deviation.rs create mode 100644 crates/basal-launch/src/windows/error.rs create mode 100644 crates/basal-launch/src/windows/job.rs create mode 100644 crates/basal-launch/src/windows/launch.rs create mode 100644 crates/basal-launch/src/windows/mod.rs create mode 100644 crates/basal-launch/src/windows/native.rs create mode 100644 crates/basal-launch/src/windows/process.rs create mode 100644 crates/basal-launch/src/windows/profile.rs create mode 100644 crates/basal-launch/src/windows/token.rs create mode 100644 crates/basal-launch/tests/windows_launch.rs 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c25e81..8ba84be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -386,6 +386,16 @@ 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" - name: Check every workspace target id: check continue-on-error: true @@ -414,6 +424,7 @@ 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 }}" } >> "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() diff --git a/Cargo.lock b/Cargo.lock index 75a7c6a..ed06b67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,6 +92,13 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "basal-launch" +version = "0.1.0" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "basal-module" version = "0.1.0" 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-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..98c50ee --- /dev/null +++ b/crates/basal-launch/src/lib.rs @@ -0,0 +1,25 @@ +//! 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 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..fbfe578 --- /dev/null +++ b/crates/basal-launch/src/windows/context.rs @@ -0,0 +1,282 @@ +//! 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()))?; + context.temp = 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) + } +} diff --git a/crates/basal-launch/src/windows/deviation.rs b/crates/basal-launch/src/windows/deviation.rs new file mode 100644 index 0000000..e96db95 --- /dev/null +++ b/crates/basal-launch/src/windows/deviation.rs @@ -0,0 +1,430 @@ +//! 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`, requested from the worker as + /// for `ThreadTokenPresent`. + #[cfg(feature = "deviations")] + IntegrityLowerFailed, + /// Worker check `not-lpac`: the `ALL_APPLICATION_PACKAGES` opt-out is + /// left out, so the worker is 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`, requested from the worker as + /// for `ThreadTokenPresent`. + #[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 { + // The birth-check variant builds what `privileges-present` + // builds but keeps the full expectation, so only the build + // knob differs; `privileges-present` changes the build and + // the expectation together. + 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..a79780e --- /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 class. + 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..f37ca64 --- /dev/null +++ b/crates/basal-launch/src/windows/launch.rs @@ -0,0 +1,776 @@ +//! 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::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, HANDLE_FLAG_INHERIT, SetHandleInformation, +}; +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)?; + + 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. + for end in [&child_stdin, &child_stdout, &child_stderr] { + check( + unsafe { + SetHandleInformation( + end.as_raw_handle(), + HANDLE_FLAG_INHERIT, + HANDLE_FLAG_INHERIT, + ) + }, + "SetHandleInformation(child pipe end)", + )?; + } + 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 holds its own copies now. + drop((child_stdin, child_stdout, child_stderr)); + 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. +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. +struct AttributeList { + buffer: Vec, +} + +impl AttributeList { + 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) + } + + fn as_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + self.buffer.as_mut_ptr().cast() + } + + 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..3488d09 --- /dev/null +++ b/crates/basal-launch/src/windows/mod.rs @@ -0,0 +1,75 @@ +//! The Windows launcher. + +mod context; +mod deviation; +mod error; +mod job; +mod launch; +mod native; +mod process; +mod profile; +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 process::{ConfinedProcess, KILL_EXIT_CODE}; +pub use profile::{PROFILE_NAME, PackageSid, create_or_open_profile, grant_test_binary_directory}; +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/process.rs b/crates/basal-launch/src/windows/process.rs new file mode 100644 index 0000000..ec4f0fe --- /dev/null +++ b/crates/basal-launch/src/windows/process.rs @@ -0,0 +1,181 @@ +//! The running worker, 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::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 worker 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 worker started by [`launch`](crate::launch), with its job and the +/// parent's ends of its standard pipes. +/// +/// Dropping it kills the worker and waits for it to end, then closes the job +/// (whose kill-on-close limit ends anything else in it) and removes the +/// worker's window station, desktop and TEMP directory. +pub struct ConfinedProcess { + process: OwnedHandle, + pid: u32, + job: OwnedHandle, + package: PackageSid, + inherited_stdio: [usize; 3], + /// Writes to the worker's stdin. + pub stdin: Option, + /// Reads the worker's stdout. + pub stdout: Option, + /// Reads the worker's stderr. + pub stderr: Option, + // Dropped last, after the worker has ended. + 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, + pid, + job, + package, + inherited_stdio, + stdin: Some(stdin), + stdout: Some(stdout), + stderr: Some(stderr), + context, + } + } + + /// The worker's process id. + pub fn id(&self) -> u32 { + self.pid + } + + /// 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 + } + + /// Kills every process in the worker's job, then the worker itself, + /// with [`KILL_EXIT_CODE`]. Killing a worker that has already ended + /// succeeds. + pub fn kill(&self) -> io::Result<()> { + unsafe { + TerminateJobObject(self.job.as_raw_handle(), KILL_EXIT_CODE); + if TerminateProcess(self.process.as_raw_handle(), KILL_EXIT_CODE) != 0 { + return Ok(()); + } + } + let error = io::Error::last_os_error(); + // Terminating a process that has already ended fails with access + // denied; that worker is as killed as it will get. + match self.try_wait()? { + Some(_) => Ok(()), + None => Err(error), + } + } + + /// The exit code, if the worker 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 worker 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 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()) + } + + /// Reads the limits of the worker's job through the parent's own handle. + pub fn job_limits(&self) -> Result { + job::read_limits(self.job.as_raw_handle()) + } + + /// Whether the worker 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 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.pid) + .field("package", &self.package) + .finish_non_exhaustive() + } +} + +impl Drop for ConfinedProcess { + fn drop(&mut self) { + if self.kill().is_ok() { + let _ = self.wait(); + } + } +} 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/token.rs b/crates/basal-launch/src/windows/token.rs new file mode 100644 index 0000000..42c7b14 --- /dev/null +++ b/crates/basal-launch/src/windows/token.rs @@ -0,0 +1,627 @@ +//! 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 for this answers "invalid class" on the Windows +/// versions tested, 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..b99567a --- /dev/null +++ b/crates/basal-launch/tests/windows_launch.rs @@ -0,0 +1,265 @@ +//! 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: the directory grants the + /// package read and execute only. + #[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_eq!(report[1], "temp-write denied 5"); + 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); + } +} From 1f8d0834d9248c2a5451e9f2bab4101cdd242528 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:05:59 +0200 Subject: [PATCH 6/7] mason: clarify basal-launch comments for a reader without context --- crates/basal-launch/src/windows/deviation.rs | 24 ++++++++++++-------- crates/basal-launch/src/windows/job.rs | 2 +- crates/basal-launch/src/windows/launch.rs | 3 ++- crates/basal-launch/src/windows/token.rs | 5 ++-- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/crates/basal-launch/src/windows/deviation.rs b/crates/basal-launch/src/windows/deviation.rs index e96db95..de5867b 100644 --- a/crates/basal-launch/src/windows/deviation.rs +++ b/crates/basal-launch/src/windows/deviation.rs @@ -39,12 +39,14 @@ pub enum Deviation { /// keep one (see [`Deviation::child_argument`]). #[cfg(feature = "deviations")] ThreadTokenPresent, - /// Worker check `integrity-lower-failed`, requested from the worker as - /// for `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 `ALL_APPLICATION_PACKAGES` opt-out is - /// left out, so the worker is an ordinary AppContainer. + /// 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. @@ -61,8 +63,9 @@ pub enum Deviation { /// filtering are not removed. #[cfg(feature = "deviations")] PrivilegesPresent, - /// Worker check `integrity-not-untrusted`, requested from the worker as - /// for `ThreadTokenPresent`. + /// 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 @@ -399,10 +402,11 @@ mod tests { .filter(|(a, b)| a != b) .count(); let expected = match deviation { - // The birth-check variant builds what `privileges-present` - // builds but keeps the full expectation, so only the build - // knob differs; `privileges-present` changes the build and - // the expectation together. + // `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, }; diff --git a/crates/basal-launch/src/windows/job.rs b/crates/basal-launch/src/windows/job.rs index a79780e..e61fd1a 100644 --- a/crates/basal-launch/src/windows/job.rs +++ b/crates/basal-launch/src/windows/job.rs @@ -42,7 +42,7 @@ pub struct JobLimits { 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 class. + /// The UI restriction flags (see [`JOB_UI_RESTRICTIONS`]). pub ui_restrictions: u32, } diff --git a/crates/basal-launch/src/windows/launch.rs b/crates/basal-launch/src/windows/launch.rs index f37ca64..e91be6d 100644 --- a/crates/basal-launch/src/windows/launch.rs +++ b/crates/basal-launch/src/windows/launch.rs @@ -182,7 +182,8 @@ pub fn launch(options: &LaunchOptions) -> std::result::Result Result { unsafe { let mut bytes = 0; From 8c40332f38ec154841e9ee65f9d25ac9e282633e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:10:29 +0200 Subject: [PATCH 7/7] mason: treat a job-killed worker as killed, and give the worker a long TEMP path On windows-latest, kill() failed with access denied: TerminateJobObject had already begun ending the worker, so the following TerminateProcess was refused while the process was not yet signaled. A successful job kill of a worker that is a member of the job is now a successful kill. The LPAC-only control's TEMP write failed with Win32 3, not 5: the system TEMP path holds 8.3 short names (RUNNER~1). The worker's TEMP is now the canonical long path, and the control's assertion accepts any denial, since only the plain control needs to show the path is writable. --- crates/basal-launch/src/windows/context.rs | 17 ++++++++++++++++- crates/basal-launch/src/windows/process.rs | 18 +++++++++++------- crates/basal-launch/tests/windows_launch.rs | 5 ++--- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/crates/basal-launch/src/windows/context.rs b/crates/basal-launch/src/windows/context.rs index fbfe578..df97651 100644 --- a/crates/basal-launch/src/windows/context.rs +++ b/crates/basal-launch/src/windows/context.rs @@ -261,7 +261,9 @@ pub(crate) fn create(image: &Path, parent_token: HANDLE, package: &PackageSid) - let temp = std::env::temp_dir().join(&name); std::fs::create_dir(&temp) .map_err(|error| format!("create {}: {error}", temp.display()))?; - context.temp = temp; + // 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)" @@ -280,3 +282,16 @@ pub(crate) fn create(image: &Path, parent_token: HANDLE, package: &PackageSid) - 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/process.rs b/crates/basal-launch/src/windows/process.rs index ec4f0fe..3331391 100644 --- a/crates/basal-launch/src/windows/process.rs +++ b/crates/basal-launch/src/windows/process.rs @@ -95,15 +95,19 @@ impl ConfinedProcess { /// with [`KILL_EXIT_CODE`]. Killing a worker that has already ended /// succeeds. pub fn kill(&self) -> io::Result<()> { - unsafe { - TerminateJobObject(self.job.as_raw_handle(), KILL_EXIT_CODE); - if TerminateProcess(self.process.as_raw_handle(), KILL_EXIT_CODE) != 0 { - return Ok(()); - } + 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 has already ended fails with access - // denied; that worker is as killed as it will get. + // Terminating a process that is already ending fails with access + // denied. That happens right after the job kill above, which ends the + // worker (a member of the job) whether or not it has finished + // exiting yet, and for a worker 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), diff --git a/crates/basal-launch/tests/windows_launch.rs b/crates/basal-launch/tests/windows_launch.rs index b99567a..e64f42b 100644 --- a/crates/basal-launch/tests/windows_launch.rs +++ b/crates/basal-launch/tests/windows_launch.rs @@ -231,14 +231,13 @@ mod deviations { /// 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: the directory grants the - /// package read and execute only. + /// 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_eq!(report[1], "temp-write denied 5"); + 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!(