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 01/13] 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 02/13] 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 03/13] 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 04/13] 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 7d242631ccbbe753351c3efdb475102496077a4b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:55:55 +0200 Subject: [PATCH 05/13] mason: fix the Windows fs built-in from its security review The module now compiles for Windows (x86_64 and aarch64 MSVC) with no warnings under clippy -D warnings, and its tests can run. Writes: - Every directory from the volume root down to the target's parent is held without FILE_SHARE_DELETE until the rename returns, so none can be moved out of the root mid-write; the parent's location is re-checked just before the rename. - A failed flush of the temporary file stops the write before the rename. - A new file is created with no explicit security descriptor, so NTFS applies real inheritance; the CreatePrivateObjectSecurity path is gone. A replaced file keeps its DACL, protection flag included. - The root is selected by the whole path, so a write to a file root works. - Intermediate directories are opened with attribute and traverse access only; a directory target is refused as "not a regular file". - A read-only target is replaced, as renameat ignores a target's mode. Roots and opens: - Root handles are reopened with the access each use needs, so a file root can be read and a directory root listed. - A failed directory query is an error, never an empty listing. - Walk errors keep their kind across roots (IO, then NOT_FOUND, then the refusal); a volume root X:\ grants its children; a file root is opened without FILE_TRAVERSE. - The reparse check fails closed, and the temp-file cleanup removes only regular files. - Directory replies are parsed as bytes bounded by the reported length, the rename buffer is 8-byte aligned and written through raw pointers, UNICODE_STRING lengths are checked, and OwnedHandle's field is private. - remove_temp walks to the lease's own directory. Tests: link-based tests assert the link exists; the swap test uses expect_err; the DACL tests apply a DACL and compare SDDL (P on replace, ID ACEs on create); new tests cover list, subdirectory create, remove_temp, legacy temps, volume roots, mount points, the held parent, flush failure and temp-name collisions. The test hooks are thread-local. The POSIX refusal test now matches the real message. fs.rs only gates helpers Windows does not use, and shares outside/io_denial/TEMP_SEQ. --- crates/basal-host/src/builtins/fs.rs | 5 +- crates/basal-host/src/builtins/fs/windows.rs | 2981 +++++++++++------- 2 files changed, 1802 insertions(+), 1184 deletions(-) diff --git a/crates/basal-host/src/builtins/fs.rs b/crates/basal-host/src/builtins/fs.rs index 0c5aa39..b8f79fd 100644 --- a/crates/basal-host/src/builtins/fs.rs +++ b/crates/basal-host/src/builtins/fs.rs @@ -52,7 +52,9 @@ use std::sync::atomic::Ordering; #[cfg(unix)] use serde_json::{Value, json}; -use super::{Denial, codes, expand_home}; +#[cfg(unix)] +use super::expand_home; +use super::{Denial, codes}; #[cfg(target_os = "linux")] pub use linux::{ @@ -129,6 +131,7 @@ fn io_denial(path: &Path, e: &std::io::Error) -> Denial { } /// The script's path as an absolute path, with `~` expanded. +#[cfg(unix)] fn absolute(path: &str) -> Result { if path.len() > MAX_PATH_BYTES || path.contains('\0') { return Err(Denial::invalid(format!( diff --git a/crates/basal-host/src/builtins/fs/windows.rs b/crates/basal-host/src/builtins/fs/windows.rs index ee907cc..ced6dd2 100644 --- a/crates/basal-host/src/builtins/fs/windows.rs +++ b/crates/basal-host/src/builtins/fs/windows.rs @@ -1,9 +1,19 @@ //! 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. +//! A path's spelling is checked before anything is opened +//! ([`validate_raw_spelling`]). Every root and every path is then walked one +//! component at a time from `\??\X:\`, each component opened relative to the +//! handle of the one before, so Windows never rewrites a path, and a reparse +//! point (symlink, junction, mount point or any other) is refused wherever +//! it appears. The handle finally acted on is checked once more: its +//! volume-GUID path must lie under the root's, component by component. +//! +//! `fs.write` holds every directory from the volume root down to the +//! target's parent without `FILE_SHARE_DELETE` until its rename returns, so +//! none of them can be moved out of the root while it runs. It writes a +//! temporary file, flushes it, and renames it over the target with POSIX +//! semantics, failing rather than falling back to any other rename. A +//! replaced file keeps its DACL; a new file takes what NTFS inherits. #![cfg_attr(not(windows), allow(unused))] @@ -15,7 +25,7 @@ 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 super::{TEMP_SEQ, check_write_size, io_denial, is_temp_name, outside}; use crate::builtins::Denial; pub use crate::builtins::codes; @@ -29,23 +39,6 @@ pub enum Target { 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:\...`). @@ -214,38 +207,36 @@ pub fn guid_path_inside_component_wise( } #[cfg(windows)] +#[allow(non_snake_case, non_upper_case_globals, clippy::upper_case_acronyms)] mod ffi { use std::ffi::c_void; pub type NTSTATUS = i32; pub type HANDLE = *mut c_void; - pub const 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_OBJECT_NAME_COLLISION: NTSTATUS = 0xC0000035_u32 as i32; pub const STATUS_ACCESS_DENIED: NTSTATUS = 0xC0000022_u32 as i32; pub const STATUS_NOT_A_DIRECTORY: NTSTATUS = 0xC0000103_u32 as i32; pub const STATUS_FILE_IS_A_DIRECTORY: NTSTATUS = 0xC00000BA_u32 as i32; pub const STATUS_NOT_SUPPORTED: NTSTATUS = 0xC00000BB_u32 as i32; pub const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000000D_u32 as i32; + pub const STATUS_NAME_TOO_LONG: NTSTATUS = 0xC0000106_u32 as i32; + pub const STATUS_FILE_CORRUPT_ERROR: NTSTATUS = 0xC0000102_u32 as i32; pub const STATUS_NO_MORE_FILES: NTSTATUS = 0x80000006_u32 as i32; - pub const STATUS_OBJECT_NAME_COLLISION: NTSTATUS = 0xC0000035_u32 as i32; + #[cfg(test)] + pub const STATUS_IO_DEVICE_ERROR: NTSTATUS = 0xC0000185_u32 as i32; - pub const FILE_READ_DATA: u32 = 0x0001; + pub const FILE_LIST_DIRECTORY: 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 FILE_READ_ATTRIBUTES: u32 = 0x0080; 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; @@ -253,7 +244,6 @@ mod ffi { 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; @@ -274,6 +264,7 @@ mod ffi { pub const FILE_RENAME_REPLACE_IF_EXISTS: u32 = 0x00000001; pub const FILE_RENAME_POSIX_SEMANTICS: u32 = 0x00000002; + pub const FILE_RENAME_IGNORE_READONLY_ATTRIBUTE: u32 = 0x00000040; pub const FILE_NAME_NORMALIZED: u32 = 0x0; pub const VOLUME_NAME_GUID: u32 = 0x1; @@ -326,6 +317,11 @@ mod ffi { pub Reserved: [u8; 2], } + /// One entry of an `NtQueryDirectoryFile` reply. Entries are never read + /// through this type: the reply is parsed as bytes at the offsets + /// `offset_of!` gives for these fields, so a misaligned or truncated + /// reply cannot make a reference to it. + #[allow(dead_code)] #[repr(C)] pub struct FILE_DIRECTORY_INFORMATION { pub NextEntryOffset: u32, @@ -341,6 +337,10 @@ mod ffi { pub FileName: [u16; 1], } + /// The header of a rename request. It is written field by field through + /// a raw pointer into an 8-byte-aligned buffer that also holds the name, + /// never built as a value. + #[allow(dead_code)] #[repr(C)] pub struct FILE_RENAME_INFORMATION_EX { pub Flags: u32, @@ -354,14 +354,6 @@ mod ffi { 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( @@ -410,18 +402,6 @@ mod ffi { 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, @@ -450,12 +430,6 @@ mod ffi { ) -> 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")] @@ -470,132 +444,176 @@ mod ffi { 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; } } +/// Every sharing mode: what reads, stats and listings open with. +#[cfg(windows)] +const SHARE_ALL: u32 = ffi::FILE_SHARE_READ | ffi::FILE_SHARE_WRITE | ffi::FILE_SHARE_DELETE; + +/// The sharing mode of the directories a write holds while it runs. Without +/// `FILE_SHARE_DELETE` nobody else can open them for `DELETE`, which a +/// rename or a delete of the directory needs, so none of them can be moved +/// out of the root before the write's rename returns. +#[cfg(windows)] +const SHARE_NO_DELETE: u32 = ffi::FILE_SHARE_READ | ffi::FILE_SHARE_WRITE; + +/// The access a directory on a walked path is opened with. `FILE_TRAVERSE` +/// is there for sharing, not for the walk itself: the kernel records a +/// handle's sharing mode only when the handle has read, write, execute +/// (`FILE_TRAVERSE` is the execute bit) or delete access. A held directory +/// opened with attribute access alone would not stop anyone renaming it. +#[cfg(windows)] +const DIR_WALK: u32 = ffi::FILE_READ_ATTRIBUTES | ffi::FILE_TRAVERSE | ffi::SYNCHRONIZE; + +/// A handle this module opened and closes. The field is private and the +/// only constructor is [`nt_open_relative`], so no code can hand an +/// arbitrary pointer to `NtClose`. #[cfg(windows)] -pub struct OwnedHandle(pub ffi::HANDLE); +struct OwnedHandle(ffi::HANDLE); #[cfg(windows)] impl OwnedHandle { - pub fn raw(&self) -> ffi::HANDLE { + 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 + fn into_file(self) -> std::fs::File { + use std::os::windows::io::FromRawHandle; + let raw = self.0; + std::mem::forget(self); + // SAFETY: `raw` is an open handle this value owned; forgetting the + // value hands that ownership to the File exactly once. + unsafe { std::fs::File::from_raw_handle(raw) } } } #[cfg(windows)] impl Drop for OwnedHandle { fn drop(&mut self) { - 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) } + // SAFETY: the handle came from a successful NtCreateFile and is + // closed exactly once, here. + unsafe { ffi::NtClose(self.0) }; } } +/// A security descriptor allocated by `GetSecurityInfo`, freed on drop. #[cfg(windows)] struct FileDacl { sd: *mut std::ffi::c_void, - 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) }; - } + // SAFETY: `sd` came from GetSecurityInfo, whose descriptors are + // documented to be released with LocalFree, and is freed once. + unsafe { ffi::LocalFree(self.sd) }; } } } #[cfg(windows)] -#[derive(Debug)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OpenError { NotFound, ReparsePoint, AccessDenied, + NotADirectory, + IsADirectory, + Collision, 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, +impl OpenError { + fn status(self) -> ffi::NTSTATUS { + match self { + Self::NotFound => ffi::STATUS_OBJECT_NAME_NOT_FOUND, + // Not an NT status of its own: the open succeeded and the entry + // was refused for what it is. + Self::ReparsePoint => ffi::STATUS_ACCESS_DENIED, + Self::AccessDenied => ffi::STATUS_ACCESS_DENIED, + Self::NotADirectory => ffi::STATUS_NOT_A_DIRECTORY, + Self::IsADirectory => ffi::STATUS_FILE_IS_A_DIRECTORY, + Self::Collision => ffi::STATUS_OBJECT_NAME_COLLISION, + Self::Other(status) => status, + } + } +} + +/// How [`nt_open_relative`] opens a name. +#[cfg(windows)] +#[derive(Clone, Copy)] +struct Open { + access: u32, + share: u32, + disposition: u32, + options: u32, security_descriptor: *mut std::ffi::c_void, -) -> Result { - let wide_name: Vec = name.encode_utf16().collect(); +} + +#[cfg(windows)] +impl Open { + /// Opens an existing entry of any type, sharing everything. + fn existing(access: u32) -> Self { + Self { + access, + share: SHARE_ALL, + disposition: ffi::FILE_OPEN, + options: 0, + security_descriptor: std::ptr::null_mut(), + } + } + + fn directory(mut self) -> Self { + self.options |= ffi::FILE_DIRECTORY_FILE; + self + } + + fn non_directory(mut self) -> Self { + self.options |= ffi::FILE_NON_DIRECTORY_FILE; + self + } + + fn share(mut self, share: u32) -> Self { + self.share = share; + self + } +} + +/// Opens `name`, one component, relative to the directory `root` (or, with +/// a null `root`, the NT path `name`). An empty `name` opens `root` itself +/// again, with the access `open` asks for. +/// +/// No reparse point is ever followed (`FILE_OPEN_REPARSE_POINT`), and one +/// that was opened is refused: the handle is asked for its attributes, and +/// an entry that is a reparse point, or whose attributes cannot be read, is +/// closed and refused. `FILE_READ_ATTRIBUTES` is added to every request so +/// that check always has the access it needs. +#[cfg(windows)] +fn nt_open_relative(root: ffi::HANDLE, name: &str, open: Open) -> Result { + let mut wide: Vec = name.encode_utf16().collect(); + // A UNICODE_STRING counts bytes in a u16; a longer name would be cut to + // a prefix, which can name a different entry. + let length = + u16::try_from(wide.len() * 2).map_err(|_| OpenError::Other(ffi::STATUS_NAME_TOO_LONG))?; + let maximum = length + .checked_add(2) + .ok_or(OpenError::Other(ffi::STATUS_NAME_TOO_LONG))?; + wide.push(0); let mut unicode_name = ffi::UNICODE_STRING { - Length: (wide_name.len() * 2) as u16, - MaximumLength: (wide_name.len() * 2) as u16, - Buffer: wide_name.as_ptr() as *mut u16, + Length: length, + MaximumLength: maximum, + Buffer: wide.as_mut_ptr(), }; let mut obj_attr = ffi::OBJECT_ATTRIBUTES { Length: std::mem::size_of::() as u32, RootDirectory: root, - ObjectName: if wide_name.is_empty() { - std::ptr::null_mut() - } else { - &mut unicode_name - }, + ObjectName: &mut unicode_name, Attributes: ffi::OBJ_CASE_INSENSITIVE, - SecurityDescriptor: security_descriptor, + SecurityDescriptor: open.security_descriptor, SecurityQualityOfService: std::ptr::null_mut(), }; let mut handle = std::ptr::null_mut(); @@ -603,22 +621,21 @@ fn nt_open_relative( 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; - } + // SAFETY: every pointer refers to a live local that outlives the call: + // the name buffer `wide`, the `unicode_name` describing it, the object + // attributes, the status block, and `handle`, which receives the new + // handle on success. let status = unsafe { ffi::NtCreateFile( &mut handle, - desired_access, + open.access | ffi::FILE_READ_ATTRIBUTES, &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, + open.share, + open.disposition, + open.options | ffi::FILE_SYNCHRONOUS_IO_NONALERT | ffi::FILE_OPEN_REPARSE_POINT, std::ptr::null_mut(), 0, ) @@ -629,80 +646,116 @@ fn nt_open_relative( OpenError::NotFound } ffi::STATUS_ACCESS_DENIED => OpenError::AccessDenied, + ffi::STATUS_NOT_A_DIRECTORY => OpenError::NotADirectory, + ffi::STATUS_FILE_IS_A_DIRECTORY => OpenError::IsADirectory, + ffi::STATUS_OBJECT_NAME_COLLISION => OpenError::Collision, _ => OpenError::Other(status), }); } - - // 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) }; + let handle = OwnedHandle(handle); + // Fail closed: an entry whose attributes cannot be read is not known + // not to be a reparse point. + let basic = query_file_basic(handle.raw()).map_err(OpenError::Other)?; + if basic.FileAttributes & ffi::FILE_ATTRIBUTE_REPARSE_POINT != 0 { return Err(OpenError::ReparsePoint); } + Ok(handle) +} - Ok(OwnedHandle(handle)) +/// Opens the file or directory behind `handle` again, with the access +/// `open` asks for. An NT open of an empty name relative to a handle opens +/// that handle's own file object (this is how `ReOpenFile` works), so the +/// new handle is for the object already checked, not for whatever a path +/// names now. +#[cfg(windows)] +fn nt_reopen(handle: &OwnedHandle, open: Open) -> Result { + nt_open_relative(handle.raw(), "", open) +} + +/// Opens each of `components` beneath `start` as a directory, in order, and +/// returns every handle (the last is the deepest). +#[cfg(windows)] +fn walk_dirs( + start: &OwnedHandle, + components: &[&str], + open: Open, +) -> Result, OpenError> { + let mut held: Vec = Vec::with_capacity(components.len()); + for comp in components { + let current = held.last().unwrap_or(start); + let next = nt_open_relative(current.raw(), comp, open.directory())?; + held.push(next); + } + Ok(held) } #[cfg(windows)] -fn query_file_basic(handle: ffi::HANDLE) -> std::io::Result { +fn query_file_basic(handle: ffi::HANDLE) -> Result { let mut io_status = ffi::IO_STATUS_BLOCK { Status: 0, Information: 0, }; let mut info = ffi::FILE_BASIC_INFORMATION::default(); + // SAFETY: the output buffer is `info`, and the length passed is its size. let status = unsafe { ffi::NtQueryInformationFile( handle, &mut io_status, - &mut info as *mut _ as *mut std::ffi::c_void, + (&mut info as *mut ffi::FILE_BASIC_INFORMATION).cast(), std::mem::size_of::() as u32, ffi::FileBasicInformation, ) }; if status < 0 { - return Err(std::io::Error::from_raw_os_error(status)); + return Err(status); } Ok(info) } #[cfg(windows)] -fn query_file_standard(handle: ffi::HANDLE) -> std::io::Result { +fn query_file_standard( + handle: ffi::HANDLE, +) -> Result { let mut io_status = ffi::IO_STATUS_BLOCK { Status: 0, Information: 0, }; let mut info = ffi::FILE_STANDARD_INFORMATION::default(); + // SAFETY: the output buffer is `info`, and the length passed is its size. let status = unsafe { ffi::NtQueryInformationFile( handle, &mut io_status, - &mut info as *mut _ as *mut std::ffi::c_void, + (&mut info as *mut ffi::FILE_STANDARD_INFORMATION).cast(), std::mem::size_of::() as u32, ffi::FileStandardInformation, ) }; if status < 0 { - return Err(std::io::Error::from_raw_os_error(status)); + return Err(status); } Ok(info) } +/// A denial with code `IO` for an NT call on `path` that failed with +/// `status`, naming both. +#[cfg(windows)] +fn nt_io(path: &Path, what: &str, status: ffi::NTSTATUS) -> Denial { + Denial::new( + codes::IO, + format!( + "{}: {what} failed (NT status 0x{:08x})", + path.display(), + status as u32 + ), + ) +} + #[cfg(windows)] fn get_volume_guid_path(handle: ffi::HANDLE) -> Result { let mut buf = vec![0u16; 1024]; + // SAFETY: `buf` is writable for the `buf.len()` UTF-16 units passed as + // the capacity. let len = unsafe { ffi::GetFinalPathNameByHandleW( handle, @@ -716,6 +769,8 @@ fn get_volume_guid_path(handle: ffi::HANDLE) -> Result { } if len as usize > buf.len() { buf.resize(len as usize + 1, 0); + // SAFETY: `buf` was grown to the length the first call asked for and + // is writable for the `buf.len()` units passed as the capacity. let len2 = unsafe { ffi::GetFinalPathNameByHandleW( handle, @@ -724,7 +779,7 @@ fn get_volume_guid_path(handle: ffi::HANDLE) -> Result { ffi::VOLUME_NAME_GUID | ffi::FILE_NAME_NORMALIZED, ) }; - if len2 == 0 { + if len2 == 0 || len2 as usize > buf.len() { return Err(Denial::denied("failed to get volume GUID path for handle")); } buf.truncate(len2 as usize); @@ -734,90 +789,130 @@ fn get_volume_guid_path(handle: ffi::HANDLE) -> Result { String::from_utf16(&buf).map_err(|_| Denial::denied("volume GUID path is not UTF-16")) } +/// Whether `parent_guid` is the directory that holds the file root whose +/// volume-GUID path is `file_root_guid`, compared component by component. +#[cfg(windows)] +fn guid_is_parent_of(parent_guid: &str, file_root_guid: &str) -> bool { + let parent: Vec<&str> = parent_guid.split('\\').filter(|s| !s.is_empty()).collect(); + let root: Vec<&str> = file_root_guid + .split('\\') + .filter(|s| !s.is_empty()) + .collect(); + root.split_last() + .is_some_and(|(_, head)| head == parent.as_slice()) +} + +/// A manifest root after [`walk_from_volume_root`] opened it from its +/// volume's root directory, refusing reparse points, with its volume-GUID +/// path for checking where later handles really are. #[cfg(windows)] struct VerifiedRoot { manifest: String, guid_path: String, is_directory: bool, + /// The root itself: [`walk_from_volume_root`] opens a directory with + /// [`DIR_WALK`] and a file with attribute access only. Operations that need more open it again with + /// [`nt_reopen`]. handle: OwnedHandle, + /// The directories above the root, from the volume root down; the last + /// is the root's parent. Empty for a volume root. + ancestors: Vec, } +/// The denial for a failed open of `comp` while walking to `manifest_root`. #[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")) - } +fn root_walk_denial(e: OpenError, comp: &str, manifest_root: &str) -> Denial { + match e { + OpenError::ReparsePoint => Denial::denied(format!( + "{comp} in root {manifest_root} is a reparse point; fs refuses every reparse point" + )), OpenError::NotFound => Denial::new( codes::NOT_FOUND, - format!("volume root {drive_prefix} does not exist"), + format!("root {manifest_root} does not exist"), ), - _ => Denial::denied(format!("failed to open volume root {drive_prefix}")), - })?; + OpenError::AccessDenied => Denial::denied(format!( + "access to {comp} in root {manifest_root} is denied" + )), + OpenError::NotADirectory => { + Denial::denied(format!("{comp} in root {manifest_root} is not a directory")) + } + other => Denial::new( + codes::IO, + format!( + "opening {comp} in root {manifest_root} failed (NT status 0x{:08x})", + other.status() as u32 + ), + ), + } +} - let remainder = &manifest_root[3..]; - if remainder.is_empty() { - let guid_path = get_volume_guid_path(root_vol.raw())?; +/// Walks `manifest_root` from its volume's root directory, one component at +/// a time, refusing a reparse point anywhere. Every handle is opened with +/// `share`. +#[cfg(windows)] +fn walk_from_volume_root(manifest_root: &str, share: u32) -> Result { + validate_raw_spelling(manifest_root)?; + let drive = &manifest_root[..3]; // e.g. "C:\" + let volume = nt_open_relative( + std::ptr::null_mut(), + &format!(r"\??\{drive}"), + Open::existing(DIR_WALK).directory().share(share), + ) + .map_err(|e| root_walk_denial(e, drive, manifest_root))?; + + let rest = &manifest_root[3..]; + if rest.is_empty() { + let guid_path = get_volume_guid_path(volume.raw())?; return Ok(VerifiedRoot { manifest: manifest_root.to_owned(), guid_path, is_directory: true, - handle: root_vol, + handle: volume, + ancestors: Vec::new(), }); } - 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 components: Vec<&str> = rest.split('\\').collect(); + let Some((last, middle)) = components.split_last() else { + return Err(outside(Path::new(manifest_root))); + }; + let mut ancestors = vec![volume]; + for comp in middle { + let current = ancestors.last().expect("the volume root is held"); let next = nt_open_relative( current.raw(), comp, - !is_last, // intermediate components must be directories - ffi::FILE_READ_ATTRIBUTES | ffi::FILE_TRAVERSE | ffi::SYNCHRONIZE, - ffi::FILE_OPEN, - 0, - std::ptr::null_mut(), + Open::existing(DIR_WALK).directory().share(share), ) - .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; - } - + .map_err(|e| root_walk_denial(e, comp, manifest_root))?; + ancestors.push(next); + } + let parent = ancestors.last().expect("the volume root is held"); + // The root's type is not known before it is open, so it is opened first + // with attribute access, which any type grants. A directory is then + // opened again with FILE_TRAVERSE, which a held directory needs to take + // part in sharing checks; a file is not, because on a file that bit is + // the execute right and may not be granted. A file root keeps the + // first handle, which shares everything: it is the very file a write + // to the root replaces, so it must never stand in the way of that. + let first = nt_open_relative(parent.raw(), last, Open::existing(ffi::SYNCHRONIZE)) + .map_err(|e| root_walk_denial(e, last, manifest_root))?; let std_info = - query_file_standard(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())?; - + query_file_standard(first.raw()).map_err(|s| nt_io(Path::new(manifest_root), "stat", s))?; + let is_directory = std_info.Directory != 0; + let handle = if is_directory { + nt_reopen(&first, Open::existing(DIR_WALK).directory().share(share)) + .map_err(|e| root_walk_denial(e, last, manifest_root))? + } else { + first + }; + let guid_path = get_volume_guid_path(handle.raw())?; Ok(VerifiedRoot { manifest: manifest_root.to_owned(), guid_path, - is_directory: is_dir, - handle: current, + is_directory, + handle, + ancestors, }) } @@ -831,105 +926,138 @@ fn filetime_to_mtime_ms(ft: i64) -> i64 { } } +/// Whether `path` is `root` or lies beneath it, by spelling. A volume root +/// (`X:\`, the only root spelling that ends in a separator) covers every +/// path on its drive. +fn path_under(path: &str, root: &str) -> bool { + path == root + || path + .strip_prefix(root) + .is_some_and(|rest| root.ends_with('\\') || rest.starts_with('\\')) +} + +/// The components of `path` below `root`, which it must lie under +/// ([`path_under`]); empty when it is the root. +fn components_below<'a>(path: &'a str, root: &str) -> Vec<&'a str> { + let rest = &path[root.len()..]; + let rest = rest.strip_prefix('\\').unwrap_or(rest); + if rest.is_empty() { + Vec::new() + } else { + rest.split('\\').collect() + } +} + +/// How bad a root's failure is, for choosing which one to report: a +/// failure that may pass beats a missing root, which beats a refusal. +#[cfg(windows)] +fn failure_rank(d: &Denial) -> u8 { + if d.code == codes::IO { + 2 + } else if d.code == codes::NOT_FOUND { + 1 + } else { + 0 + } +} + +/// Finds a manifest root that `path` lies under and walks it +/// ([`walk_from_volume_root`]). A file root grants only its own path. When +/// every root `path` lies under fails to walk, the most telling failure is +/// returned: an `IO` error (which may succeed if tried again), then +/// `NOT_FOUND` (the root is gone), then a refusal. `remove_temp` relies on +/// that order to keep a record after a transient failure and to drop one +/// whose directory no longer exists. #[cfg(windows)] -fn select_root<'a>(path: &str, roots: &'a [String]) -> Result { +fn select_root(path: &str, roots: &[String], share: u32) -> Result { validate_raw_spelling(path)?; + let mut failure: Option = None; 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); + if validate_raw_spelling(r).is_err() || !path_under(path, r) { + continue; + } + match walk_from_volume_root(r, share) { + Ok(vr) if vr.is_directory || path == vr.manifest => return Ok(vr), + Ok(_) => {} + Err(d) => { + if failure + .as_ref() + .is_none_or(|f| failure_rank(&d) > failure_rank(f)) + { + failure = Some(d); } } } } - Err(outside(Path::new(path))) + Err(failure.unwrap_or_else(|| outside(Path::new(path)))) +} + +/// The denial for a failed open of a directory between a root and the +/// entry `path` names: a reparse point is refused as one, an unexpected NT +/// failure is `IO`, and a missing, unreadable or non-directory component +/// is "outside the manifest's roots or missing", the answer the Unix +/// implementation gives when resolving such a path fails. +#[cfg(windows)] +fn intermediate_denial(e: OpenError, path: &Path) -> Denial { + match e { + OpenError::ReparsePoint => Denial::denied(format!( + "{} contains a reparse point; fs refuses every reparse point", + path.display() + )), + OpenError::Other(status) => nt_io(path, "opening a directory", status), + _ => outside(path), + } } #[cfg(windows)] pub fn resolve(path: &str, roots: &[String], purpose: Purpose) -> Result { validate_raw_spelling(path)?; - let vr = select_root(path, roots)?; + let vr = select_root(path, roots, SHARE_ALL)?; + let p = Path::new(path); if purpose == Purpose::Read { if path == vr.manifest { return Ok(Target::Existing(PathBuf::from(path))); } - 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))); - } - } + let components = components_below(path, &vr.manifest); + let Some((last, middle)) = components.split_last() else { + return Err(outside(p)); + }; + let dirs = walk_dirs(&vr.handle, middle, Open::existing(ffi::SYNCHRONIZE)) + .map_err(|e| intermediate_denial(e, p))?; + let parent = dirs.last().unwrap_or(&vr.handle); + match nt_open_relative(parent.raw(), last, Open::existing(ffi::SYNCHRONIZE)) { + Ok(leaf) => { + let guid = get_volume_guid_path(leaf.raw())?; + return if guid_path_inside_component_wise(&guid, &vr.guid_path, vr.is_directory) { + Ok(Target::Existing(PathBuf::from(path))) + } else { + Err(outside(p)) + }; } - if !found { - // fall through to Target::Entry + // A missing entry: answered below as the name in its parent + // directory, which must lie under the root. + Err(OpenError::NotFound) => {} + Err(OpenError::ReparsePoint) => { + return Err(Denial::denied(format!( + "{path} contains a reparse point; fs refuses every reparse point" + ))); } + Err(_) => return Err(outside(p)), } } - let 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 { + if vr.is_directory { + // The directory root itself is not an entry a write can replace. let parent_str = parent.to_str().ok_or_else(|| outside(p))?; - if parent_str != vr.manifest && !parent_str.starts_with(&format!(r"{}\", vr.manifest)) { + if path == vr.manifest || !path_under(parent_str, &vr.manifest) { return Err(outside(p)); } + } else if path != vr.manifest { + return Err(outside(p)); } Ok(Target::Entry { @@ -938,6 +1066,25 @@ pub fn resolve(path: &str, roots: &[String], purpose: Purpose) -> Result Denial { + match e { + OpenError::ReparsePoint => Denial::denied(format!( + "{} became a symlink while it was being opened", + path.display() + )), + OpenError::NotFound => io_denial(path, &std::io::Error::from(std::io::ErrorKind::NotFound)), + OpenError::NotADirectory => { + Denial::new(codes::IO, format!("{} is not a directory", path.display())) + } + OpenError::AccessDenied => { + Denial::denied(format!("access to {} is denied", path.display())) + } + other => nt_io(path, "opening", other.status()), + } +} + #[cfg(windows)] pub fn open_checked( resolved: &Path, @@ -946,80 +1093,55 @@ pub fn open_checked( ) -> Result { let path_str = resolved.to_str().ok_or_else(|| outside(resolved))?; validate_raw_spelling(path_str)?; - let vr = select_root(path_str, roots)?; + let vr = select_root(path_str, roots, SHARE_ALL)?; + let leaf_open = if directory { + Open::existing(ffi::FILE_GENERIC_READ | ffi::FILE_TRAVERSE).directory() + } else { + Open::existing(ffi::FILE_GENERIC_READ) + }; - if path_str == vr.manifest { + let opened = 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) { + // The walk opened the root with attribute access only; reading or + // listing it needs a handle with the access for that. + nt_reopen(&vr.handle, leaf_open).map_err(|e| leaf_denial(e, resolved))? + } else { + let components = components_below(path_str, &vr.manifest); + let Some((last, middle)) = components.split_last() else { return Err(outside(resolved)); - } - return Ok(std::fs::File::from(vr.handle)); - } + }; + let dirs = walk_dirs(&vr.handle, middle, Open::existing(DIR_WALK)) + .map_err(|e| intermediate_denial(e, resolved))?; + let parent = dirs.last().unwrap_or(&vr.handle); + nt_open_relative(parent.raw(), last, leaf_open).map_err(|e| leaf_denial(e, resolved))? + }; - if !vr.is_directory { + let guid = get_volume_guid_path(opened.raw())?; + if !guid_path_inside_component_wise(&guid, &vr.guid_path, vr.is_directory) { return Err(outside(resolved)); } + Ok(opened.into_file()) +} - 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); +/// Test-only hooks. They are per thread, so a test that sets one affects +/// only the calls it makes itself, never a test running beside it. +#[cfg(all(test, windows))] +mod hooks { + use std::cell::{Cell, RefCell}; + + thread_local! { + /// Runs in `read` between resolving the path and opening it. + pub(super) static BEFORE_READ_OPEN: RefCell>> = + const { RefCell::new(None) }; + /// Makes `write_call` treat the rename as refused by the volume. + pub(super) static REFUSE_POSIX_RENAME: Cell = const { Cell::new(false) }; + /// Makes `write_call` treat flushing its temporary file as failed. + pub(super) static FAIL_TEMP_FLUSH: Cell = const { Cell::new(false) }; } - - 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); @@ -1035,9 +1157,11 @@ pub fn read(path: &str, roots: &[String], max_bytes: u64) -> Result Result max_bytes { return Err(too_large()); } @@ -1078,106 +1202,92 @@ pub fn read(path: &str, roots: &[String], max_bytes: u64) -> Result Result { + let basic = query_file_basic(handle.raw()).map_err(|s| nt_io(path, "stat", s))?; + let std_info = query_file_standard(handle.raw()).map_err(|s| nt_io(path, "stat", s))?; + Ok(json!({ + "exists": true, + "kind": if std_info.Directory != 0 { "dir" } else { "file" }, + "size": std_info.EndOfFile, + "mtime_ms": filetime_to_mtime_ms(basic.LastWriteTime), + })) +} + #[cfg(windows)] pub fn stat(path: &str, roots: &[String]) -> Result { validate_raw_spelling(path)?; - let vr = select_root(path, roots)?; + let vr = select_root(path, roots, SHARE_ALL)?; + let p = Path::new(path); 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(), - ); + return stat_value(&vr.handle, p); + } - 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}"))); + let components = components_below(path, &vr.manifest); + let Some((last, middle)) = components.split_last() else { + return Err(outside(p)); + }; + let dirs = walk_dirs(&vr.handle, middle, Open::existing(ffi::SYNCHRONIZE)) + .map_err(|e| intermediate_denial(e, p))?; + let parent = dirs.last().unwrap_or(&vr.handle); + match nt_open_relative(parent.raw(), last, Open::existing(ffi::SYNCHRONIZE)) { + Ok(leaf) => { + let guid = get_volume_guid_path(leaf.raw())?; + if !guid_path_inside_component_wise(&guid, &vr.guid_path, vr.is_directory) { + return Err(outside(p)); } + stat_value(&leaf, p) } + Err(OpenError::NotFound) => Ok(json!({ "exists": false })), + Err(OpenError::ReparsePoint) => Err(Denial::denied(format!( + "{path} is a reparse point; fs refuses every reparse point" + ))), + Err(OpenError::AccessDenied) => Err(Denial::denied(format!("access to {path} is denied"))), + Err(e) => Err(nt_io(p, "opening", e.status())), } - - Err(outside(Path::new(path))) } +/// Byte offsets of the `FILE_DIRECTORY_INFORMATION` fields a scan reads. #[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; +const DIR_NEXT_OFFSET: usize = + std::mem::offset_of!(ffi::FILE_DIRECTORY_INFORMATION, NextEntryOffset); +#[cfg(windows)] +const DIR_ATTRIBUTES: usize = std::mem::offset_of!(ffi::FILE_DIRECTORY_INFORMATION, FileAttributes); +#[cfg(windows)] +const DIR_NAME_LENGTH: usize = + std::mem::offset_of!(ffi::FILE_DIRECTORY_INFORMATION, FileNameLength); +#[cfg(windows)] +const DIR_NAME: usize = std::mem::offset_of!(ffi::FILE_DIRECTORY_INFORMATION, FileName); - 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; +#[cfg(windows)] +fn u32_at(bytes: &[u8], at: usize) -> u32 { + u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]) +} +/// Hands each entry of the directory behind `handle` (not `.` or `..`) to +/// `visit` as its UTF-16 name and attributes, until `visit` answers false. +/// The handle needs `FILE_LIST_DIRECTORY`. Any failed query is an error, +/// the first one included: an unreadable directory is never reported as +/// an empty one. The reply is parsed as bytes and every entry is checked +/// against the length the kernel reported, so a malformed reply is an +/// error rather than a read past it. +#[cfg(windows)] +fn scan_directory( + handle: ffi::HANDLE, + mut visit: impl FnMut(&[u16], u32) -> bool, +) -> Result<(), ffi::NTSTATUS> { + // u64 storage: the kernel requires an 8-byte-aligned buffer. + let mut buffer = vec![0u64; 8 * 1024]; + let buffer_bytes = buffer.len() * std::mem::size_of::(); + let mut restart = 1u8; loop { + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + // SAFETY: the buffer is `buffer_bytes` long and outlives the call. let status = unsafe { ffi::NtQueryDirectoryFile( handle, @@ -1185,8 +1295,8 @@ pub fn list(path: &str, roots: &[String]) -> Result { 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, + buffer.as_mut_ptr().cast(), + buffer_bytes as u32, ffi::FileDirectoryInformation, 0, std::ptr::null_mut(), @@ -1194,56 +1304,108 @@ pub fn list(path: &str, roots: &[String]) -> Result { ) }; restart = 0; - - if status == ffi::STATUS_NO_MORE_FILES || io_status.Information == 0 { - break; + if status == ffi::STATUS_NO_MORE_FILES { + return Ok(()); } if status < 0 { - return Err(Denial::new( - codes::IO, - format!("directory scan failed: 0x{status:08x}"), - )); + return Err(status); } - - let mut offset = 0; + let filled = io_status.Information; + if filled == 0 { + return Ok(()); + } + if filled > buffer_bytes { + return Err(ffi::STATUS_FILE_CORRUPT_ERROR); + } + // SAFETY: the first `filled` bytes of the buffer are initialised + // (it was zeroed) and lie within it. + let bytes = unsafe { std::slice::from_raw_parts(buffer.as_ptr().cast::(), filled) }; + let mut offset = 0usize; loop { - let entry_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"), - )); - } + let entry = &bytes[offset..]; + if entry.len() < DIR_NAME { + return Err(ffi::STATUS_FILE_CORRUPT_ERROR); } - - if entry.NextEntryOffset == 0 { - break; + let next = u32_at(entry, DIR_NEXT_OFFSET) as usize; + let attributes = u32_at(entry, DIR_ATTRIBUTES); + let name_bytes = u32_at(entry, DIR_NAME_LENGTH) as usize; + let Some(raw_name) = entry.get(DIR_NAME..DIR_NAME + name_bytes) else { + return Err(ffi::STATUS_FILE_CORRUPT_ERROR); + }; + if !name_bytes.is_multiple_of(2) { + return Err(ffi::STATUS_FILE_CORRUPT_ERROR); + } + let name: Vec = raw_name + .as_chunks::<2>() + .0 + .iter() + .map(|unit| u16::from_le_bytes(*unit)) + .collect(); + let dot = u16::from(b'.'); + let is_dot = name == [dot] || name == [dot, dot]; + if !is_dot && !visit(&name, attributes) { + return Ok(()); } - offset += entry.NextEntryOffset as usize; - if offset >= buffer.len() { + if next == 0 { break; } + offset = match offset.checked_add(next) { + Some(o) if o < filled => o, + _ => return Err(ffi::STATUS_FILE_CORRUPT_ERROR), + }; } } +} - entries.sort(); - Ok(Value::Array( +#[cfg(windows)] +pub fn list(path: &str, roots: &[String]) -> Result { + use std::os::windows::io::AsRawHandle; + let real = match resolve(path, roots, Purpose::Read)? { + Target::Existing(real) => real, + Target::Entry { parent, name } => { + return Err(Denial::new( + codes::NOT_FOUND, + format!("{} does not exist", parent.join(name).display()), + )); + } + }; + let dir = open_checked(&real, roots, true)?; + + let mut names: Vec<(Vec, u32)> = Vec::new(); + scan_directory(dir.as_raw_handle(), |name, attributes| { + names.push((name.to_vec(), attributes)); + names.len() <= MAX_LIST_ENTRIES + }) + .map_err(|s| nt_io(&real, "listing", s))?; + if names.len() > MAX_LIST_ENTRIES { + return Err(Denial::new( + codes::TOO_LARGE, + format!( + "{} has more than {MAX_LIST_ENTRIES} entries", + real.display() + ), + )); + } + + let mut entries = Vec::with_capacity(names.len()); + for (name, attributes) in names { + let kind = if attributes & ffi::FILE_ATTRIBUTE_REPARSE_POINT != 0 { + "symlink" + } else if attributes & ffi::FILE_ATTRIBUTE_DIRECTORY != 0 { + "dir" + } else { + "file" + }; + let Ok(text) = String::from_utf16(&name) else { + return Err(Denial::new( + codes::NOT_UTF8, + format!("{} holds a name that is not UTF-8", real.display()), + )); + }; + entries.push((text, kind)); + } + entries.sort(); + Ok(Value::Array( entries .into_iter() .map(|(name, kind)| json!({ "name": name, "kind": kind })) @@ -1251,153 +1413,233 @@ pub fn list(path: &str, roots: &[String]) -> Result { )) } +/// The DACL of the file a write is about to replace, or `None` when there +/// is no such file and the new one takes what NTFS inherits for it. +/// Refuses a reparse point and anything that is not a regular file, as the +/// Unix write refuses a symlink and a non-regular file. #[cfg(windows)] -fn 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(), +fn existing_target_dacl( + parent: &OwnedHandle, + leaf: &str, + target: &Path, +) -> Result, Denial> { + let file = match nt_open_relative( + parent.raw(), + leaf, + Open::existing(ffi::READ_CONTROL | ffi::SYNCHRONIZE).non_directory(), ) { - Ok(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, - }) + Ok(file) => file, + Err(OpenError::NotFound) => return Ok(None), + Err(OpenError::ReparsePoint) => { + return Err(Denial::denied(format!( + "{} is a symlink; fs.write does not replace symlinks", + target.display() + ))); } - Err(OpenError::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" - ))), + Err(OpenError::IsADirectory) => { + return Err(Denial::invalid(format!( + "{} is not a regular file", + target.display() + ))); + } + Err(OpenError::AccessDenied) => { + return Err(Denial::denied(format!( + "the permissions of {} cannot be read", + target.display() + ))); + } + Err(e) => return Err(nt_io(target, "opening", e.status())), + }; + let mut sd = std::ptr::null_mut(); + // SAFETY: `file` is open with READ_CONTROL; on success `sd` receives a + // LocalAlloc'd descriptor that FileDacl frees. + let err = unsafe { + ffi::GetSecurityInfo( + file.raw(), + ffi::SE_FILE_OBJECT, + ffi::DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut sd, + ) + }; + if err != 0 { + return Err(Denial::new( + codes::IO, + format!( + "{}: reading its permissions failed (error {err})", + target.display() + ), + )); } + Ok(Some(FileDacl { sd })) } +/// Marks the open file behind `handle` for deletion; it goes when the last +/// handle to it closes. #[cfg(windows)] -fn 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, - })?; - +fn delete_by_handle(handle: &OwnedHandle) -> Result<(), ffi::NTSTATUS> { let mut disp = ffi::FILE_DISPOSITION_INFORMATION { DeleteFile: 1 }; let mut io_status = ffi::IO_STATUS_BLOCK { Status: 0, Information: 0, }; + // SAFETY: the buffer passed is `disp`, and the length passed is its + // size. let status = unsafe { ffi::NtSetInformationFile( handle.raw(), &mut io_status, - &mut disp as *mut _ as *mut std::ffi::c_void, + (&mut disp as *mut ffi::FILE_DISPOSITION_INFORMATION).cast(), std::mem::size_of::() as u32, ffi::FileDispositionInformation, ) }; - if status < 0 { - return Err(status); - } - Ok(()) + if status < 0 { Err(status) } else { Ok(()) } +} + +/// What [`unlink_file`] found under a name. +#[cfg(windows)] +enum Unlinked { + Removed, + Absent, + /// Something other than a regular file, left in place: what it is. + NotRegular(&'static str), +} + +/// Removes `name` from the directory behind `parent` only if it is a +/// regular file, as the Unix `unlink_regular` does. A directory or a +/// reparse point under the name is left alone, and a link is never +/// followed, so nothing but that one entry can be removed. +#[cfg(windows)] +fn unlink_file(parent: ffi::HANDLE, name: &str) -> Result { + let file = match nt_open_relative( + parent, + name, + Open::existing(ffi::DELETE | ffi::SYNCHRONIZE).non_directory(), + ) { + Ok(file) => file, + Err(OpenError::NotFound) => return Ok(Unlinked::Absent), + Err(OpenError::ReparsePoint) => return Ok(Unlinked::NotRegular("reparse point")), + Err(OpenError::IsADirectory) => return Ok(Unlinked::NotRegular("directory")), + Err(e) => return Err(e.status()), + }; + delete_by_handle(&file)?; + Ok(Unlinked::Removed) +} + +/// Flushes the file or directory behind `handle` to disk. +#[cfg(windows)] +fn flush(handle: &OwnedHandle) -> ffi::NTSTATUS { + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + // SAFETY: `handle` is open; the status block is a live local. + unsafe { ffi::NtFlushBuffersFile(handle.raw(), &mut io_status) } } #[cfg(windows)] pub fn write(path: &str, roots: &[String], text: &str) -> Result { - 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) + TEMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) ); write_call(path, roots, text, &key, None) } +/// The directory a write to a path acts in, held open from the volume root +/// down, and the name the write replaces in it. +#[cfg(windows)] +struct WriteParent { + root: VerifiedRoot, + /// The directories between a directory root and the parent, the parent + /// last. Empty when the parent is the root, or for a file root. + below: Vec, + /// The parent directory, as spelled in the path. + dir: PathBuf, + /// The last component of the path. + leaf: String, +} + +#[cfg(windows)] +impl WriteParent { + /// The handle on the directory the write acts in. + fn parent(&self) -> &OwnedHandle { + if let Some(dir) = self.below.last() { + dir + } else if self.root.is_directory { + &self.root.handle + } else { + self.root + .ancestors + .last() + .expect("a file root has a parent directory") + } + } + + /// Checks where the parent directory really is, against the root. + fn check_inside(&self) -> Result<(), Denial> { + let guid = get_volume_guid_path(self.parent().raw())?; + let inside = if self.root.is_directory { + guid_path_inside_component_wise(&guid, &self.root.guid_path, true) + } else { + guid_is_parent_of(&guid, &self.root.guid_path) + }; + if inside { + Ok(()) + } else { + Err(outside(&self.dir.join(&self.leaf))) + } + } +} + +/// Selects the root for `path` by the whole path (so a file root grants a +/// write to itself), walks to the directory that holds it and checks that +/// directory. Every directory from the volume root down to that one is +/// opened with the sharing mode `share` and stays open as long as the +/// returned value lives, so a caller passing [`SHARE_NO_DELETE`] keeps them +/// all from being moved until it drops the value. +#[cfg(windows)] +fn open_write_parent(path: &str, roots: &[String], share: u32) -> Result { + validate_raw_spelling(path)?; + let p = Path::new(path); + let (Some(parent), Some(leaf)) = (p.parent(), p.file_name().and_then(OsStr::to_str)) else { + return Err(outside(p)); + }; + let parent_str = parent.to_str().ok_or_else(|| outside(p))?; + let root = select_root(path, roots, share)?; + let below = if root.is_directory { + // The directory root itself is not an entry a write can replace. + if path == root.manifest || !path_under(parent_str, &root.manifest) { + return Err(outside(p)); + } + let components = components_below(parent_str, &root.manifest); + walk_dirs( + &root.handle, + &components, + Open::existing(DIR_WALK).share(share), + ) + .map_err(|e| intermediate_denial(e, p))? + } else { + // select_root grants a file root only to its own path. Its parent + // is the last of the root's ancestors, which walk_from_volume_root + // opened and keeps in `root.ancestors`; WriteParent::parent uses it. + Vec::new() + }; + let wp = WriteParent { + root, + below, + dir: parent.to_path_buf(), + leaf: leaf.to_owned(), + }; + wp.check_inside()?; + Ok(wp) +} + #[cfg(windows)] pub fn write_call( path: &str, @@ -1409,211 +1651,237 @@ pub fn write_call( check_write_size(text.len())?; validate_raw_spelling(path)?; let temp_name_os = temp_name(call_key)?; - let temp_name_str = temp_name_os + let temp_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)); - } + // `wp` holds every directory from the volume root down to the parent, + // opened without FILE_SHARE_DELETE, until write_call returns. Renaming + // or deleting a directory needs it opened for DELETE, which those + // handles refuse, so none can be moved out of the root before the + // rename below lands where the check said it would. + let wp = open_write_parent(path, roots, SHARE_NO_DELETE)?; + let target_path = wp.dir.join(&wp.leaf); + let temp_path = wp.dir.join(temp_str); - // 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)?; + // A replaced file keeps its DACL. A new file gets no explicit + // descriptor, so NTFS gives it what the folder's inheritable entries + // grant, marked inherited, and later changes to the folder reach it. + let target_dacl = existing_target_dacl(wp.parent(), &wp.leaf, &target_path)?; let lease = TempLease { call_key: call_key.to_owned(), - dir: parent_path_buf.clone(), - target: OsString::from(leaf_name), + dir: wp.dir.clone(), + target: OsString::from(&wp.leaf), temp: temp_name_os.clone(), roots: roots.to_vec(), }; - + // With a ledger, the record is durable before the file exists, so no + // crash can leave a temporary file that nothing has recorded. let hold = match ledger { Some(l) => Some(l.record(&lease).map_err(|e| { Denial::new( codes::IO, format!( "the temporary file for {} could not be recorded: {e}", - parent_path_buf.join(leaf_name).display() + target_path.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:?}"), - )); + let create = Open { + access: ffi::FILE_GENERIC_WRITE | ffi::DELETE | ffi::SYNCHRONIZE, + share: SHARE_ALL, + disposition: ffi::FILE_CREATE, + options: ffi::FILE_NON_DIRECTORY_FILE, + security_descriptor: target_dacl.as_ref().map_or(std::ptr::null_mut(), |d| d.sd), + }; + // On a failed create the record stays: a file an earlier send left + // under this name may still be there, and cleanup checks for it. + let mut replaced = false; + let temp = loop { + match nt_open_relative(wp.parent().raw(), temp_str, create) { + Ok(file) => break file, + // The name belongs to this call alone, so a regular file already + // under it is what an earlier send of the same call left. It is + // replaced once; a directory or link there is an error. + Err(OpenError::Collision) + if !replaced + && matches!( + unlink_file(wp.parent().raw(), temp_str), + Ok(Unlinked::Removed) + ) => + { + replaced = true; } + Err(e) => return Err(nt_io(&temp_path, "creating", e.status())), } }; - if let Some(l) = ledger { l.created(&lease); } - // Write contents to temp file - let bytes = text.as_bytes(); - let mut io_status = ffi::IO_STATUS_BLOCK { - Status: 0, - Information: 0, + // On a failure from here on, the temporary file is removed; the record + // is cleared only when that worked, otherwise cleanup tries again. + let abandon = |temp: OwnedHandle, hold: Option>| { + if delete_by_handle(&temp).is_ok() { + drop(temp); + clear(hold); + } }; - let mut offset = 0i64; - while (offset as usize) < bytes.len() { - let chunk = &bytes[offset as usize..]; + + let bytes = text.as_bytes(); + let mut written = 0usize; + while written < bytes.len() { + let chunk = &bytes[written..]; + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let mut offset = written as i64; + // SAFETY: `chunk` is live for the call; its length is at most + // MAX_WRITE_BYTES, so it fits in a u32. let status = unsafe { ffi::NtWriteFile( - temp_handle.raw(), + temp.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.as_ptr().cast(), 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}"), - )); + if status < 0 || io_status.Information == 0 { + abandon(temp, hold); + return Err(nt_io(&temp_path, "writing", status)); } - offset += io_status.Information as i64; + written += io_status.Information; } - // Flush temp file - unsafe { ffi::NtFlushBuffersFile(temp_handle.raw(), &mut io_status) }; + // The data must be on disk before the rename makes it the target: the + // rename is journaled, the data is not, so renaming unflushed data can + // leave an empty or partial file after a crash. A failed flush stops + // the write, as a failed sync does on Unix. + #[cfg(test)] + let flush_status = if hooks::FAIL_TEMP_FLUSH.with(std::cell::Cell::get) { + ffi::STATUS_IO_DEVICE_ERROR + } else { + flush(&temp) + }; + #[cfg(not(test))] + let flush_status = flush(&temp); + if flush_status < 0 { + abandon(temp, hold); + return Err(nt_io( + &target_path, + "flushing the new contents", + flush_status, + )); + } - // Rename using FileRenameInformationEx POSIX semantics - let wide_target: Vec = leaf_name.encode_utf16().collect(); + // The parent is held, but where it is gets checked again before the + // rename so the write does not rely on the hold alone. + if let Err(d) = wp.check_inside() { + abandon(temp, hold); + return Err(d); + } + + // FileRenameInformationEx with POSIX semantics replaces the target even + // while it is open. If the volume refuses it, the write fails: there is + // no fallback to a non-POSIX rename or a copy. + // A read-only target is replaced, as renameat ignores a target's mode. + let wide_target: Vec = wp.leaf.encode_utf16().collect(); let name_bytes = wide_target.len() * std::mem::size_of::(); - let 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; + let name_offset = std::mem::offset_of!(ffi::FILE_RENAME_INFORMATION_EX, FileName); + let struct_size = + (name_offset + name_bytes).max(std::mem::size_of::()); + // u64 storage gives the 8-byte alignment the structure needs. + let mut rename_buf = vec![0u64; struct_size.div_ceil(std::mem::size_of::())]; + let rename_info = rename_buf + .as_mut_ptr() + .cast::(); + // SAFETY: the buffer is aligned for the structure and holds its header + // plus `name_bytes` of name; fields are written through the raw pointer + // (no reference is made), and the name is copied through a pointer + // derived from it, so it may run past the declared one-element array. unsafe { - (*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); + std::ptr::addr_of_mut!((*rename_info).Flags).write( + ffi::FILE_RENAME_REPLACE_IF_EXISTS + | ffi::FILE_RENAME_POSIX_SEMANTICS + | ffi::FILE_RENAME_IGNORE_READONLY_ATTRIBUTE, + ); + std::ptr::addr_of_mut!((*rename_info).RootDirectory).write(wp.parent().raw()); + std::ptr::addr_of_mut!((*rename_info).FileNameLength).write(name_bytes as u32); + std::ptr::copy_nonoverlapping( + wide_target.as_ptr(), + std::ptr::addr_of_mut!((*rename_info).FileName).cast::(), + wide_target.len(), + ); } - #[cfg(test)] - let simulate_refusal = SIMULATE_POSIX_RENAME_REFUSAL.load(std::sync::atomic::Ordering::Relaxed); - #[cfg(not(test))] - let simulate_refusal = false; - - let rename_status = if simulate_refusal { - ffi::STATUS_NOT_SUPPORTED - } else { + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let rename = |io_status: &mut ffi::IO_STATUS_BLOCK| { + // SAFETY: `rename_info` points into `rename_buf`, which is + // `struct_size` bytes or more and outlives the call. unsafe { ffi::NtSetInformationFile( - temp_handle.raw(), - &mut io_status, - rename_info as *mut std::ffi::c_void, + temp.raw(), + io_status, + rename_info.cast(), struct_size as u32, ffi::FileRenameInformationEx, ) } }; + #[cfg(test)] + let rename_status = if hooks::REFUSE_POSIX_RENAME.with(std::cell::Cell::get) { + ffi::STATUS_NOT_SUPPORTED + } else { + rename(&mut io_status) + }; + #[cfg(not(test))] + let rename_status = rename(&mut io_status); if rename_status < 0 { - let _ = unlink_file(parent_handle.raw(), temp_name_str); + abandon(temp, hold); if rename_status == ffi::STATUS_NOT_SUPPORTED || rename_status == ffi::STATUS_INVALID_PARAMETER { return Err(Denial::denied(format!( - "volume does not support POSIX replace rename (status 0x{rename_status:08x})" + "volume does not support POSIX replace rename (status 0x{:08x})", + rename_status as u32 ))); } - return Err(Denial::new( - codes::IO, - format!("rename to {leaf_name} failed: 0x{rename_status:08x}"), + return Err(nt_io( + &target_path, + "renaming the new contents into place", + rename_status, )); } + drop(temp); - drop(temp_handle); - - // Flush parent directory - unsafe { ffi::NtFlushBuffersFile(parent_handle.raw(), &mut io_status) }; + // Make the rename itself durable. Best effort, as on Unix: a failure + // here leaves the new file in place. Flushing a directory needs write + // access, which the held handle does not carry. + if let Ok(dir) = nt_reopen( + wp.parent(), + Open::existing(ffi::FILE_WRITE_DATA | ffi::SYNCHRONIZE).directory(), + ) { + let _ = flush(&dir); + } clear(hold); Ok(json!({ "bytes": text.len() })) @@ -1626,186 +1894,79 @@ pub fn remove_temp(lease: &TempLease) -> Result { "the record does not name a temporary file", ))); } - + let Some(temp_str) = lease.temp.to_str() else { + return Ok(TempRemoval::Refused(Denial::invalid( + "the recorded temporary file name is not UTF-8", + ))); + }; let joined = lease.dir.join(&lease.target); - let Some(path_str) = joined.to_str() else { + let Some(path) = 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, + // The lease's directory is reached exactly as write_call reached it: + // the root chosen by the whole path, then a walk down to `lease.dir`. + let wp = match open_write_parent(path, &lease.roots, SHARE_ALL) { + Ok(wp) => wp, Err(d) if d.code == codes::NOT_FOUND => return Ok(TempRemoval::Absent), Err(d) if d.code == codes::IO => return Err(d), Err(d) => return Ok(TempRemoval::Refused(d)), }; - - if parent != lease.dir || leaf_name != lease.target { + // The walk follows the spelling of `lease.dir` and refuses every reparse + // point, so it cannot end anywhere that spelling does not name. What + // this check catches is a `lease.target` that is not one plain name: one + // holding a separator makes the parent of `dir\target` a deeper + // directory than `lease.dir`. + if wp.dir != lease.dir || OsStr::new(&wp.leaf) != lease.target { return Ok(TempRemoval::Refused(Denial::denied(format!( "{} now resolves to {}", joined.display(), - 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() + wp.dir.join(&wp.leaf).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) + let temp_path = wp.dir.join(temp_str); + Ok(match unlink_file(wp.parent().raw(), temp_str) { + Ok(Unlinked::Removed) => TempRemoval::Removed, + Ok(Unlinked::Absent) => TempRemoval::Absent, + Ok(Unlinked::NotRegular(kind)) => TempRemoval::Refused(Denial::denied(format!( + "{} is a {kind}, not a temporary file", + temp_path.display() + ))), + Err(status) => return Err(nt_io(&temp_path, "removing", status)), + }) } #[cfg(windows)] pub fn remove_legacy_temps(path: &str, roots: &[String]) -> Result { - 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 wp = open_write_parent(path, roots, SHARE_ALL)?; + let lister = nt_reopen( + wp.parent(), + Open::existing(ffi::FILE_LIST_DIRECTORY | ffi::SYNCHRONIZE).directory(), + ) + .map_err(|e| nt_io(&wp.dir, "opening the directory to list", e.status()))?; + let mut names = Vec::new(); + scan_directory(lister.raw(), |name, _| { + if let Ok(name) = String::from_utf16(name) + && is_legacy_temp_name(OsStr::new(&name)) + { + names.push(name); } - } - + true + }) + .map_err(|s| nt_io(&wp.dir, "listing", s))?; let mut removed = 0; - for name in legacy_names { - if unlink_file(handle, &name).is_ok() { - removed += 1; + for name in names { + // Each name is examined again as it is removed: only a regular + // file goes, whatever the listing said it was. + match unlink_file(wp.parent().raw(), &name) { + Ok(Unlinked::Removed) => removed += 1, + Ok(_) => {} + Err(status) => return Err(nt_io(&wp.dir.join(&name), "removing", status)), } } - Ok(removed) } @@ -1932,50 +2093,299 @@ mod tests { )); } - #[cfg(windows)] - mod win_tests { - use super::*; + #[test] + fn path_under_matches_by_component_and_volume_roots_cover_their_drive() { + assert!(path_under(r"C:\root", r"C:\root")); + assert!(path_under(r"C:\root\a\b.txt", r"C:\root")); + assert!(!path_under(r"C:\rootx\a.txt", r"C:\root")); + assert!(!path_under(r"C:\other", r"C:\root")); + assert!(path_under(r"C:\", r"C:\")); + assert!(path_under(r"C:\x", r"C:\")); + assert!(path_under(r"C:\x\y.txt", r"C:\")); + assert!(!path_under(r"D:\x", r"C:\")); + } + + #[test] + fn components_below_handles_directory_and_volume_roots() { + assert!(components_below(r"C:\root", r"C:\root").is_empty()); + assert_eq!(components_below(r"C:\root\a\b", r"C:\root"), ["a", "b"]); + assert!(components_below(r"C:\", r"C:\").is_empty()); + assert_eq!(components_below(r"C:\x\y", r"C:\"), ["x", "y"]); + } + + #[cfg(windows)] + mod win_tests { + use super::*; + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use std::sync::Mutex; + + /// Win32 calls only the tests make. + #[allow(non_snake_case)] + mod test_ffi { + use std::ffi::c_void; + + pub const SDDL_REVISION_1: u32 = 1; + pub const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; + pub const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + pub const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + pub const ERROR_SHARING_VIOLATION: i32 = 32; + + #[link(name = "advapi32")] + unsafe extern "system" { + pub fn ConvertStringSecurityDescriptorToSecurityDescriptorW( + StringSecurityDescriptor: *const u16, + StringSDRevision: u32, + SecurityDescriptor: *mut *mut c_void, + SecurityDescriptorSize: *mut u32, + ) -> i32; + + pub fn ConvertSecurityDescriptorToStringSecurityDescriptorW( + SecurityDescriptor: *mut c_void, + RequestedStringSDRevision: u32, + SecurityInformation: u32, + StringSecurityDescriptor: *mut *mut u16, + StringSecurityDescriptorLen: *mut u32, + ) -> i32; + + pub fn SetFileSecurityW( + lpFileName: *const u16, + SecurityInformation: u32, + pSecurityDescriptor: *mut c_void, + ) -> i32; + } + + #[link(name = "kernel32")] + unsafe extern "system" { + pub fn GetVolumeNameForVolumeMountPointW( + lpszVolumeMountPoint: *const u16, + lpszVolumeName: *mut u16, + cchBufferLength: u32, + ) -> i32; + + pub fn SetVolumeMountPointW( + lpszVolumeMountPoint: *const u16, + lpszVolumeName: *const u16, + ) -> i32; + + pub fn DeleteVolumeMountPointW(lpszVolumeMountPoint: *const u16) -> i32; + } + } + + struct WinTree { + base: PathBuf, + root: PathBuf, + outside: PathBuf, + } + + impl WinTree { + fn new(tag: &str) -> Self { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let base = std::env::temp_dir().join(format!( + "basal-win-tree-{tag}-{}-{}", + std::process::id(), + SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let root = base.join("root"); + let outside = base.join("outside"); + std::fs::create_dir_all(root.join("sub")).expect("create root"); + std::fs::create_dir_all(&outside).expect("create outside"); + std::fs::write(root.join("a.txt"), "inside").expect("write a.txt"); + std::fs::write(root.join("sub\\b.txt"), "nested").expect("write b.txt"); + std::fs::write(outside.join("secret.txt"), "secret").expect("write secret"); + Self { + base, + root, + outside, + } + } + + fn roots(&self) -> Vec { + vec![self.root.display().to_string()] + } + + fn p(&self, rel: &str) -> String { + self.root.join(rel).display().to_string() + } + + /// The names in the root directory that are temporary files. + fn temps_in(&self, rel: &str) -> Vec { + std::fs::read_dir(self.root.join(rel)) + .expect("read dir") + .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with(".basal-")) + .collect() + } + } + + impl Drop for WinTree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.base); + } + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(Some(0)).collect() + } + + /// Fails the test unless `path` is a symlink, junction or mount point. + fn assert_link(path: &Path) { + let meta = std::fs::symlink_metadata(path) + .unwrap_or_else(|e| panic!("{} was not created: {e}", path.display())); + assert!( + meta.file_type().is_symlink(), + "{} is not a link", + path.display() + ); + } + + /// Creates a file symlink. Needs SeCreateSymbolicLinkPrivilege, which + /// an elevated administrator (as on GitHub's Windows runners) holds. + fn file_symlink(link: &Path, target: &Path) { + std::os::windows::fs::symlink_file(target, link).unwrap_or_else(|e| { + panic!( + "creating symlink {} failed (needs SeCreateSymbolicLinkPrivilege): {e}", + link.display() + ) + }); + assert_link(link); + } + + /// Creates a directory junction; needs no privilege. + fn junction(link: &Path, target: &Path) { + let status = std::process::Command::new("cmd") + .args([ + "/c", + "mklink", + "/J", + link.to_str().unwrap(), + target.to_str().unwrap(), + ]) + .status() + .expect("run mklink /J"); + assert!(status.success(), "mklink /J {} failed", link.display()); + assert_link(link); + } + + /// Sets the DACL of `path` from an SDDL string, including its + /// protection flag. + fn set_dacl(path: &Path, sddl: &str) { + let wide_sddl = wide(sddl); + let mut sd = std::ptr::null_mut(); + let ok = unsafe { + test_ffi::ConvertStringSecurityDescriptorToSecurityDescriptorW( + wide_sddl.as_ptr(), + test_ffi::SDDL_REVISION_1, + &mut sd, + std::ptr::null_mut(), + ) + }; + assert_ne!(ok, 0, "convert {sddl}: {}", std::io::Error::last_os_error()); + let mut info = ffi::DACL_SECURITY_INFORMATION; + if sddl.starts_with("D:P") { + info |= test_ffi::PROTECTED_DACL_SECURITY_INFORMATION; + } + let wide_path = wide(path.to_str().unwrap()); + let ok = unsafe { test_ffi::SetFileSecurityW(wide_path.as_ptr(), info, sd) }; + let err = std::io::Error::last_os_error(); + unsafe { ffi::LocalFree(sd) }; + assert_ne!(ok, 0, "set the DACL of {}: {err}", path.display()); + } + + /// The DACL of `path` as SDDL, read without following a link. + fn dacl_of(path: &Path) -> Result { + let file = std::fs::OpenOptions::new() + .access_mode(ffi::READ_CONTROL) + .custom_flags( + test_ffi::FILE_FLAG_BACKUP_SEMANTICS | test_ffi::FILE_FLAG_OPEN_REPARSE_POINT, + ) + .open(path) + .map_err(|e| format!("open {}: {e}", path.display()))?; + let mut sd = std::ptr::null_mut(); + let err = unsafe { + ffi::GetSecurityInfo( + file.as_raw_handle(), + ffi::SE_FILE_OBJECT, + ffi::DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut sd, + ) + }; + if err != 0 { + return Err(format!("GetSecurityInfo {}: {err}", path.display())); + } + let mut text = std::ptr::null_mut(); + let ok = unsafe { + test_ffi::ConvertSecurityDescriptorToStringSecurityDescriptorW( + sd, + test_ffi::SDDL_REVISION_1, + ffi::DACL_SECURITY_INFORMATION, + &mut text, + std::ptr::null_mut(), + ) + }; + let result = if ok == 0 || text.is_null() { + Err(format!("convert the DACL of {} to SDDL", path.display())) + } else { + let mut len = 0; + while unsafe { *text.add(len) } != 0 { + len += 1; + } + let units = unsafe { std::slice::from_raw_parts(text, len) }; + let sddl = String::from_utf16_lossy(units); + unsafe { ffi::LocalFree(text.cast()) }; + Ok(sddl) + }; + unsafe { ffi::LocalFree(sd) }; + result + } + + /// Splits `D:()()...` into its flags and ACEs. + fn dacl_parts(sddl: &str) -> (String, Vec) { + let rest = sddl + .strip_prefix("D:") + .unwrap_or_else(|| panic!("not a DACL: {sddl}")); + let (flags, aces) = rest.split_at(rest.find('(').unwrap_or(rest.len())); + let aces = aces + .split(')') + .filter(|a| !a.is_empty()) + .map(|a| a.trim_start_matches('(').to_owned()) + .collect(); + (flags.to_owned(), aces) + } - struct WinTree { - base: PathBuf, - root: PathBuf, - outside: PathBuf, + /// A ledger that records the temporary file's DACL once it exists. + #[derive(Default)] + struct DaclObserver { + temp_dacl: Mutex>>, } - 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, - } - } + struct NoHold; + impl TempHold for NoHold { + fn clear(self: Box) {} + } - fn roots(&self) -> Vec { - vec![self.root.display().to_string()] + impl TempLedger for DaclObserver { + fn record(&self, _lease: &TempLease) -> Result, String> { + Ok(Box::new(NoHold)) } - fn p(&self, rel: &str) -> String { - self.root.join(rel).display().to_string() + fn created(&self, lease: &TempLease) { + *self.temp_dacl.lock().unwrap() = Some(dacl_of(&lease.dir.join(&lease.temp))); } } - impl Drop for WinTree { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.base); + impl DaclObserver { + fn temp_dacl(&self) -> String { + self.temp_dacl + .lock() + .unwrap() + .clone() + .expect("the ledger saw the temporary file") + .expect("the temporary file's DACL was read") } } @@ -1996,6 +2406,9 @@ mod tests { let st_missing = stat(&t.p("missing.txt"), &roots).expect("stat missing"); assert_eq!(st_missing["exists"], false); + let st_root = stat(&t.root.display().to_string(), &roots).expect("stat root"); + assert_eq!(st_root["kind"], "dir"); + let outside_path = t.outside.join("secret.txt").display().to_string(); let denial = stat(&outside_path, &roots).expect_err("outside"); assert_eq!(denial.code, codes::DENIED); @@ -2024,38 +2437,31 @@ mod tests { let t = WinTree::new("symlink-refused"); let roots = t.roots(); - // Attempt symlink creation (requires developer mode or admin privilege). - // If privilege is not held, Command fails and we verify junction fallback. let out_link = t.root.join("symlink_out.txt"); let in_link = t.root.join("symlink_in.txt"); - let _ = std::process::Command::new("cmd") - .args([ - "/c", - "mklink", - out_link.to_str().unwrap(), - t.outside.join("secret.txt").to_str().unwrap(), - ]) - .status(); - let _ = std::process::Command::new("cmd") - .args([ - "/c", - "mklink", - in_link.to_str().unwrap(), - t.root.join("a.txt").to_str().unwrap(), - ]) - .status(); + file_symlink(&out_link, &t.outside.join("secret.txt")); + file_symlink(&in_link, &t.root.join("a.txt")); + + let err_out = read(&out_link.display().to_string(), &roots, 1024) + .expect_err("symlink out denied"); + assert_eq!(err_out.code, codes::DENIED); + // Every reparse point is refused, in-root ones included. + let err_in = read(&in_link.display().to_string(), &roots, 1024) + .expect_err("in-root symlink denied"); + assert_eq!(err_in.code, codes::DENIED); + assert!( + err_in.message.contains("reparse point"), + "{}", + err_in.message + ); - if out_link.exists() || out_link.is_symlink() { - let err_out = read(&out_link.display().to_string(), &roots, 1024) - .expect_err("symlink out denied"); - assert_eq!(err_out.code, codes::DENIED); - } - if in_link.exists() || in_link.is_symlink() { - // On Windows, every reparse point is refused in this campaign, including in-root ones. - let err_in = read(&in_link.display().to_string(), &roots, 1024) - .expect_err("in-root symlink denied"); - assert_eq!(err_in.code, codes::DENIED); - } + let err_write = + write(&out_link.display().to_string(), &roots, "x").expect_err("write link"); + assert_eq!(err_write.code, codes::DENIED); + assert_eq!( + std::fs::read_to_string(t.outside.join("secret.txt")).unwrap(), + "secret" + ); } #[test] @@ -2063,59 +2469,93 @@ mod tests { let t = WinTree::new("reparse-points"); let roots = t.roots(); - // 1. Leaf reparse point + // A junction as the last component. let leaf_junc = t.root.join("leaf_junc"); - let _ = std::process::Command::new("cmd") - .args([ - "/c", - "mklink", - "/J", - leaf_junc.to_str().unwrap(), - t.outside.to_str().unwrap(), - ]) - .status(); - if leaf_junc.exists() { - let st = stat(&leaf_junc.display().to_string(), &roots) - .expect_err("leaf reparse stat denial"); - assert_eq!(st.code, codes::DENIED); - } + junction(&leaf_junc, &t.outside); + let st = stat(&leaf_junc.display().to_string(), &roots) + .expect_err("leaf reparse stat denial"); + assert_eq!(st.code, codes::DENIED); + assert!(st.message.contains("reparse point"), "{}", st.message); - // 2. Middle (intermediate) reparse point + // A junction in the middle of the path. let mid_junc = t.root.join("mid_junc"); - let _ = std::process::Command::new("cmd") - .args([ - "/c", - "mklink", - "/J", - mid_junc.to_str().unwrap(), - t.outside.to_str().unwrap(), - ]) - .status(); - if mid_junc.exists() { - let target = mid_junc.join("secret.txt").display().to_string(); - let err = read(&target, &roots, 1024).expect_err("mid reparse denial"); - assert_eq!(err.code, codes::DENIED); - } + junction(&mid_junc, &t.outside); + let target = mid_junc.join("secret.txt").display().to_string(); + let err = read(&target, &roots, 1024).expect_err("mid reparse denial"); + assert_eq!(err.code, codes::DENIED); + assert!(err.message.contains("reparse point"), "{}", err.message); - // 3. Root itself is a reparse point + // The root itself is a junction: refused for being one, not + // for some other failure of the walk. let root_junc = t.base.join("root_junc"); - let _ = std::process::Command::new("cmd") - .args([ - "/c", - "mklink", - "/J", - root_junc.to_str().unwrap(), - t.root.to_str().unwrap(), - ]) - .status(); - if root_junc.exists() { - let junc_roots = vec![root_junc.display().to_string()]; - let target = root_junc.join("a.txt").display().to_string(); - let err = read(&target, &junc_roots, 1024).expect_err("root reparse denial"); - assert_eq!(err.code, codes::DENIED); + junction(&root_junc, &t.root); + let junc_roots = vec![root_junc.display().to_string()]; + let target = root_junc.join("a.txt").display().to_string(); + let err = read(&target, &junc_roots, 1024).expect_err("root reparse denial"); + assert_eq!(err.code, codes::DENIED); + assert!(err.message.contains("reparse point"), "{}", err.message); + } + + /// Removes a volume mount point when the test ends, however it ends. + struct MountPoint(Vec); + + impl Drop for MountPoint { + fn drop(&mut self) { + unsafe { test_ffi::DeleteVolumeMountPointW(self.0.as_ptr()) }; } } + #[test] + fn windows_mount_point_refused() { + let t = WinTree::new("mount-point"); + let roots = t.roots(); + let mnt = t.root.join("mnt"); + std::fs::create_dir(&mnt).expect("create mount directory"); + + // Mount the volume the tree is on at root\mnt. Needs an + // administrator, as on GitHub's Windows runners. + let drive = &t.root.to_str().unwrap()[..3]; + let mut volume = vec![0u16; 64]; + let ok = unsafe { + test_ffi::GetVolumeNameForVolumeMountPointW( + wide(drive).as_ptr(), + volume.as_mut_ptr(), + volume.len() as u32, + ) + }; + assert_ne!( + ok, + 0, + "volume name of {drive}: {}", + std::io::Error::last_os_error() + ); + let mount_at = wide(&format!("{}\\", mnt.display())); + let ok = unsafe { test_ffi::SetVolumeMountPointW(mount_at.as_ptr(), volume.as_ptr()) }; + assert_ne!( + ok, + 0, + "mount {drive} at {} (needs an administrator): {}", + mnt.display(), + std::io::Error::last_os_error() + ); + let _mounted = MountPoint(mount_at); + assert_link(&mnt); + + let st = stat(&mnt.display().to_string(), &roots).expect_err("stat mount point"); + assert_eq!(st.code, codes::DENIED); + assert!(st.message.contains("reparse point"), "{}", st.message); + + let through = mnt.join("Windows").display().to_string(); + let err = list(&through, &roots).expect_err("list through mount point"); + assert_eq!(err.code, codes::DENIED); + assert!(err.message.contains("reparse point"), "{}", err.message); + + let mnt_roots = vec![mnt.display().to_string()]; + let err = list(&mnt.display().to_string(), &mnt_roots).expect_err("mount point root"); + assert_eq!(err.code, codes::DENIED); + assert!(err.message.contains("reparse point"), "{}", err.message); + } + #[test] fn windows_dotdot_and_relative_escapes() { let t = WinTree::new("dotdot-escapes"); @@ -2257,17 +2697,17 @@ mod tests { let t = WinTree::new("aliases"); let roots = t.roots(); - // Inside root: case alias succeeds (kernel resolves canonical path which is inside root) + // A case alias inside the root opens the same entry. let case_alias_inside = t.root.join("SUB\\B.TXT").display().to_string(); let val = read(&case_alias_inside, &roots, 1024).expect("read case alias inside"); assert_eq!(val["text"], "nested"); - // Outside root: case alias outside root is denied + // Outside the root: refused because the path's spelling does not + // lie under the root's, before anything is opened. let case_outside = t.outside.join("SECRET.TXT").display().to_string(); let err_case = read(&case_outside, &roots, 1024).expect_err("case outside denied"); assert_eq!(err_case.code, codes::DENIED); - // Outside root: 8.3 alias outside root is denied (does not match manifest root prefix) let outside_83 = t.base.join("OUTSI~1\\secret.txt").display().to_string(); let err_83 = read(&outside_83, &roots, 1024).expect_err("8.3 outside denied"); assert_eq!(err_83.code, codes::DENIED); @@ -2282,59 +2722,67 @@ mod tests { let target_clone = target_path.clone(); let outside_clone = t.outside.join("secret.txt"); - - // Deterministic hook: executed immediately after path resolution before handle open - *SWAP_HOOK.lock().unwrap() = Some(Box::new(move || { - let _ = std::fs::remove_file(&target_clone); - // Create reparse point or link at the target location pointing outside - let _ = std::process::Command::new("cmd") - .args([ - "/c", - "mklink", - target_clone.to_str().unwrap(), - outside_clone.to_str().unwrap(), - ]) - .status(); - })); - + // Runs on this thread only, after resolution and before the open. + hooks::BEFORE_READ_OPEN.with(|h| { + *h.borrow_mut() = Some(Box::new(move || { + std::fs::remove_file(&target_clone).expect("remove the checked file"); + file_symlink(&target_clone, &outside_clone); + })); + }); let res = read(&target_path.display().to_string(), &roots, 1024); - *SWAP_HOOK.lock().unwrap() = None; + hooks::BEFORE_READ_OPEN.with(|h| *h.borrow_mut() = None); - if let Err(denial) = res { - assert_eq!(denial.code, codes::DENIED); - assert!( - denial.message.contains("became a symlink") - || denial.message.contains("outside") - ); - } + let denial = res.expect_err("a symlink swapped in after the check is refused"); + assert_eq!(denial.code, codes::DENIED); + assert!( + denial.message.contains("became a symlink"), + "{}", + denial.message + ); } #[test] fn windows_posix_rename_refusal_simulated() { - // Modern NTFS and ReFS on Windows 10 1709+ natively support FileRenameInformationEx - // POSIX semantics. To verify that a refusing volume is refused with no fallback, - // we simulate the refusal deterministically via SIMULATE_POSIX_RENAME_REFUSAL. + // NTFS and ReFS on Windows 10 1709 and later support the POSIX + // rename, so a test hook (on this thread only) makes the rename + // fail as a refusing volume would, to show the write is refused + // with no fallback. let t = WinTree::new("posix-refusal"); let roots = t.roots(); let target = t.p("refused_rename.txt"); - SIMULATE_POSIX_RENAME_REFUSAL.store(true, std::sync::atomic::Ordering::Relaxed); + hooks::REFUSE_POSIX_RENAME.with(|f| f.set(true)); let res = write(&target, &roots, "data"); - SIMULATE_POSIX_RENAME_REFUSAL.store(false, std::sync::atomic::Ordering::Relaxed); + hooks::REFUSE_POSIX_RENAME.with(|f| f.set(false)); let err = res.expect_err("POSIX rename refusal"); assert_eq!(err.code, codes::DENIED); - assert!(err.message.contains("POSIX replace rename is refused")); - - // Verify temporary file was deleted on refusal - let entries = std::fs::read_dir(&t.root).unwrap(); - for entry in entries { - let name = entry.unwrap().file_name().to_string_lossy().into_owned(); - assert!( - !name.starts_with(".basal-call-"), - "leftover temp file: {name}" - ); - } + assert!( + err.message + .contains("volume does not support POSIX replace rename"), + "{}", + err.message + ); + assert!( + !Path::new(&target).exists(), + "nothing was renamed into place" + ); + assert!(t.temps_in("").is_empty(), "leftover temp files"); + } + + #[test] + fn windows_failed_flush_stops_the_replace() { + let t = WinTree::new("flush-failure"); + let roots = t.roots(); + + hooks::FAIL_TEMP_FLUSH.with(|f| f.set(true)); + let res = write(&t.p("a.txt"), &roots, "unflushed"); + hooks::FAIL_TEMP_FLUSH.with(|f| f.set(false)); + + let err = res.expect_err("a failed flush refuses the rename"); + assert_eq!(err.code, codes::IO); + assert_eq!(std::fs::read_to_string(t.p("a.txt")).unwrap(), "inside"); + assert!(t.temps_in("").is_empty(), "leftover temp files"); } #[test] @@ -2345,19 +2793,38 @@ mod tests { let res = read(&file_path, &roots, 1024).expect("read file root"); assert_eq!(res["text"], "inside"); + let st = stat(&file_path, &roots).expect("stat file root"); + assert_eq!(st["kind"], "file"); let sibling = t.p("sub\\b.txt"); let err = read(&sibling, &roots, 1024).expect_err("sibling denied"); assert_eq!(err.code, codes::DENIED); + let err = list(&file_path, &roots).expect_err("a file root is not listed"); + assert_eq!(err.code, codes::DENIED); - // Write to file root replaces file root + // Writing to the granted file's own path replaces its contents. + resolve(&file_path, &roots, Purpose::Write).expect("authorized"); write(&file_path, &roots, "replaced").expect("replace file root"); assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "replaced"); - // Write to sibling denied let err_write = write(&sibling, &roots, "sibling data").expect_err("sibling write denied"); assert_eq!(err_write.code, codes::DENIED); + let err_write = write(&t.p("new.txt"), &roots, "new").expect_err("new sibling denied"); + assert_eq!(err_write.code, codes::DENIED); + assert!(!Path::new(&t.p("new.txt")).exists()); + } + + #[test] + fn windows_file_root_is_read_without_execute_access() { + let t = WinTree::new("file-root-no-execute"); + let file_path = t.p("a.txt"); + // Read for everyone, and no execute right for anyone. + set_dacl(Path::new(&file_path), "D:P(A;;FR;;;WD)"); + let roots = vec![file_path.clone()]; + + let res = read(&file_path, &roots, 1024).expect("read file root"); + assert_eq!(res["text"], "inside"); } #[test] @@ -2371,6 +2838,38 @@ mod tests { write(&target, &roots, "version 2").expect("write v2"); assert_eq!(std::fs::read_to_string(&target).unwrap(), "version 2"); + assert!(t.temps_in("").is_empty(), "leftover temp files"); + } + + #[test] + fn windows_write_creates_file_in_subdirectory() { + let t = WinTree::new("write-subdir"); + let roots = t.roots(); + let target = t.p("sub\\created.txt"); + + write(&target, &roots, "created").expect("create in subdirectory"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "created"); + assert!(t.temps_in("sub").is_empty(), "leftover temp files"); + + let missing_parent = t.p("nowhere\\x.txt"); + let err = write(&missing_parent, &roots, "x").expect_err("missing parent"); + assert_eq!(err.code, codes::DENIED); + + let dir_target = write(&t.p("sub"), &roots, "x").expect_err("directory target"); + assert_eq!(dir_target.code, codes::INVALID_ARGUMENTS); + } + + #[test] + fn windows_write_replaces_read_only_file() { + let t = WinTree::new("write-read-only"); + let roots = t.roots(); + let target = t.root.join("a.txt"); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms).expect("set read-only"); + + write(&target.display().to_string(), &roots, "replaced").expect("replace"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "replaced"); } #[test] @@ -2384,132 +2883,25 @@ mod tests { 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! + // The name was replaced; the other link keeps the old contents. assert_eq!(std::fs::read_to_string(&link).unwrap(), "new link content"); assert_eq!(std::fs::read_to_string(&orig).unwrap(), "initial content"); } - 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"); + fn windows_replace_keeps_the_protected_dacl() { + let t = WinTree::new("dacl-replace"); let roots = t.roots(); let target = t.root.join("protected.txt"); std::fs::write(&target, "secret").expect("write target"); + set_dacl(&target, "D:P(A;;FA;;;WD)"); + let (flags, aces) = dacl_parts(&dacl_of(&target).expect("target DACL")); + assert!(flags.contains('P'), "the DACL was applied: {flags}"); + assert_eq!(aces, ["A;;FA;;;WD"], "the DACL was applied"); - // 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(), - }; - + let observer = DaclObserver::default(); write_call( &target.display().to_string(), &roots, @@ -2519,13 +2911,14 @@ mod tests { ) .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" - ); - + for (what, sddl) in [ + ("temporary file", observer.temp_dacl()), + ("result", dacl_of(&target).expect("result DACL")), + ] { + let (flags, aces) = dacl_parts(&sddl); + assert!(flags.contains('P'), "the {what} is protected: {sddl}"); + assert_eq!(aces, ["A;;FA;;;WD"], "the {what} keeps the DACL: {sddl}"); + } assert_eq!( std::fs::read_to_string(&target).unwrap(), "replacement text" @@ -2533,16 +2926,14 @@ mod tests { } #[test] - fn windows_dacl_preservation_on_create() { + fn windows_create_inherits_the_folder_dacl() { let t = WinTree::new("dacl-create"); let roots = t.roots(); - let new_file = t.root.join("new_file.txt"); - - let temp_sddl_holder = std::sync::Arc::new(std::sync::Mutex::new(None)); - let observer = DaclObserver { - temp_sddl: temp_sddl_holder.clone(), - }; + let sub = t.root.join("sub"); + set_dacl(&sub, "D:P(A;OICI;FA;;;WD)"); + let new_file = sub.join("new_file.txt"); + let observer = DaclObserver::default(); write_call( &new_file.display().to_string(), &roots, @@ -2552,15 +2943,239 @@ mod tests { ) .expect("write call create with dacl"); - let captured = temp_sddl_holder.lock().unwrap().clone(); - assert!( - captured.is_some(), - "temp DACL was observed during create temp phase" - ); + for (what, sddl) in [ + ("temporary file", observer.temp_dacl()), + ("result", dacl_of(&new_file).expect("result DACL")), + ] { + let (flags, aces) = dacl_parts(&sddl); + assert!(!flags.contains('P'), "the {what} is not protected: {sddl}"); + assert_eq!( + aces, + ["A;ID;FA;;;WD"], + "the {what} carries the folder's entry, inherited: {sddl}" + ); + } assert_eq!( std::fs::read_to_string(&new_file).unwrap(), "initial created text" ); } + + /// A test `TempLedger` that, when the write records its temporary + /// file, tries to move a directory out of the root. + struct MoveDuringWrite { + from: PathBuf, + to: PathBuf, + result: Mutex>>, + } + + impl TempLedger for MoveDuringWrite { + fn record(&self, _lease: &TempLease) -> Result, String> { + // No file beneath `from` is open yet, so the write's own + // handle on that directory is the only thing that can stop + // the move. + *self.result.lock().unwrap() = Some(std::fs::rename(&self.from, &self.to)); + Ok(Box::new(NoHold)) + } + } + + #[test] + fn windows_write_parent_cannot_be_moved_out_during_the_write() { + let t = WinTree::new("parent-move"); + let roots = t.roots(); + let moved = t.outside.join("sub-moved"); + let ledger = MoveDuringWrite { + from: t.root.join("sub"), + to: moved.clone(), + result: Mutex::new(None), + }; + + write_call( + &t.p("sub\\new.txt"), + &roots, + "data", + "call-move-1", + Some(&ledger), + ) + .expect("write"); + + let attempt = ledger + .result + .lock() + .unwrap() + .take() + .expect("move attempted"); + let err = attempt.expect_err("the held parent cannot be moved"); + assert_eq!( + err.raw_os_error(), + Some(test_ffi::ERROR_SHARING_VIOLATION), + "{err}" + ); + assert!(!moved.exists(), "nothing was moved outside the root"); + assert_eq!( + std::fs::read_to_string(t.p("sub\\new.txt")).unwrap(), + "data" + ); + } + + #[test] + fn windows_list_directory_root_and_subdirectory() { + let t = WinTree::new("list"); + let roots = t.roots(); + + let root_list = list(&t.root.display().to_string(), &roots).expect("list root"); + assert_eq!( + root_list, + json!([ + { "name": "a.txt", "kind": "file" }, + { "name": "sub", "kind": "dir" }, + ]) + ); + let sub_list = list(&t.p("sub"), &roots).expect("list sub"); + assert_eq!(sub_list, json!([{ "name": "b.txt", "kind": "file" }])); + + let missing = list(&t.p("missing"), &roots).expect_err("missing"); + assert_eq!(missing.code, codes::NOT_FOUND); + let file = list(&t.p("a.txt"), &roots).expect_err("a file"); + assert_eq!(file.code, codes::IO); + } + + #[test] + fn windows_failed_directory_query_is_an_error() { + let t = WinTree::new("list-failure"); + // The walk's handle on a directory root has no FILE_LIST_DIRECTORY, + // so querying it fails on the first call. + let vr = + walk_from_volume_root(&t.root.display().to_string(), SHARE_ALL).expect("walk root"); + let mut seen = 0; + let res = scan_directory(vr.handle.raw(), |_, _| { + seen += 1; + true + }); + assert_eq!(res, Err(ffi::STATUS_ACCESS_DENIED)); + assert_eq!(seen, 0); + } + + fn lease(dir: &Path, target: &str, temp: &str, roots: Vec) -> TempLease { + TempLease { + call_key: "k".to_owned(), + dir: dir.to_path_buf(), + target: OsString::from(target), + temp: OsString::from(temp), + roots, + } + } + + #[test] + fn windows_remove_temp_acts_in_the_recorded_directory() { + let t = WinTree::new("remove-temp-subdir"); + let temp = ".basal-call-k1.tmp"; + std::fs::write(t.root.join("sub").join(temp), "partial").unwrap(); + // The same name in the root is not the recorded file. + std::fs::write(t.root.join(temp), "decoy").unwrap(); + let l = lease(&t.root.join("sub"), "x.txt", temp, t.roots()); + + assert_eq!(remove_temp(&l), Ok(TempRemoval::Removed)); + assert!(!t.root.join("sub").join(temp).exists()); + assert!(t.root.join(temp).exists(), "the root's file is untouched"); + assert_eq!(remove_temp(&l), Ok(TempRemoval::Absent)); + } + + #[test] + fn windows_remove_temp_under_a_file_root() { + let t = WinTree::new("remove-temp-file-root"); + let temp = ".basal-call-k2.tmp"; + std::fs::write(t.root.join(temp), "partial").unwrap(); + let l = lease(&t.root, "a.txt", temp, vec![t.p("a.txt")]); + + assert_eq!(remove_temp(&l), Ok(TempRemoval::Removed)); + assert!(!t.root.join(temp).exists()); + } + + #[test] + fn windows_remove_temp_refuses_anything_but_a_regular_file() { + let t = WinTree::new("remove-temp-dir"); + let temp = ".basal-call-k3.tmp"; + std::fs::create_dir(t.root.join(temp)).unwrap(); + let l = lease(&t.root, "x.txt", temp, t.roots()); + + let Ok(TempRemoval::Refused(why)) = remove_temp(&l) else { + panic!("a directory under the temporary name is refused"); + }; + assert_eq!(why.code, codes::DENIED); + assert!(t.root.join(temp).is_dir(), "the directory is untouched"); + } + + #[test] + fn windows_remove_temp_with_a_missing_root_is_absent() { + let t = WinTree::new("remove-temp-gone"); + let gone = t.base.join("gone"); + let l = lease( + &gone, + "x.txt", + ".basal-call-k4.tmp", + vec![gone.display().to_string()], + ); + assert_eq!(remove_temp(&l), Ok(TempRemoval::Absent)); + } + + #[test] + fn windows_remove_legacy_temps_removes_only_regular_files() { + let t = WinTree::new("legacy-temps"); + std::fs::write(t.root.join(".basal-12-3.tmp"), "old").unwrap(); + std::fs::create_dir(t.root.join(".basal-45-6.tmp")).unwrap(); + std::fs::write(t.root.join(".basal-x-1.tmp"), "not legacy").unwrap(); + + assert_eq!(remove_legacy_temps(&t.p("a.txt"), &t.roots()), Ok(1)); + assert!(!t.root.join(".basal-12-3.tmp").exists()); + assert!(t.root.join(".basal-45-6.tmp").is_dir()); + assert!(t.root.join(".basal-x-1.tmp").exists()); + + // Also beside a file root: the directory a write to that file + // root acts in. + std::fs::write(t.root.join(".basal-7-8.tmp"), "old").unwrap(); + assert_eq!(remove_legacy_temps(&t.p("a.txt"), &[t.p("a.txt")]), Ok(1)); + assert!(!t.root.join(".basal-7-8.tmp").exists()); + } + + #[test] + fn windows_temp_name_holding_a_directory_is_not_removed() { + let t = WinTree::new("temp-collision-dir"); + let roots = t.roots(); + let temp_dir = t.root.join(".basal-call-collide.tmp"); + std::fs::create_dir(&temp_dir).unwrap(); + + let err = write_call(&t.p("x.txt"), &roots, "data", "collide", None) + .expect_err("a directory under the temporary name stops the write"); + assert_eq!(err.code, codes::IO); + assert!(temp_dir.is_dir(), "the directory is untouched"); + assert!(!Path::new(&t.p("x.txt")).exists()); + } + + #[test] + fn windows_temp_name_holding_a_regular_file_is_replaced() { + let t = WinTree::new("temp-collision-file"); + let roots = t.roots(); + std::fs::write(t.root.join(".basal-call-again.tmp"), "earlier send").unwrap(); + + write_call(&t.p("x.txt"), &roots, "data", "again", None).expect("write"); + assert_eq!(std::fs::read_to_string(t.p("x.txt")).unwrap(), "data"); + assert!(t.temps_in("").is_empty(), "leftover temp files"); + } + + #[test] + fn windows_volume_root_grants_its_children() { + let t = WinTree::new("volume-root"); + let drive = t.root.to_str().unwrap()[..3].to_owned(); + let roots = vec![drive]; + + resolve(&t.p("a.txt"), &roots, Purpose::Read).expect("resolve"); + let val = read(&t.p("a.txt"), &roots, 1024).expect("read under a volume root"); + assert_eq!(val["text"], "inside"); + let st = stat(&t.p("sub\\b.txt"), &roots).expect("stat under a volume root"); + assert_eq!(st["size"], 6); + write(&t.p("v.txt"), &roots, "volume").expect("write under a volume root"); + assert_eq!(std::fs::read_to_string(t.p("v.txt")).unwrap(), "volume"); + } } } 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 06/13] 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 07/13] 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 08/13] 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!( From 651c11934c0162ed6d2574c63720df085b9514c0 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:57:35 +0200 Subject: [PATCH 09/13] Draw host entropy from BCryptGenRandom on Windows getentropy exists only on Unix. On Windows the system-preferred RNG supplies each journaled draw, and there is still no fallback when it fails. (cherry picked from commit a69f2b028b987aa60a08c5f05ee5cd62b5192fb8) --- crates/basal-host/src/core_host.rs | 34 ++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/basal-host/src/core_host.rs b/crates/basal-host/src/core_host.rs index 6b7bced..842438f 100644 --- a/crates/basal-host/src/core_host.rs +++ b/crates/basal-host/src/core_host.rs @@ -240,14 +240,44 @@ pub(crate) fn sample() -> f64 { // Live draws use OS entropy, not shared predictable process state. The // runtime journals each draw and replay serves that saved value instead. // There is deliberately no fallback when the OS cannot supply entropy. - let rc = unsafe { libc::getentropy((&mut x as *mut u64).cast(), std::mem::size_of::()) }; + fill_entropy(&mut x); + (x >> 11) as f64 / ((1u64 << 53) as f64) +} + +#[cfg(unix)] +fn fill_entropy(x: &mut u64) { + let rc = unsafe { libc::getentropy((x as *mut u64).cast(), std::mem::size_of::()) }; assert_eq!( rc, 0, "OS entropy unavailable: {}", std::io::Error::last_os_error() ); - (x >> 11) as f64 / ((1u64 << 53) as f64) +} + +#[cfg(windows)] +fn fill_entropy(x: &mut u64) { + // The system-preferred RNG needs no algorithm handle; flag value 2 is + // BCRYPT_USE_SYSTEM_PREFERRED_RNG. + #[link(name = "bcrypt")] + unsafe extern "system" { + fn BCryptGenRandom( + algorithm: *mut core::ffi::c_void, + buffer: *mut u8, + len: u32, + flags: u32, + ) -> i32; + } + const BCRYPT_USE_SYSTEM_PREFERRED_RNG: u32 = 2; + let status = unsafe { + BCryptGenRandom( + std::ptr::null_mut(), + (x as *mut u64).cast(), + std::mem::size_of::() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + }; + assert_eq!(status, 0, "OS entropy unavailable: NTSTATUS {status:#010x}"); } /// A published status lasts half an hour unless core supersedes its revision. From 9cafad64e6dbdad821706a18b23d2ee793be40d4 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:03:41 +0200 Subject: [PATCH 10/13] Run basal-host's tests on their own in the Windows baseline job The workspace test can't build until every crate compiles on Windows, so basal-host's built-ins get their own test step that reports now. (cherry picked from commit 990ce58ea81ea3e4563b9818e92fdfc3cc276004) --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c25e81..5510386 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -406,6 +406,13 @@ jobs: continue-on-error: true timeout-minutes: 40 run: cargo test --workspace --locked --no-fail-fast 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-no-fail-fast.log" + # The workspace test can't build until every crate compiles on Windows, + # so basal-host's own tests run separately to report its built-ins now. + - name: Test basal-host on its own + id: test-host + continue-on-error: true + timeout-minutes: 30 + run: cargo test -p basal-host --lib --locked --no-fail-fast 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-host.log" - name: Summarize the baseline if: always() run: | From 0897adcf783eb13b97ca5fa35a6b80c2543212a6 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:41:16 +0200 Subject: [PATCH 11/13] Re-anchor the random-source control on the shared entropy call --- mutations.toml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/mutations.toml b/mutations.toml index 543b6bc..df6ae46 100644 --- a/mutations.toml +++ b/mutations.toml @@ -8221,13 +8221,7 @@ let mut x = 0u64; // Live draws use OS entropy, not shared predictable process state. The // runtime journals each draw and replay serves that saved value instead. // There is deliberately no fallback when the OS cannot supply entropy. - let rc = unsafe { libc::getentropy((&mut x as *mut u64).cast(), std::mem::size_of::()) }; - assert_eq!( - rc, - 0, - "OS entropy unavailable: {}", - std::io::Error::last_os_error() - );""" + fill_entropy(&mut x);""" new = """ // NON-VACUITY BREAK use std::sync::atomic::{AtomicU64, Ordering}; From c61d815f386af368cd9bc2443de8ef9afd85b5a8 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:22:42 +0200 Subject: [PATCH 12/13] mason: Windows worker entry and startup checks The worker builds and confines itself on Windows under basal-launch: - GUI-subsystem image (no console host); an import test checks the subsystem, the forbidden GUI/COM DLLs and that no C runtime DLL is imported. - --package-sid parsing: missing exits 70 package-sid-argument-missing, unparsable or repeated exits 64. - Startup steps 1-6 before the first frame read: revert and require ERROR_NO_TOKEN, lower the primary to Untrusted and close the handle, close the CSR ALPC port and private File handles, check the actual primary token, check single mitigation bits, check the handle table against two per-image profiles that are never combined. Each failure exits 70 with its reason token. A step-3 failure (snapshot or close) is reported as handle-not-allowed, since the spec names no token for it. - The checks are pure functions tested on every system; the readers and the sequence are Windows-only. - Thread CPU clock from GetThreadTimes; JsClock takes an injectable sample source. The existing real-clock test keeps its claim but uses budgets and burns of several scheduler ticks, because Windows advances the clock a tick (~15.6 ms) at a time. - A `deviations` feature lets a test worker act on --confinement-deviation for the three checks the parent cannot break; without it the argument is refused as unknown (exit 64). Tests that use basal-testkit are gated off Windows until it builds there, and basal-testkit becomes a non-Windows dev-dependency. Windows worker tests spawn through basal-launch. The windows-baseline job runs the worker's tests on their own, with and without the feature. --- .github/workflows/ci.yml | 16 + Cargo.lock | 2 + crates/basal-worker/Cargo.toml | 34 +- crates/basal-worker/examples/wrapper_stack.rs | 9 + crates/basal-worker/src/clock.rs | 94 ++- crates/basal-worker/src/clock/windows.rs | 99 +++ crates/basal-worker/src/confinement.rs | 10 + .../basal-worker/src/confinement/windows.rs | 290 ++++++++ .../src/confinement/windows/allowlist.rs | 472 +++++++++++++ .../src/confinement/windows/attest.rs | 321 +++++++++ .../src/confinement/windows/mitigation.rs | 226 +++++++ .../src/confinement/windows/native.rs | 623 ++++++++++++++++++ .../src/confinement/windows/startup.rs | 133 ++++ crates/basal-worker/src/main.rs | 44 +- crates/basal-worker/tests/budgets.rs | 3 + crates/basal-worker/tests/clock.rs | 3 + crates/basal-worker/tests/codemode.rs | 4 + crates/basal-worker/tests/fuzz.rs | 3 + .../tests/inherited_descriptors.rs | 3 + crates/basal-worker/tests/ipc.rs | 3 + crates/basal-worker/tests/lockdown.rs | 3 + crates/basal-worker/tests/profile.rs | 3 + crates/basal-worker/tests/replay.rs | 3 + .../basal-worker/tests/sandbox_integrity.rs | 4 + crates/basal-worker/tests/windows_image.rs | 211 ++++++ crates/basal-worker/tests/windows_startup.rs | 383 +++++++++++ 26 files changed, 2986 insertions(+), 13 deletions(-) create mode 100644 crates/basal-worker/src/clock/windows.rs create mode 100644 crates/basal-worker/src/confinement/windows.rs create mode 100644 crates/basal-worker/src/confinement/windows/allowlist.rs create mode 100644 crates/basal-worker/src/confinement/windows/attest.rs create mode 100644 crates/basal-worker/src/confinement/windows/mitigation.rs create mode 100644 crates/basal-worker/src/confinement/windows/native.rs create mode 100644 crates/basal-worker/src/confinement/windows/startup.rs create mode 100644 crates/basal-worker/tests/windows_image.rs create mode 100644 crates/basal-worker/tests/windows_startup.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01cf43b..46b5d6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -396,6 +396,22 @@ jobs: continue-on-error: true timeout-minutes: 20 run: cargo test -p basal-launch --features deviations --locked -- --show-output 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-launch.log" + # The worker's tests on their own: its Windows startup checks, its image + # and a real activation, each worker started through the launcher. The + # plain build is the production worker, which must refuse the + # launcher's request to skip a check. The deviations build is a test + # worker that honours that request, for the three checks the parent + # cannot break from outside. + - name: Test the worker on its own + id: test-worker + continue-on-error: true + timeout-minutes: 30 + run: cargo test -p basal-worker --locked --no-fail-fast -- --show-output 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-worker.log" + - name: Test the worker with its test variants + id: test-worker-deviations + continue-on-error: true + timeout-minutes: 30 + run: cargo test -p basal-worker --features deviations --locked --no-fail-fast -- --show-output 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-worker-deviations.log" - name: Check every workspace target id: check continue-on-error: true diff --git a/Cargo.lock b/Cargo.lock index ed06b67..b450c98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,6 +168,7 @@ dependencies = [ name = "basal-worker" version = "0.1.0" dependencies = [ + "basal-launch", "basal-proto", "basal-testkit", "landlock", @@ -175,6 +176,7 @@ dependencies = [ "rquickjs", "seccompiler", "serde_json", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/basal-worker/Cargo.toml b/crates/basal-worker/Cargo.toml index 58349ed..a442e03 100644 --- a/crates/basal-worker/Cargo.toml +++ b/crates/basal-worker/Cargo.toml @@ -19,10 +19,42 @@ rquickjs.workspace = true landlock = "=0.4.7" seccompiler = "=0.5.0" +# The worker's own startup checks on Windows: its tokens, mitigation +# policies, handle table and thread CPU time. Every function used here is +# exported by kernel32, advapi32 or ntdll, so the image imports no DLL that +# would pull User32, GDI or COM into the confined process. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_System_Console", + "Win32_System_Threading", +] } + +[features] +default = [] +# Test-only: lets the worker act on `--confinement-deviation=`, which +# basal-launch passes for the three startup checks the parent cannot break +# from outside (a thread token left in place, a failed or skipped integrity +# lowering). A worker built without this feature refuses that argument as +# unknown, so a production worker has no way to skip a check. +deviations = [] + [dev-dependencies] -basal-testkit.workspace = true serde_json.workspace = true +# basal-testkit does not build on Windows yet. The test files that use it are +# compiled only on other systems until it does. +[target.'cfg(not(windows))'.dev-dependencies] +basal-testkit.workspace = true + +# On Windows every test spawn of the worker goes through the launcher, under +# the real confinement. Its test variants break one confinement property at a +# time, for the worker's refusal tests. +[target.'cfg(windows)'.dev-dependencies] +basal-launch = { workspace = true, features = ["deviations"] } + [[bin]] name = "ck-basal-worker" path = "src/main.rs" diff --git a/crates/basal-worker/examples/wrapper_stack.rs b/crates/basal-worker/examples/wrapper_stack.rs index 7f57759..93b6e79 100644 --- a/crates/basal-worker/examples/wrapper_stack.rs +++ b/crates/basal-worker/examples/wrapper_stack.rs @@ -2,6 +2,9 @@ //! the engine freezes its global object, both directly and through the worker. //! Frozen globals reject new names before a call can run; scoped functions can //! recurse. Direct versus worker entry separates those engine costs from OS confinement. + +// Windows: gated until basal-testkit builds there. +#![cfg_attr(windows, allow(dead_code, unused_imports))] use basal_proto::{ActivationRequest, ActivationResult, Budgets, JsonText, Profile}; use basal_worker::{engine, link::Channel}; use std::cell::RefCell; @@ -30,6 +33,12 @@ fn direct(request: &ActivationRequest) -> ActivationResult { ))); engine::run_activation(request, link) } +#[cfg(windows)] +fn main() { + panic!("wrapper_stack needs basal-testkit, which does not build on Windows yet"); +} + +#[cfg(not(windows))] fn main() { let mode = std::env::args().nth(1).unwrap_or_else(|| "direct".into()); println!( diff --git a/crates/basal-worker/src/clock.rs b/crates/basal-worker/src/clock.rs index 5dde9ad..775454d 100644 --- a/crates/basal-worker/src/clock.rs +++ b/crates/basal-worker/src/clock.rs @@ -10,12 +10,25 @@ use std::cell::Cell; use std::time::Duration; +#[cfg(any(windows, test))] +pub mod windows; + +/// The calling thread's CPU time. +/// +/// On Windows it is the thread's kernel plus user time from `GetThreadTimes` +/// (see [`windows`]), which advances in scheduler ticks. +#[cfg(windows)] +pub fn thread_cpu_time() -> Duration { + windows::thread_cpu_time() +} + /// The calling thread's CPU time. /// /// `CLOCK_THREAD_CPUTIME_ID` is supported on every macOS release this worker /// targets and on Linux; if the call ever fails, the budget falls back to /// treating the clock as stopped, and the parent's wall-clock deadline (which /// kills the worker) remains the backstop. +#[cfg(not(windows))] pub fn thread_cpu_time() -> Duration { let mut ts = libc::timespec { tv_sec: 0, @@ -39,22 +52,31 @@ pub struct JsClock { spent: Cell, entered_at: Cell>, exhausted: Cell, + /// Where CPU time samples come from: [`thread_cpu_time`], or a scripted + /// source in tests. + sample: fn() -> Duration, } impl JsClock { pub fn new(budget: Duration) -> Self { + Self::with_source(budget, thread_cpu_time) + } + + /// A clock that reads its CPU time samples from `sample`. + pub fn with_source(budget: Duration, sample: fn() -> Duration) -> Self { Self { budget, spent: Cell::new(Duration::ZERO), entered_at: Cell::new(None), exhausted: Cell::new(false), + sample, } } /// Starts counting: the worker is about to run JavaScript. pub fn enter(&self) { if self.entered_at.get().is_none() { - self.entered_at.set(Some(thread_cpu_time())); + self.entered_at.set(Some((self.sample)())); } } @@ -62,7 +84,7 @@ impl JsClock { /// about to wait on the parent. pub fn leave(&self) { if let Some(start) = self.entered_at.take() { - let now = thread_cpu_time(); + let now = (self.sample)(); self.spent.set(self.spent.get() + now.saturating_sub(start)); } } @@ -71,7 +93,7 @@ impl JsClock { let running = self .entered_at .get() - .map(|start| thread_cpu_time().saturating_sub(start)) + .map(|start| (self.sample)().saturating_sub(start)) .unwrap_or_default(); self.spent.get() + running } @@ -103,18 +125,76 @@ mod tests { } } + /// Against the real clock. The budget and the burns span several + /// scheduler ticks (about 15.6 ms on Windows, where the thread CPU clock + /// advances a tick at a time), so tick rounding cannot flip the outcome. #[test] fn time_outside_the_engine_is_not_counted() { - let clock = JsClock::new(Duration::from_millis(30)); + let clock = JsClock::new(Duration::from_millis(150)); clock.enter(); - burn(Duration::from_millis(5)); + burn(Duration::from_millis(30)); clock.leave(); // Sleeping and burning CPU while left must not count. std::thread::sleep(Duration::from_millis(50)); - burn(Duration::from_millis(40)); + burn(Duration::from_millis(200)); assert!(!clock.over_budget(), "spent {:?}", clock.spent()); clock.enter(); - burn(Duration::from_millis(40)); + burn(Duration::from_millis(200)); + assert!(clock.over_budget()); + } + + thread_local! { + static SAMPLE: Cell = const { Cell::new(Duration::ZERO) }; + } + + fn scripted() -> Duration { + SAMPLE.with(Cell::get) + } + + fn set(millis: u64) { + SAMPLE.with(|sample| sample.set(Duration::from_millis(millis))); + } + + /// With injected samples: only the intervals between `enter` and + /// `leave` count, a running interval counts up to the latest sample, and + /// exhaustion stays latched. + #[test] + fn only_samples_taken_inside_the_engine_are_counted() { + set(1_000); + let clock = JsClock::with_source(Duration::from_millis(100), scripted); + clock.enter(); + set(1_060); + clock.leave(); + assert_eq!(clock.spent(), Duration::from_millis(60)); + // A long gap outside the engine is not counted. + set(9_000); + assert_eq!(clock.spent(), Duration::from_millis(60)); + assert!(!clock.over_budget()); + clock.enter(); + // Entering twice keeps the first sample. + set(9_020); + clock.enter(); + set(9_040); + assert_eq!(clock.spent(), Duration::from_millis(100)); + assert!(!clock.over_budget(), "exactly the budget is not over it"); + set(9_041); assert!(clock.over_budget()); + clock.leave(); + assert!(clock.exhausted()); + assert!(clock.over_budget(), "exhaustion is latched"); + } + + /// A clock that failed returns zero, which can be below the sample taken + /// on entry. The interval then counts as nothing rather than wrapping. + #[test] + fn a_sample_below_the_entry_sample_counts_as_nothing() { + set(500); + let clock = JsClock::with_source(Duration::from_millis(10), scripted); + clock.enter(); + set(0); + assert_eq!(clock.spent(), Duration::ZERO); + clock.leave(); + assert_eq!(clock.spent(), Duration::ZERO); + assert!(!clock.over_budget()); } } diff --git a/crates/basal-worker/src/clock/windows.rs b/crates/basal-worker/src/clock/windows.rs new file mode 100644 index 0000000..ee9dd76 --- /dev/null +++ b/crates/basal-worker/src/clock/windows.rs @@ -0,0 +1,99 @@ +//! The thread CPU clock on Windows. +//! +//! `GetThreadTimes` reports the calling thread's kernel and user time in +//! 100-nanosecond units. The kernel charges that time a scheduler tick at a +//! time (about 15.6 ms by default), so the clock advances in ticks: a budget +//! smaller than a tick is imprecise, and the activation's wall deadline, +//! enforced by the parent, stays the hard bound. A failed read counts as +//! zero, the same fallback the Unix clock uses. + +use std::time::Duration; + +/// The calling thread's kernel plus user time. +#[cfg(windows)] +pub fn thread_cpu_time() -> Duration { + use windows_sys::Win32::Foundation::FILETIME; + use windows_sys::Win32::System::Threading::{GetCurrentThread, GetThreadTimes}; + + let zero = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let (mut creation, mut exit, mut kernel, mut user) = (zero, zero, zero, zero); + // SAFETY: the pseudo-handle names the calling thread, and the four + // out-pointers are valid FILETIMEs for the duration of the call. + let ok = unsafe { + GetThreadTimes( + GetCurrentThread(), + &mut creation, + &mut exit, + &mut kernel, + &mut user, + ) + } != 0; + let ticks = + |time: FILETIME| u64::from(time.dwHighDateTime) << 32 | u64::from(time.dwLowDateTime); + cpu_time(ok.then(|| (ticks(kernel), ticks(user)))) +} + +/// CPU time from one `GetThreadTimes` sample of (kernel, user) time in +/// 100-nanosecond units, or zero when the read failed. +pub fn cpu_time(sample: Option<(u64, u64)>) -> Duration { + match sample { + None => Duration::ZERO, + Some((kernel, user)) => { + let units = kernel.saturating_add(user); + Duration::new(units / 10_000_000, (units % 10_000_000) as u32 * 100) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kernel_and_user_time_add_up_in_100_nanosecond_units() { + // One second of kernel time and one scheduler tick (156,250 units) of + // user time. + assert_eq!( + cpu_time(Some((10_000_000, 156_250))), + Duration::from_secs(1) + Duration::from_micros(15_625) + ); + assert_eq!(cpu_time(Some((0, 1))), Duration::from_nanos(100)); + assert_eq!(cpu_time(Some((0, 0))), Duration::ZERO); + } + + #[test] + fn a_failed_read_is_zero() { + assert_eq!(cpu_time(None), Duration::ZERO); + } + + #[test] + fn the_largest_sample_saturates_instead_of_wrapping() { + let largest = cpu_time(Some((u64::MAX, u64::MAX))); + assert_eq!(largest, cpu_time(Some((u64::MAX, 0)))); + assert!(largest > cpu_time(Some((u64::MAX - 1, 0)))); + } + + /// The real clock advances while the thread burns CPU. Several ticks are + /// burned so the reading moves even at scheduler-tick resolution. The + /// loop is bounded by work done, not by the clock under test, so a clock + /// stuck at zero fails instead of hanging. + #[cfg(windows)] + #[test] + fn the_real_clock_advances_with_work() { + let start = thread_cpu_time(); + let mut x = 0u64; + for round in 0..10_000u32 { + if thread_cpu_time().saturating_sub(start) >= Duration::from_millis(100) { + break; + } + for _ in 0..100_000u32 { + x = x.wrapping_mul(31).wrapping_add(u64::from(round)); + std::hint::black_box(x); + } + } + assert!(thread_cpu_time() >= start + Duration::from_millis(100)); + } +} diff --git a/crates/basal-worker/src/confinement.rs b/crates/basal-worker/src/confinement.rs index 5e39765..77d4e19 100644 --- a/crates/basal-worker/src/confinement.rs +++ b/crates/basal-worker/src/confinement.rs @@ -16,6 +16,11 @@ use basal_proto::Confinement; #[cfg(target_os = "linux")] pub mod linux; +// The pure checks under `windows` are also built for unit tests elsewhere, +// so their accept and reject cases run on every system. +#[cfg(any(windows, test))] +pub mod windows; + /// The Seatbelt profile, checked in beside the crate and embedded at build /// time so the binary cannot be pointed at a different one. pub const SEATBELT_PROFILE: &str = include_str!("../sandbox/worker.sb"); @@ -33,6 +38,9 @@ pub enum ConfinementError { /// A Linux startup precondition or confinement layer failed. #[cfg(target_os = "linux")] Linux(&'static str), + /// A Windows startup check failed, with its reason token. + #[cfg(windows)] + Windows(windows::Refusal), } impl fmt::Display for ConfinementError { @@ -43,6 +51,8 @@ impl fmt::Display for ConfinementError { Self::Unsupported => write!(f, "no supported OS sandbox on this platform"), #[cfg(target_os = "linux")] Self::Linux(token) => f.write_str(token), + #[cfg(windows)] + Self::Windows(refusal) => write!(f, "{refusal}"), } } } diff --git a/crates/basal-worker/src/confinement/windows.rs b/crates/basal-worker/src/confinement/windows.rs new file mode 100644 index 0000000..a2e79d4 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows.rs @@ -0,0 +1,290 @@ +//! The worker's own confinement checks on Windows. +//! +//! On Windows the parent fixes most of the confinement when it creates the +//! worker (see the `basal-launch` crate): a Less Privileged AppContainer +//! token with no capabilities, every group deny-only, no privileges and the +//! NULL SID as the only restricting SID; a job; mitigation policies; and an +//! explicit list of the three inherited pipes. The loader then runs on the +//! main thread under a start-up impersonation token, because the restricted +//! primary allows too little for it to map system DLLs. +//! +//! Before reading its first frame the worker finishes the job, in this order, +//! and refuses to run (exit 70, the reason on stderr) at the first failure: +//! +//! 1. it drops the start-up token and checks the thread has none left +//! (`thread-token-present`); +//! 2. it lowers its primary token from Low to Untrusted integrity and closes +//! the handle it used (`integrity-lower-failed`); +//! 3. it closes the connection to the session's client/server runtime port +//! and any device handle the loader left that it did not inherit; +//! 4. it reads its actual primary token back and checks every property +//! (`not-lpac`, `capabilities-present`, `restricting-sid-mismatch`, +//! `group-not-deny-only`, `privileges-present`, `integrity-not-untrusted`); +//! 5. it reads its mitigation policies back (`mitigation-mismatch`); +//! 6. it checks its whole handle table against an allowlist +//! (`handle-not-allowed`). +//! +//! The checks themselves ([`attest`], [`mitigation`], [`allowlist`]) are pure +//! functions over what was read, so their accept and reject cases are tested +//! on every system. The reads and the startup sequence are Windows-only. + +pub mod allowlist; +pub mod attest; +pub mod mitigation; + +#[cfg(windows)] +mod native; +#[cfg(windows)] +mod startup; + +#[cfg(windows)] +pub use native::PackageSid; +#[cfg(windows)] +pub use startup::enter; + +/// The reason tokens the worker exits with. Each names the check that +/// failed; tests and the parent match on them. +pub mod reason { + /// No `--package-sid=` argument was given. + pub const PACKAGE_SID_ARGUMENT_MISSING: &str = "package-sid-argument-missing"; + /// The main thread still has an impersonation token after reverting. + pub const THREAD_TOKEN_PRESENT: &str = "thread-token-present"; + /// The primary token could not be lowered to Untrusted integrity, or the + /// handle used for it could not be closed. + pub const INTEGRITY_LOWER_FAILED: &str = "integrity-lower-failed"; + /// The primary is not a Less Privileged AppContainer token of the + /// expected package. + pub const NOT_LPAC: &str = "not-lpac"; + /// The primary holds a capability. + pub const CAPABILITIES_PRESENT: &str = "capabilities-present"; + /// The primary's restricting SIDs are not exactly the NULL SID. + pub const RESTRICTING_SID_MISMATCH: &str = "restricting-sid-mismatch"; + /// An access group of the primary is not deny-only. + pub const GROUP_NOT_DENY_ONLY: &str = "group-not-deny-only"; + /// The primary holds a privilege. + pub const PRIVILEGES_PRESENT: &str = "privileges-present"; + /// The primary's integrity is not Untrusted. + pub const INTEGRITY_NOT_UNTRUSTED: &str = "integrity-not-untrusted"; + /// A required mitigation policy is off, or a forbidden opt-out is on. + pub const MITIGATION_MISMATCH: &str = "mitigation-mismatch"; + /// The handle table holds a handle outside the allowlist, or the table + /// could not be read or cleaned. + pub const HANDLE_NOT_ALLOWED: &str = "handle-not-allowed"; +} + +/// A failed check: its reason token and what was found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refusal { + pub reason: &'static str, + pub detail: String, +} + +impl Refusal { + pub fn new(reason: &'static str, detail: impl Into) -> Self { + Self { + reason, + detail: detail.into(), + } + } +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.reason, self.detail) + } +} + +/// The package SID argument the launcher passes on every Windows spawn. +pub const PACKAGE_SID_ARGUMENT: &str = "--package-sid="; + +/// The argument basal-launch passes for a worker check it cannot break from +/// the parent. Only a worker built with the `deviations` feature accepts it. +pub const DEVIATION_ARGUMENT: &str = "--confinement-deviation="; + +/// A startup check the parent asks a test worker to break. +#[cfg(feature = "deviations")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Deviation { + /// Keep the start-up thread token: step 1 must refuse. + ThreadTokenPresent, + /// Open the primary without the right to adjust it, so lowering fails: + /// step 2 must refuse. + IntegrityLowerFailed, + /// Skip lowering the primary: step 4 must refuse it as still Low. + IntegrityNotUntrusted, +} + +#[cfg(feature = "deviations")] +impl Deviation { + fn parse(name: &str) -> Option { + match name { + "thread-token-present" => Some(Self::ThreadTokenPresent), + "integrity-lower-failed" => Some(Self::IntegrityLowerFailed), + "integrity-not-untrusted" => Some(Self::IntegrityNotUntrusted), + _ => None, + } + } +} + +/// The engine-mode command line on Windows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + /// The package SID, as text. It is parsed with the system's SID parser + /// before confinement starts. + pub package_sid: String, + /// The startup check to break, in a test worker. + #[cfg(feature = "deviations")] + pub deviation: Option, +} + +impl Arguments { + /// Whether step 1 leaves the start-up token in place. + #[cfg(windows)] + fn keeps_thread_token(&self) -> bool { + #[cfg(feature = "deviations")] + if self.deviation == Some(Deviation::ThreadTokenPresent) { + return true; + } + false + } + + /// Whether step 2 opens the primary without the right to adjust it. + #[cfg(windows)] + fn opens_primary_read_only(&self) -> bool { + #[cfg(feature = "deviations")] + if self.deviation == Some(Deviation::IntegrityLowerFailed) { + return true; + } + false + } + + /// Whether step 2 leaves the primary at Low, the integrity the process + /// was created with. + #[cfg(windows)] + fn skips_lowering(&self) -> bool { + #[cfg(feature = "deviations")] + if self.deviation == Some(Deviation::IntegrityNotUntrusted) { + return true; + } + false + } +} + +/// Why the engine-mode command line was not accepted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArgumentError { + /// No `--package-sid=` argument: exit 70 `package-sid-argument-missing`. + Missing, + /// An unknown, repeated or malformed argument: exit 64. + Usage(String), +} + +/// Parses the engine-mode arguments: exactly one `--package-sid=`, and +/// in a test worker at most one `--confinement-deviation=`. Anything +/// else is a usage error, checked before a missing SID is reported. +pub fn engine_arguments(args: &[String]) -> Result { + let mut package_sid: Option<&str> = None; + #[cfg(feature = "deviations")] + let mut deviation: Option = None; + for arg in args { + if let Some(sid) = arg.strip_prefix(PACKAGE_SID_ARGUMENT) { + if package_sid.replace(sid).is_some() { + return Err(ArgumentError::Usage(format!("repeated argument {arg}"))); + } + continue; + } + #[cfg(feature = "deviations")] + if let Some(name) = arg.strip_prefix(DEVIATION_ARGUMENT) { + let parsed = Deviation::parse(name) + .ok_or_else(|| ArgumentError::Usage(format!("unknown deviation {name}")))?; + if deviation.replace(parsed).is_some() { + return Err(ArgumentError::Usage(format!("repeated argument {arg}"))); + } + continue; + } + return Err(ArgumentError::Usage(format!("unknown argument {arg}"))); + } + let package_sid = package_sid.ok_or(ArgumentError::Missing)?; + if package_sid.is_empty() { + return Err(ArgumentError::Usage("empty --package-sid".into())); + } + Ok(Arguments { + package_sid: package_sid.to_owned(), + #[cfg(feature = "deviations")] + deviation, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(list: &[&str]) -> Vec { + list.iter().map(|arg| (*arg).to_owned()).collect() + } + + const SID: &str = "--package-sid=S-1-15-2-1-2-3-4-5-6-7"; + + #[test] + fn exactly_one_package_sid_is_accepted() { + let parsed = engine_arguments(&args(&[SID])).expect("one package SID"); + assert_eq!(parsed.package_sid, "S-1-15-2-1-2-3-4-5-6-7"); + } + + #[test] + fn a_missing_package_sid_is_its_own_error() { + assert_eq!(engine_arguments(&[]), Err(ArgumentError::Missing)); + } + + #[test] + fn other_command_lines_are_usage_errors() { + for line in [ + &[SID, SID][..], + &["--package-sid="], + &[SID, "--landlock=required"], + &["--bogus"], + &["--bogus", SID], + &["--package-sid"], + ] { + assert!( + matches!(engine_arguments(&args(line)), Err(ArgumentError::Usage(_))), + "{line:?}" + ); + } + } + + /// A worker built without the `deviations` feature has no way to skip a + /// startup check: the request is an unknown argument like any other. + #[cfg(not(feature = "deviations"))] + #[test] + fn a_production_worker_refuses_every_deviation_request() { + for name in [ + "thread-token-present", + "integrity-lower-failed", + "integrity-not-untrusted", + ] { + let request = format!("{DEVIATION_ARGUMENT}{name}"); + assert_eq!( + engine_arguments(&args(&[SID, &request])), + Err(ArgumentError::Usage(format!("unknown argument {request}"))) + ); + } + } + + #[cfg(feature = "deviations")] + #[test] + fn a_test_worker_accepts_the_three_deviations_once() { + for (name, deviation) in [ + ("thread-token-present", Deviation::ThreadTokenPresent), + ("integrity-lower-failed", Deviation::IntegrityLowerFailed), + ("integrity-not-untrusted", Deviation::IntegrityNotUntrusted), + ] { + let request = format!("{DEVIATION_ARGUMENT}{name}"); + let parsed = engine_arguments(&args(&[SID, &request])).expect("accepted"); + assert_eq!(parsed.deviation, Some(deviation)); + assert!(engine_arguments(&args(&[SID, &request, &request])).is_err()); + } + let other = format!("{DEVIATION_ARGUMENT}not-lpac"); + assert!(engine_arguments(&args(&[SID, &other])).is_err()); + } +} diff --git a/crates/basal-worker/src/confinement/windows/allowlist.rs b/crates/basal-worker/src/confinement/windows/allowlist.rs new file mode 100644 index 0000000..e395810 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/allowlist.rs @@ -0,0 +1,472 @@ +//! The handle allowlist (startup step 6). +//! +//! Before it reads input the worker lists every handle in its own table and +//! accepts the table only if it fits one of two measured profiles, one per +//! supported Windows image. Some rows are exact: the three standard pipes and +//! the loader's read-only `\KnownDlls` directory must each be there. Every +//! other row is a ceiling on handles the system's own start-up code creates +//! for the process's thread pool, loader and crypto initialisation: unnamed, +//! private objects that give no file, network, process or persistent reach. +//! Anything else (a file that is not a standard pipe, a section, process, +//! thread, token, job, key, port, another directory, any inheritable handle +//! that is not a standard pipe) is refused. +//! +//! The table must fit a single profile on its own. The two profiles are never +//! combined, so a table holding Windows 11's SchedulerSharedData handle and +//! Server 2022's two CNG semaphores fits neither and is refused. + +use std::collections::BTreeMap; + +/// Which standard handle an entry is, by its handle value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stdio { + Input, + Output, + Error, +} + +/// One handle in the worker's table, as identified without dereferencing it: +/// its type comes from the table's type index and the system's type list, +/// and only the single Directory handle has its name queried. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandleEntry { + /// The handle value. + pub value: usize, + /// The object type name, or `unknown type `. + pub type_name: String, + /// The granted access mask. + pub access: u32, + /// Whether the handle is marked inheritable (`OBJ_INHERIT`). + pub inheritable: bool, + /// Which standard handle it is, if its value is one of them. + pub stdio: Option, + /// The object name, read for the Directory handle only. + pub name: Option, +} + +/// Read access to the stdin pipe, as `CreatePipe` grants it. +pub const STDIN_ACCESS: u32 = 0x0012_0189; +/// Write access to the stdout and stderr pipes, as `CreatePipe` grants it. +pub const STDOUT_ACCESS: u32 = 0x0012_0196; +/// Query and traverse on the `\KnownDlls` object directory. +pub const KNOWN_DLLS_ACCESS: u32 = 0x3; +/// The one object directory the loader keeps open. +pub const KNOWN_DLLS: &str = "\\KnownDlls"; + +/// At most `count` handles of this type with exactly this access. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Ceiling { + pub type_name: &'static str, + pub access: u32, + pub count: usize, +} + +/// The measured handle table of one Windows image. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Profile { + pub name: &'static str, + /// The ceiling rows. A row with count 0 is a type this image never has. + pub ceilings: [Ceiling; 7], + /// The largest number of handles in the whole table, the exact rows + /// included. + pub total: usize, +} + +const fn ceiling(type_name: &'static str, access: u32, count: usize) -> Ceiling { + Ceiling { + type_name, + access, + count, + } +} + +/// Windows 11 (the `windows-latest` runner image). +pub const WINDOWS_LATEST: Profile = Profile { + name: "windows-latest", + ceilings: [ + ceiling("Event", 0x001f_0003, 7), + ceiling("IoCompletion", 0x001f_0003, 2), + ceiling("TpWorkerFactory", 0x000f_00ff, 2), + ceiling("IRTimer", 0x0010_0002, 4), + ceiling("WaitCompletionPacket", 0x1, 6), + ceiling("Semaphore", 0x0010_0003, 0), + ceiling("SchedulerSharedData", 0x1, 1), + ], + total: 26, +}; + +/// Windows Server 2022 (the `windows-2022` runner image). +pub const WINDOWS_2022: Profile = Profile { + name: "windows-2022", + ceilings: [ + ceiling("Event", 0x001f_0003, 7), + ceiling("IoCompletion", 0x001f_0003, 2), + ceiling("TpWorkerFactory", 0x000f_00ff, 2), + ceiling("IRTimer", 0x0010_0002, 4), + ceiling("WaitCompletionPacket", 0x1, 5), + ceiling("Semaphore", 0x0010_0003, 2), + ceiling("SchedulerSharedData", 0x1, 0), + ], + total: 26, +}; + +/// Every profile a table may fit. +pub const PROFILES: [&Profile; 2] = [&WINDOWS_LATEST, &WINDOWS_2022]; + +/// Whether startup step 3 closes this handle before the allowlist runs. +/// +/// It closes the loader's connection to the session's client/server runtime +/// port (every ALPC Port) and every File handle that is neither a standard +/// handle nor inheritable, which on Server 2022 is the crypto driver device +/// the CNG start-up code opens. Only the three pipes are inherited; any other +/// inheritable handle came from the parent by mistake and is left open, so +/// that the allowlist refuses it instead of the worker hiding it. +pub fn closed_at_startup(type_name: &str, stdio: bool, inheritable: bool) -> bool { + match type_name { + "ALPC Port" => true, + "File" => !stdio && !inheritable, + _ => false, + } +} + +fn describe(entry: &HandleEntry) -> String { + format!( + "handle {:#x} {} access {:#x}{}{}", + entry.value, + entry.type_name, + entry.access, + if entry.inheritable { + " inheritable" + } else { + "" + }, + entry + .name + .as_deref() + .map(|name| format!(" named {name}")) + .unwrap_or_default() + ) +} + +/// Checks a handle table. Returns the profile it fits, or every reason it +/// fits none. +pub fn check(entries: &[HandleEntry]) -> Result<&'static Profile, String> { + let mut refused: Vec = Vec::new(); + let mut stdio = [0usize; 3]; + let mut known_dlls = 0usize; + // Handles counted against a ceiling row, by (type, access). + let mut counted: BTreeMap<(&str, u32), usize> = BTreeMap::new(); + for entry in entries { + if let Some(slot) = entry.stdio { + let (index, access) = match slot { + Stdio::Input => (0, STDIN_ACCESS), + Stdio::Output => (1, STDOUT_ACCESS), + Stdio::Error => (2, STDOUT_ACCESS), + }; + if entry.type_name == "File" && entry.access == access { + stdio[index] += 1; + } else { + refused.push(format!("standard {slot:?} is {}", describe(entry))); + } + continue; + } + if entry.inheritable { + refused.push(format!("inheritable {}", describe(entry))); + continue; + } + if entry.type_name == "Directory" { + if entry.name.as_deref() == Some(KNOWN_DLLS) && entry.access == KNOWN_DLLS_ACCESS { + known_dlls += 1; + } else { + refused.push(describe(entry)); + } + continue; + } + let row = PROFILES + .iter() + .flat_map(|profile| profile.ceilings.iter()) + .find(|row| row.type_name == entry.type_name.as_str() && row.access == entry.access); + match row { + Some(row) => *counted.entry((row.type_name, row.access)).or_default() += 1, + None => refused.push(describe(entry)), + } + } + for (index, name) in ["stdin", "stdout", "stderr"].into_iter().enumerate() { + if stdio[index] != 1 { + refused.push(format!("{} {name} pipe handles, expected 1", stdio[index])); + } + } + if known_dlls != 1 { + refused.push(format!( + "{known_dlls} {KNOWN_DLLS} directory handles, expected 1" + )); + } + if !refused.is_empty() { + return Err(refused.join("; ")); + } + + let mut misfits: Vec = Vec::new(); + for profile in PROFILES { + let mut over: Vec = Vec::new(); + for row in &profile.ceilings { + let count = counted + .get(&(row.type_name, row.access)) + .copied() + .unwrap_or(0); + if count > row.count { + over.push(format!( + "{count} {} {:#x}, at most {}", + row.type_name, row.access, row.count + )); + } + } + if entries.len() > profile.total { + over.push(format!( + "{} handles, at most {}", + entries.len(), + profile.total + )); + } + if over.is_empty() { + return Ok(profile); + } + misfits.push(format!("not {}: {}", profile.name, over.join(", "))); + } + Err(misfits.join("; ")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(type_name: &str, access: u32) -> HandleEntry { + HandleEntry { + value: 0, + type_name: type_name.into(), + access, + inheritable: false, + stdio: None, + name: None, + } + } + + fn rows(type_name: &str, access: u32, count: usize) -> Vec { + (0..count).map(|_| entry(type_name, access)).collect() + } + + /// The exact rows: the three inherited pipes and `\KnownDlls`. + fn exact() -> Vec { + let pipe = |stdio, access| HandleEntry { + inheritable: true, + stdio: Some(stdio), + ..entry("File", access) + }; + vec![ + pipe(Stdio::Input, STDIN_ACCESS), + pipe(Stdio::Output, STDOUT_ACCESS), + pipe(Stdio::Error, STDOUT_ACCESS), + HandleEntry { + name: Some(KNOWN_DLLS.into()), + ..entry("Directory", KNOWN_DLLS_ACCESS) + }, + ] + } + + /// The 26-handle table measured on Windows 11. + fn latest() -> Vec { + let mut table = exact(); + table.extend(rows("Event", 0x1f0003, 7)); + table.extend(rows("IoCompletion", 0x1f0003, 2)); + table.extend(rows("TpWorkerFactory", 0xf00ff, 2)); + table.extend(rows("IRTimer", 0x100002, 4)); + table.extend(rows("WaitCompletionPacket", 0x1, 6)); + table.extend(rows("SchedulerSharedData", 0x1, 1)); + table + } + + /// The 26-handle table measured on Windows Server 2022. + fn server_2022() -> Vec { + let mut table = exact(); + table.extend(rows("Event", 0x1f0003, 7)); + table.extend(rows("IoCompletion", 0x1f0003, 2)); + table.extend(rows("TpWorkerFactory", 0xf00ff, 2)); + table.extend(rows("IRTimer", 0x100002, 4)); + table.extend(rows("WaitCompletionPacket", 0x1, 5)); + table.extend(rows("Semaphore", 0x100003, 2)); + table + } + + #[test] + fn each_images_measured_table_fits_its_own_profile() { + assert_eq!(latest().len(), 26); + assert_eq!(check(&latest()), Ok(&WINDOWS_LATEST)); + assert_eq!(server_2022().len(), 26); + assert_eq!(check(&server_2022()), Ok(&WINDOWS_2022)); + } + + #[test] + fn a_smaller_table_fits() { + assert_eq!(check(&exact()), Ok(&WINDOWS_LATEST)); + let mut table = exact(); + table.extend(rows("Semaphore", 0x100003, 1)); + assert_eq!(check(&table), Ok(&WINDOWS_2022)); + } + + /// Windows 11's SchedulerSharedData handle together with Server 2022's + /// semaphores: 28 handles that fit neither profile alone, and would pass + /// only if the two profiles were combined. + #[test] + fn the_mixed_table_fits_no_profile() { + let mut mixed = latest(); + mixed.extend(rows("Semaphore", 0x100003, 2)); + assert_eq!(mixed.len(), 28); + let error = check(&mixed).expect_err("the mixed table must be refused"); + assert!(error.contains("not windows-latest: 2 Semaphore"), "{error}"); + assert!( + error.contains("1 SchedulerSharedData 0x1, at most 0"), + "{error}" + ); + // Even within the total, one row of each image is refused. + let mut small = exact(); + small.extend(rows("Semaphore", 0x100003, 1)); + small.extend(rows("SchedulerSharedData", 0x1, 1)); + assert!(check(&small).is_err()); + } + + #[test] + fn an_excess_count_is_refused() { + let mut table = latest(); + table.pop(); + table.push(entry("Event", 0x1f0003)); + assert_eq!(table.len(), 26); + let error = check(&table).expect_err("eight events"); + assert!(error.contains("8 Event 0x1f0003, at most 7"), "{error}"); + + let mut table = server_2022(); + table.push(entry("WaitCompletionPacket", 0x1)); + assert!( + check(&table).is_err(), + "six wait packets on 2022 and 27 in all" + ); + } + + #[test] + fn a_table_over_the_total_is_refused() { + // Every ceiling of windows-latest filled, plus one Semaphore that only + // windows-2022 allows: 27 handles. + let mut table = latest(); + table.extend(rows("Semaphore", 0x100003, 1)); + let error = check(&table).expect_err("27 handles"); + assert!(error.contains("27 handles, at most 26"), "{error}"); + } + + #[test] + fn startup_closes_ports_and_private_files_only() { + assert!(closed_at_startup("ALPC Port", false, false)); + assert!(closed_at_startup("File", false, false)); + // The standard pipes stay, and so does an inheritable file, which the + // allowlist must see and refuse. + assert!(!closed_at_startup("File", true, true)); + assert!(!closed_at_startup("File", false, true)); + for kept in [ + "Directory", + "Event", + "IoCompletion", + "TpWorkerFactory", + "IRTimer", + "WaitCompletionPacket", + "Semaphore", + "SchedulerSharedData", + "Key", + ] { + assert!(!closed_at_startup(kept, false, false), "{kept}"); + } + } + + #[test] + fn an_unknown_row_is_refused() { + for (type_name, access) in [ + ("Key", 0x20019), + ("Section", 0x4), + ("Process", 0x1000), + ("Thread", 0x1fffff), + ("Token", 0x8), + ("Job", 0x1f001f), + ("ALPC Port", 0x1f0001), + ("File", 0x100003), + ("Mutant", 0x1f0001), + ("unknown type 99", 0x1), + // A known type with an access mask the table does not list. + ("Event", 0x1f0001), + ("TpWorkerFactory", 0xf003f), + ] { + let mut table = exact(); + table.push(entry(type_name, access)); + let error = check(&table).expect_err(type_name); + assert!(error.contains(type_name), "{error}"); + } + } + + #[test] + fn a_missing_pipe_or_known_dlls_is_refused() { + for missing in 0..4 { + let mut table = latest(); + table.remove(missing); + assert!(check(&table).is_err(), "row {missing} missing"); + } + // A duplicate stdin pipe is not a second accepted pipe. + let mut table = latest(); + table.push(exact().remove(0)); + assert!(check(&table).is_err()); + } + + #[test] + fn a_standard_handle_must_be_its_own_pipe() { + // stdout with read access, stdin with write access. + let mut table = exact(); + table[0].access = STDOUT_ACCESS; + assert!(check(&table).is_err()); + let mut table = exact(); + table[1].access = STDIN_ACCESS; + assert!(check(&table).is_err()); + // A standard handle that is not a File. + let mut table = exact(); + table[2].type_name = "Event".into(); + assert!(check(&table).is_err()); + } + + #[test] + fn an_inheritable_handle_that_is_not_stdio_is_refused() { + let mut table = exact(); + table.push(HandleEntry { + inheritable: true, + ..entry("Event", 0x1f0003) + }); + let error = check(&table).expect_err("inheritable event"); + assert!(error.contains("inheritable"), "{error}"); + // A file handle the parent leaked, with pipe access but not one of + // the standard handles. + let mut table = exact(); + table.push(HandleEntry { + inheritable: true, + ..entry("File", STDOUT_ACCESS) + }); + assert!(check(&table).is_err()); + } + + #[test] + fn only_the_known_dlls_directory_is_allowed() { + let mut table = exact(); + table[3].name = Some("\\Sessions\\1\\BaseNamedObjects".into()); + assert!(check(&table).is_err()); + let mut table = exact(); + table[3].name = None; + assert!(check(&table).is_err()); + let mut table = exact(); + table[3].access = 0xf; + assert!(check(&table).is_err()); + let mut table = exact(); + table.push(table[3].clone()); + assert!(check(&table).is_err(), "two directory handles"); + } +} diff --git a/crates/basal-worker/src/confinement/windows/attest.rs b/crates/basal-worker/src/confinement/windows/attest.rs new file mode 100644 index 0000000..c5ff246 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/attest.rs @@ -0,0 +1,321 @@ +//! The check of the worker's actual primary token (startup step 4). +//! +//! The parent builds the primary and checks it on the suspended process, but +//! the system changes a token at creation (process creation makes an +//! AppContainer primary Low whatever integrity was supplied), and the worker +//! lowers its own integrity after load. So the worker reads its token back +//! once more, after every change, and judges what it reads rather than what +//! anyone meant to build. +//! +//! The readers report each property separately, and a property that could +//! not be read fails the check that needed it. The checks run in a fixed +//! order and the first failure is the reason. + +use super::{Refusal, reason}; + +/// The NULL SID, the only restricting SID the primary may have. +pub const NULL_SID: &str = "S-1-0-0"; +/// The Untrusted mandatory label the worker lowers itself to. +pub const UNTRUSTED_INTEGRITY: &str = "S-1-16-0"; +/// The claim that marks a Less Privileged AppContainer token. +pub const LPAC_CLAIM: &str = "WIN://NOALLAPPPKG"; +/// `SE_GROUP_INTEGRITY`: marks the integrity label, which is not an access +/// group. +pub const SE_GROUP_INTEGRITY: u32 = 0x20; +/// `SE_GROUP_USE_FOR_DENY_ONLY`: the group matches only deny entries. +pub const SE_GROUP_USE_FOR_DENY_ONLY: u32 = 0x10; + +/// The `WIN://NOALLAPPPKG` security attribute as read from the token. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Claim { + /// The token has no attribute of that name. + Absent, + /// The attribute holds unsigned 64-bit values. + Unsigned(Vec), + /// The attribute holds values of another type, by its type number. + OtherType(u16), +} + +/// One access group: its SID and attribute flags. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Group { + pub sid: String, + pub attributes: u32, +} + +/// What was read from the primary. Each field is the property, or why it +/// could not be read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrimaryFacts { + pub lpac_claim: Result, + /// The token's AppContainer SID, if it is an AppContainer token, and + /// whether it is equal (`EqualSid`) to the `--package-sid` argument. + pub package: Result, String>, + pub capabilities: Result, String>, + pub restricting_sids: Result, String>, + pub groups: Result, String>, + pub privileges: Result, + pub integrity: Result, +} + +/// The property, or the named refusal of the check that could not read it. +fn read<'a, T>(reason: &'static str, result: &'a Result) -> Result<&'a T, Refusal> { + result + .as_ref() + .map_err(|error| Refusal::new(reason, format!("could not read: {error}"))) +} + +/// Runs the six token checks in order and returns the first failure. +pub fn check(facts: &PrimaryFacts) -> Result<(), Refusal> { + let claim = read(reason::NOT_LPAC, &facts.lpac_claim)?; + if !matches!(claim, Claim::Unsigned(values) if values.as_slice() == [1]) { + return Err(Refusal::new( + reason::NOT_LPAC, + format!("{LPAC_CLAIM} is {claim:?}, not [1]"), + )); + } + match read(reason::NOT_LPAC, &facts.package)? { + Some((_, true)) => {} + Some((sid, false)) => { + return Err(Refusal::new( + reason::NOT_LPAC, + format!("AppContainer SID {sid} is not the --package-sid"), + )); + } + None => return Err(Refusal::new(reason::NOT_LPAC, "not an AppContainer token")), + } + + let capabilities = read(reason::CAPABILITIES_PRESENT, &facts.capabilities)?; + if !capabilities.is_empty() { + return Err(Refusal::new( + reason::CAPABILITIES_PRESENT, + format!("capabilities {capabilities:?}"), + )); + } + + let sids = read(reason::RESTRICTING_SID_MISMATCH, &facts.restricting_sids)?; + if sids.as_slice() != [NULL_SID] { + return Err(Refusal::new( + reason::RESTRICTING_SID_MISMATCH, + format!("restricting SIDs {sids:?}, not [{NULL_SID}]"), + )); + } + + let groups = read(reason::GROUP_NOT_DENY_ONLY, &facts.groups)?; + let enabled: Vec<&str> = groups + .iter() + .filter(|group| group.attributes & SE_GROUP_INTEGRITY == 0) + .filter(|group| group.attributes & SE_GROUP_USE_FOR_DENY_ONLY == 0) + .map(|group| group.sid.as_str()) + .collect(); + if !enabled.is_empty() { + return Err(Refusal::new( + reason::GROUP_NOT_DENY_ONLY, + format!("groups not deny-only {enabled:?}"), + )); + } + + let privileges = *read(reason::PRIVILEGES_PRESENT, &facts.privileges)?; + if privileges != 0 { + return Err(Refusal::new( + reason::PRIVILEGES_PRESENT, + format!("{privileges} privileges"), + )); + } + + let integrity = read(reason::INTEGRITY_NOT_UNTRUSTED, &facts.integrity)?; + if integrity != UNTRUSTED_INTEGRITY { + return Err(Refusal::new( + reason::INTEGRITY_NOT_UNTRUSTED, + format!("integrity {integrity}, not {UNTRUSTED_INTEGRITY}"), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PACKAGE: &str = "S-1-15-2-1-2-3-4-5-6-7"; + + /// The primary measured in a fully confined worker after it lowered + /// itself: deny-only groups (logon included) plus the integrity label. + fn confined() -> PrimaryFacts { + PrimaryFacts { + lpac_claim: Ok(Claim::Unsigned(vec![1])), + package: Ok(Some((PACKAGE.into(), true))), + capabilities: Ok(vec![]), + restricting_sids: Ok(vec![NULL_SID.into()]), + groups: Ok(vec![ + Group { + sid: "S-1-1-0".into(), + attributes: SE_GROUP_USE_FOR_DENY_ONLY, + }, + Group { + sid: "S-1-5-5-0-1".into(), + attributes: SE_GROUP_USE_FOR_DENY_ONLY | 0xc000_0000, + }, + Group { + sid: UNTRUSTED_INTEGRITY.into(), + attributes: SE_GROUP_INTEGRITY | 0x40, + }, + ]), + privileges: Ok(0), + integrity: Ok(UNTRUSTED_INTEGRITY.into()), + } + } + + fn reason_of(facts: &PrimaryFacts) -> &'static str { + check(facts).expect_err("must be refused").reason + } + + #[test] + fn the_confined_primary_passes() { + assert_eq!(check(&confined()), Ok(())); + // No group count is pinned: a token with fewer groups passes too. + let mut fewer = confined(); + fewer.groups = Ok(vec![]); + assert_eq!(check(&fewer), Ok(())); + } + + /// One way to break the confined primary, and the reason it must get. + type Case = (&'static str, Box); + + #[test] + fn each_broken_property_is_refused_with_its_reason() { + let cases: Vec = vec![ + ( + reason::NOT_LPAC, + Box::new(|f| f.lpac_claim = Ok(Claim::Absent)), + ), + ( + reason::NOT_LPAC, + Box::new(|f| f.lpac_claim = Ok(Claim::Unsigned(vec![0]))), + ), + ( + reason::NOT_LPAC, + Box::new(|f| f.lpac_claim = Ok(Claim::Unsigned(vec![1, 1]))), + ), + ( + reason::NOT_LPAC, + Box::new(|f| f.lpac_claim = Ok(Claim::OtherType(1))), + ), + ( + reason::NOT_LPAC, + Box::new(|f| f.package = Ok(Some(("S-1-15-2-9".into(), false)))), + ), + (reason::NOT_LPAC, Box::new(|f| f.package = Ok(None))), + ( + reason::CAPABILITIES_PRESENT, + Box::new(|f| f.capabilities = Ok(vec!["S-1-15-3-1".into()])), + ), + ( + reason::RESTRICTING_SID_MISMATCH, + Box::new(|f| f.restricting_sids = Ok(vec![])), + ), + ( + reason::RESTRICTING_SID_MISMATCH, + Box::new(|f| f.restricting_sids = Ok(vec![NULL_SID.into(), "S-1-1-0".into()])), + ), + ( + reason::RESTRICTING_SID_MISMATCH, + Box::new(|f| f.restricting_sids = Ok(vec!["S-1-1-0".into()])), + ), + ( + reason::GROUP_NOT_DENY_ONLY, + Box::new(|f| { + f.groups.as_mut().unwrap().push(Group { + sid: "S-1-5-5-0-1".into(), + attributes: 0xc000_0007, + }) + }), + ), + ( + reason::PRIVILEGES_PRESENT, + Box::new(|f| f.privileges = Ok(1)), + ), + ( + reason::INTEGRITY_NOT_UNTRUSTED, + Box::new(|f| f.integrity = Ok("S-1-16-4096".into())), + ), + ]; + for (expected, breaks) in cases { + let mut facts = confined(); + breaks(&mut facts); + assert_eq!(reason_of(&facts), expected, "{facts:?}"); + } + } + + #[test] + fn an_unreadable_property_fails_the_check_that_needs_it() { + fn error() -> Result { + Err("Win32 5".to_owned()) + } + let mut facts = confined(); + facts.lpac_claim = error(); + assert_eq!(reason_of(&facts), reason::NOT_LPAC); + let mut facts = confined(); + facts.package = error(); + assert_eq!(reason_of(&facts), reason::NOT_LPAC); + let mut facts = confined(); + facts.capabilities = error(); + assert_eq!(reason_of(&facts), reason::CAPABILITIES_PRESENT); + let mut facts = confined(); + facts.restricting_sids = error(); + assert_eq!(reason_of(&facts), reason::RESTRICTING_SID_MISMATCH); + let mut facts = confined(); + facts.groups = error(); + assert_eq!(reason_of(&facts), reason::GROUP_NOT_DENY_ONLY); + let mut facts = confined(); + facts.privileges = error(); + assert_eq!(reason_of(&facts), reason::PRIVILEGES_PRESENT); + let mut facts = confined(); + facts.integrity = error(); + assert_eq!(reason_of(&facts), reason::INTEGRITY_NOT_UNTRUSTED); + } + + /// The checks run in the spec's order: with every property broken, the + /// first one is reported, and fixing it reveals the next. + #[test] + fn the_first_failing_check_in_order_is_reported() { + let mut facts = PrimaryFacts { + lpac_claim: Ok(Claim::Absent), + package: Ok(None), + capabilities: Ok(vec!["S-1-15-3-1".into()]), + restricting_sids: Ok(vec![]), + groups: Ok(vec![Group { + sid: "S-1-1-0".into(), + attributes: 7, + }]), + privileges: Ok(3), + integrity: Ok("S-1-16-4096".into()), + }; + let fixed = confined(); + let order = [ + reason::NOT_LPAC, + reason::CAPABILITIES_PRESENT, + reason::RESTRICTING_SID_MISMATCH, + reason::GROUP_NOT_DENY_ONLY, + reason::PRIVILEGES_PRESENT, + reason::INTEGRITY_NOT_UNTRUSTED, + ]; + for expected in order { + assert_eq!(reason_of(&facts), expected); + match expected { + reason::NOT_LPAC => { + facts.lpac_claim = fixed.lpac_claim.clone(); + facts.package = fixed.package.clone(); + } + reason::CAPABILITIES_PRESENT => facts.capabilities = fixed.capabilities.clone(), + reason::RESTRICTING_SID_MISMATCH => { + facts.restricting_sids = fixed.restricting_sids.clone() + } + reason::GROUP_NOT_DENY_ONLY => facts.groups = fixed.groups.clone(), + reason::PRIVILEGES_PRESENT => facts.privileges = fixed.privileges.clone(), + _ => facts.integrity = fixed.integrity.clone(), + } + } + assert_eq!(check(&facts), Ok(())); + } +} diff --git a/crates/basal-worker/src/confinement/windows/mitigation.rs b/crates/basal-worker/src/confinement/windows/mitigation.rs new file mode 100644 index 0000000..ca1d974 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/mitigation.rs @@ -0,0 +1,226 @@ +//! The mitigation policies the worker requires of its own process. +//! +//! The parent sets them as a creation attribute; the worker reads each policy +//! word back with `GetProcessMitigationPolicy` and checks single bits. Only +//! the listed bits are judged. Whole words are never compared: they differ +//! between Windows releases (Windows 11 reports an extra Win32k bit that +//! Server 2022 does not), and a bit added by a later release must not make a +//! correctly confined worker refuse to start. + +/// One policy word per mitigation policy the worker reads. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct MitigationWords { + /// `PROCESS_MITIGATION_DYNAMIC_CODE_POLICY`. + pub dynamic_code: u32, + /// `PROCESS_MITIGATION_BINARY_SIGNATURE_POLICY`. + pub signature: u32, + /// `PROCESS_MITIGATION_IMAGE_LOAD_POLICY`. + pub image_load: u32, + /// `PROCESS_MITIGATION_SYSTEM_CALL_DISABLE_POLICY`. + pub system_call: u32, + /// `PROCESS_MITIGATION_STRICT_HANDLE_CHECK_POLICY`. + pub strict_handle: u32, + /// `PROCESS_MITIGATION_EXTENSION_POINT_DISABLE_POLICY`. + pub extension_point: u32, + /// `PROCESS_MITIGATION_CHILD_PROCESS_POLICY`. + pub child_process: u32, +} + +/// Which policy word a bit lives in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Policy { + DynamicCode, + Signature, + ImageLoad, + SystemCall, + StrictHandle, + ExtensionPoint, + ChildProcess, +} + +impl Policy { + fn word(self, words: &MitigationWords) -> u32 { + match self { + Self::DynamicCode => words.dynamic_code, + Self::Signature => words.signature, + Self::ImageLoad => words.image_load, + Self::SystemCall => words.system_call, + Self::StrictHandle => words.strict_handle, + Self::ExtensionPoint => words.extension_point, + Self::ChildProcess => words.child_process, + } + } +} + +/// One named bit of a policy word. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bit { + pub policy: Policy, + pub mask: u32, + pub name: &'static str, +} + +const fn bit(policy: Policy, index: u32, name: &'static str) -> Bit { + Bit { + policy, + mask: 1 << index, + name, + } +} + +/// The bits that must be set. The positions are those of the bit fields in +/// the `PROCESS_MITIGATION_*_POLICY` structures. +pub const REQUIRED: [Bit; 10] = [ + // No executable memory can be created, or made executable, after load. + bit(Policy::DynamicCode, 0, "ProhibitDynamicCode"), + // Only Microsoft-signed DLLs load. + bit(Policy::Signature, 0, "MicrosoftSignedOnly"), + bit(Policy::ImageLoad, 0, "NoRemoteImages"), + bit(Policy::ImageLoad, 1, "NoLowMandatoryLabelImages"), + bit(Policy::ImageLoad, 2, "PreferSystem32Images"), + // No system calls into the GUI kernel component. + bit(Policy::SystemCall, 0, "DisallowWin32kSystemCalls"), + // Using an invalid handle raises an exception, and that cannot be undone. + bit( + Policy::StrictHandle, + 0, + "RaiseExceptionOnInvalidHandleReference", + ), + bit( + Policy::StrictHandle, + 1, + "HandleExceptionsPermanentlyEnabled", + ), + // No legacy extension points (AppInit DLLs, hooks and the like). + bit(Policy::ExtensionPoint, 0, "DisableExtensionPoints"), + bit(Policy::ChildProcess, 0, "NoChildProcessCreation"), +]; + +/// The bits that must be clear: each lets code in the process turn the +/// dynamic-code prohibition off again, for one thread or from outside. +pub const FORBIDDEN: [Bit; 2] = [ + bit(Policy::DynamicCode, 1, "AllowThreadOptOut"), + bit(Policy::DynamicCode, 2, "AllowRemoteDowngrade"), +]; + +/// Checks the words read back: every required bit set and every forbidden +/// bit clear. Any other bit is ignored. The error names every bit that is +/// wrong. +pub fn validate(words: &MitigationWords) -> Result<(), String> { + let mut wrong: Vec = Vec::new(); + for required in REQUIRED { + if required.policy.word(words) & required.mask == 0 { + wrong.push(format!("{} is off", required.name)); + } + } + for forbidden in FORBIDDEN { + if forbidden.policy.word(words) & forbidden.mask != 0 { + wrong.push(format!("{} is on", forbidden.name)); + } + } + if wrong.is_empty() { + Ok(()) + } else { + Err(format!("{} (read back {words:?})", wrong.join(", "))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The words measured in a fully confined child. Windows 11 reports + /// Win32k word 5 and Server 2022 word 1; both must pass. + fn measured(system_call: u32) -> MitigationWords { + MitigationWords { + dynamic_code: 1, + signature: 5, + image_load: 7, + system_call, + strict_handle: 3, + extension_point: 1, + child_process: 1, + } + } + + fn set(words: &mut MitigationWords, bit: Bit, on: bool) { + let word = match bit.policy { + Policy::DynamicCode => &mut words.dynamic_code, + Policy::Signature => &mut words.signature, + Policy::ImageLoad => &mut words.image_load, + Policy::SystemCall => &mut words.system_call, + Policy::StrictHandle => &mut words.strict_handle, + Policy::ExtensionPoint => &mut words.extension_point, + Policy::ChildProcess => &mut words.child_process, + }; + if on { + *word |= bit.mask; + } else { + *word &= !bit.mask; + } + } + + #[test] + fn the_measured_words_of_both_images_pass() { + assert_eq!(validate(&measured(5)), Ok(())); + assert_eq!(validate(&measured(1)), Ok(())); + } + + #[test] + fn exactly_the_required_bits_pass_and_other_bits_are_ignored() { + let mut words = MitigationWords::default(); + for required in REQUIRED { + set(&mut words, required, true); + } + assert_eq!(validate(&words), Ok(())); + // Bits nobody listed (audit modes, store signing, later additions) + // change nothing, in any word but the dynamic-code one, whose other + // bits include the forbidden opt-outs. + let mut noisy = words; + noisy.signature |= !1; + noisy.image_load |= !7; + noisy.system_call |= !1; + noisy.strict_handle |= !3; + noisy.extension_point |= !1; + noisy.child_process |= !1; + noisy.dynamic_code |= 1 << 3; + assert_eq!(validate(&noisy), Ok(())); + } + + #[test] + fn each_missing_required_bit_is_refused_by_name() { + for required in REQUIRED { + let mut words = measured(5); + set(&mut words, required, false); + let error = validate(&words).expect_err(required.name); + assert!( + error.starts_with(&format!("{} is off", required.name)), + "{error}" + ); + } + assert!(validate(&MitigationWords::default()).is_err()); + } + + /// Every required bit set does not excuse an opt-out: each forbidden bit + /// alone, together with all the required ones, is refused. + #[test] + fn every_required_bit_with_a_forbidden_opt_out_is_refused() { + let mut all_required = MitigationWords::default(); + for required in REQUIRED { + set(&mut all_required, required, true); + } + for forbidden in FORBIDDEN { + let mut words = all_required; + set(&mut words, forbidden, true); + let error = validate(&words).expect_err(forbidden.name); + assert!( + error.starts_with(&format!("{} is on", forbidden.name)), + "{error}" + ); + } + assert_eq!(FORBIDDEN[0].name, "AllowThreadOptOut"); + assert_eq!(FORBIDDEN[0].mask, 0x2); + assert_eq!(FORBIDDEN[1].name, "AllowRemoteDowngrade"); + assert_eq!(FORBIDDEN[1].mask, 0x4); + } +} diff --git a/crates/basal-worker/src/confinement/windows/native.rs b/crates/basal-worker/src/confinement/windows/native.rs new file mode 100644 index 0000000..1f75506 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/native.rs @@ -0,0 +1,623 @@ +//! The Win32 and native calls behind the worker's startup checks. +//! +//! Each reader reports what it found and leaves judging it to the pure +//! checks in [`super::attest`], [`super::mitigation`] and +//! [`super::allowlist`]. Native structure layouts are those of 64-bit +//! Windows, the only Windows target basal builds for. + +use super::allowlist::{HandleEntry, Stdio}; +use super::attest::{Claim, Group, LPAC_CLAIM, PrimaryFacts}; +use super::mitigation::MitigationWords; +use std::collections::BTreeMap; +use std::ffi::c_void; +use std::mem::size_of; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::{ + CloseHandle, DUPLICATE_SAME_ACCESS, DuplicateHandle, GetLastError, HANDLE, LocalFree, +}; +use windows_sys::Win32::Security::Authorization::{ConvertSidToStringSidW, ConvertStringSidToSidW}; +use windows_sys::Win32::Security::{ + CreateWellKnownSid, EqualSid, GetLengthSid, GetTokenInformation, PSID, SID_AND_ATTRIBUTES, + SetTokenInformation, TOKEN_APPCONTAINER_INFORMATION, TOKEN_GROUPS, TOKEN_INFORMATION_CLASS, + TOKEN_MANDATORY_LABEL, TOKEN_PRIVILEGES, TokenAppContainerSid, TokenCapabilities, TokenGroups, + TokenIntegrityLevel, TokenIsAppContainer, TokenPrivileges, TokenRestrictedSids, + TokenSecurityAttributes, WinUntrustedLabelSid, +}; +use windows_sys::Win32::System::Console::{ + GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, +}; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, GetProcessMitigationPolicy, PROCESS_MITIGATION_POLICY, + ProcessChildProcessPolicy, ProcessDynamicCodePolicy, ProcessExtensionPointDisablePolicy, + ProcessImageLoadPolicy, ProcessSignaturePolicy, ProcessStrictHandleCheckPolicy, + ProcessSystemCallDisablePolicy, +}; + +pub(super) type Result = std::result::Result; + +/// The pseudo-handle for this process's primary token +/// (`GetCurrentProcessToken`). Querying through it opens no handle, so the +/// token check leaves nothing behind in the handle table. +pub(super) const CURRENT_PROCESS_TOKEN: HANDLE = -4isize as HANDLE; + +/// `SE_GROUP_INTEGRITY`, the attribute of the integrity label in a token's +/// group list. +const SE_GROUP_INTEGRITY: u32 = 0x20; +/// `OBJ_INHERIT` in a handle-table entry's attributes. +pub(super) const OBJ_INHERIT: u32 = 0x2; +/// `STATUS_INFO_LENGTH_MISMATCH`: the buffer was too small. +const STATUS_INFO_LENGTH_MISMATCH: i32 = 0xc000_0004_u32 as i32; +/// `STATUS_BUFFER_OVERFLOW`: the buffer was too small for the whole result. +const STATUS_BUFFER_OVERFLOW: i32 = 0x8000_0005_u32 as i32; +/// `ProcessHandleInformation`, the process's own handle table. +const PROCESS_HANDLE_INFORMATION: u32 = 51; +/// `ObjectNameInformation`. +const OBJECT_NAME_INFORMATION: u32 = 1; +/// `ObjectTypesInformation`: every object type and its index. +const OBJECT_TYPES_INFORMATION: u32 = 3; +/// `TOKEN_SECURITY_ATTRIBUTE_TYPE_UINT64`. +const ATTRIBUTE_TYPE_UINT64: u16 = 2; + +#[link(name = "ntdll", kind = "raw-dylib")] +unsafe extern "system" { + fn NtQueryInformationProcess( + process: HANDLE, + class: u32, + buffer: *mut c_void, + length: u32, + returned: *mut u32, + ) -> i32; + fn NtQueryObject( + handle: HANDLE, + class: u32, + buffer: *mut c_void, + length: u32, + returned: *mut u32, + ) -> i32; + fn NtQueryInformationToken( + token: HANDLE, + class: TOKEN_INFORMATION_CLASS, + buffer: *mut c_void, + length: u32, + returned: *mut u32, + ) -> i32; +} + +/// The calling thread's last Win32 error, named after the call that failed. +pub(super) fn last(api: &str) -> String { + let code = unsafe { GetLastError() }; + format!( + "{api}: Win32 {code} ({})", + std::io::Error::from_raw_os_error(code as i32) + ) +} + +fn status(api: &str, status: i32) -> String { + format!("{api}: NTSTATUS {:#010x}", status as u32) +} + +/// Closes a handle this process owns and reports a failure. +pub(super) fn close(handle: HANDLE, what: &str) -> Result<()> { + if unsafe { CloseHandle(handle) } == 0 { + Err(last(&format!("CloseHandle({what})"))) + } else { + Ok(()) + } +} + +/// The package SID from `--package-sid=`, parsed by the system's own SID +/// parser. +pub struct PackageSid(PSID); + +impl PackageSid { + /// Parses SID text. Fails for text the system does not accept as a SID. + pub fn parse(text: &str) -> Result { + let wide: Vec = text.encode_utf16().chain(Some(0)).collect(); + let mut sid: PSID = null_mut(); + if unsafe { ConvertStringSidToSidW(wide.as_ptr(), &mut sid) } == 0 { + return Err(last(&format!("ConvertStringSidToSidW({text})"))); + } + Ok(Self(sid)) + } +} + +impl Drop for PackageSid { + fn drop(&mut self) { + // The SID was allocated by ConvertStringSidToSidW with LocalAlloc. + unsafe { LocalFree(self.0) }; + } +} + +/// A SID as text. +/// +/// # Safety +/// +/// `sid` must point to a valid SID. +unsafe fn sid_string(sid: PSID) -> Result { + unsafe { + let mut text = null_mut(); + if ConvertSidToStringSidW(sid, &mut text) == 0 { + return Err(last("ConvertSidToStringSidW")); + } + let mut length = 0; + while *text.add(length) != 0 { + length += 1; + } + let result = String::from_utf16_lossy(std::slice::from_raw_parts(text, length)); + LocalFree(text.cast()); + Ok(result) + } +} + +/// Reads one variable-length token information class into an aligned +/// buffer. +fn token_buffer(token: HANDLE, class: TOKEN_INFORMATION_CLASS) -> Result> { + unsafe { + let mut bytes = 0; + GetTokenInformation(token, class, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(last(&format!("GetTokenInformation(class {class}, size)"))); + } + // A list with no entries can be shorter than the C declaration, which + // reserves one entry. Keep room for it so a reference to the + // declaration never reaches past the allocation. + let allocation = (bytes as usize) + .max(size_of::()) + .max(size_of::()); + let mut data = vec![0usize; allocation.div_ceil(size_of::())]; + if GetTokenInformation(token, class, data.as_mut_ptr().cast(), bytes, &mut bytes) == 0 { + return Err(last(&format!("GetTokenInformation(class {class})"))); + } + Ok(data) + } +} + +/// The entries of a `TOKEN_GROUPS` buffer. +/// +/// # Safety +/// +/// `data` must hold a `TOKEN_GROUPS` structure returned by the system. +unsafe fn groups(data: &[usize]) -> &[SID_AND_ATTRIBUTES] { + unsafe { + let list = &*data.as_ptr().cast::(); + std::slice::from_raw_parts(list.Groups.as_ptr(), list.GroupCount as usize) + } +} + +fn sid_list(token: HANDLE, class: TOKEN_INFORMATION_CLASS) -> Result> { + let data = token_buffer(token, class)?; + unsafe { groups(&data) } + .iter() + .map(|entry| unsafe { sid_string(entry.Sid) }) + .collect() +} + +/// Reads every property the token check judges, each on its own. +pub(super) fn primary_facts(token: HANDLE, package: &PackageSid) -> PrimaryFacts { + PrimaryFacts { + lpac_claim: lpac_claim(token), + package: appcontainer(token, package), + capabilities: sid_list(token, TokenCapabilities), + restricting_sids: sid_list(token, TokenRestrictedSids), + groups: token_buffer(token, TokenGroups).and_then(|data| { + unsafe { groups(&data) } + .iter() + .map(|group| { + Ok(Group { + sid: unsafe { sid_string(group.Sid) }?, + attributes: group.Attributes, + }) + }) + .collect() + }), + privileges: token_buffer(token, TokenPrivileges).map(|data| unsafe { + (*data.as_ptr().cast::()).PrivilegeCount as usize + }), + integrity: token_buffer(token, TokenIntegrityLevel).and_then(|data| unsafe { + sid_string((*data.as_ptr().cast::()).Label.Sid) + }), + } +} + +/// The token's AppContainer SID, and whether it equals the package SID. +fn appcontainer(token: HANDLE, package: &PackageSid) -> Result> { + let is_app = token_buffer(token, TokenIsAppContainer)?; + if unsafe { *is_app.as_ptr().cast::() } == 0 { + return Ok(None); + } + let info = token_buffer(token, TokenAppContainerSid)?; + let sid = + unsafe { (*info.as_ptr().cast::()).TokenAppContainer }; + if sid.is_null() { + return Ok(None); + } + let equal = unsafe { EqualSid(sid, package.0) } != 0; + Ok(Some((unsafe { sid_string(sid) }?, equal))) +} + +// Token security attributes have no Win32 reader, so the claim is read with +// the native call. The layouts are those of +// TOKEN_SECURITY_ATTRIBUTES_INFORMATION and TOKEN_SECURITY_ATTRIBUTE_V1. +#[repr(C)] +struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *mut u16, +} + +impl UnicodeString { + /// # Safety + /// + /// `buffer` must hold `length` bytes of UTF-16, or be null. + unsafe fn text(&self) -> String { + if self.buffer.is_null() { + return String::new(); + } + String::from_utf16_lossy(unsafe { + std::slice::from_raw_parts(self.buffer, usize::from(self.length) / 2) + }) + } +} + +#[repr(C)] +struct SecurityAttribute { + name: UnicodeString, + value_type: u16, + reserved: u16, + flags: u32, + value_count: u32, + values: *mut c_void, +} + +#[repr(C)] +struct SecurityAttributes { + version: u16, + reserved: u16, + count: u32, + attributes: *mut SecurityAttribute, +} + +/// The `WIN://NOALLAPPPKG` attribute. The dedicated information class, +/// `TokenIsLessPrivilegedAppContainer`, fails with "invalid information +/// class" on Windows 11 and Server 2022, so the claim is read instead. +fn lpac_claim(token: HANDLE) -> Result { + unsafe { + let mut bytes = 0; + let size_status = + NtQueryInformationToken(token, TokenSecurityAttributes, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(status( + "NtQueryInformationToken(TokenSecurityAttributes, size)", + size_status, + )); + } + let mut data = vec![0usize; (bytes as usize).div_ceil(size_of::())]; + let read = NtQueryInformationToken( + token, + TokenSecurityAttributes, + data.as_mut_ptr().cast(), + bytes, + &mut bytes, + ); + if read != 0 { + return Err(status( + "NtQueryInformationToken(TokenSecurityAttributes)", + read, + )); + } + let header = &*data.as_ptr().cast::(); + if header.version != 1 { + return Err(format!( + "unsupported token security attributes version {}", + header.version + )); + } + if header.count == 0 { + return Ok(Claim::Absent); + } + let entries = std::slice::from_raw_parts(header.attributes, header.count as usize); + for entry in entries { + if entry.name.text() != LPAC_CLAIM { + continue; + } + if entry.value_type != ATTRIBUTE_TYPE_UINT64 { + return Ok(Claim::OtherType(entry.value_type)); + } + let values = if entry.value_count == 0 { + Vec::new() + } else { + std::slice::from_raw_parts(entry.values.cast::(), entry.value_count as usize) + .to_vec() + }; + return Ok(Claim::Unsigned(values)); + } + Ok(Claim::Absent) + } +} + +/// Sets the token's integrity label to Untrusted, `S-1-16-0`. The handle +/// needs `TOKEN_ADJUST_DEFAULT`. +pub(super) fn set_untrusted(token: HANDLE) -> Result<()> { + unsafe { + // SECURITY_MAX_SID_SIZE is 68 bytes. + let mut sid = [0usize; 68usize.div_ceil(size_of::())]; + let mut bytes = size_of_val(&sid) as u32; + if CreateWellKnownSid( + WinUntrustedLabelSid, + null_mut(), + sid.as_mut_ptr().cast(), + &mut bytes, + ) == 0 + { + return Err(last("CreateWellKnownSid(Untrusted)")); + } + let label = TOKEN_MANDATORY_LABEL { + Label: SID_AND_ATTRIBUTES { + Sid: sid.as_mut_ptr().cast(), + Attributes: SE_GROUP_INTEGRITY, + }, + }; + let length = size_of::() + GetLengthSid(label.Label.Sid) as usize; + if SetTokenInformation( + token, + TokenIntegrityLevel, + (&label as *const TOKEN_MANDATORY_LABEL).cast(), + length as u32, + ) == 0 + { + return Err(last("SetTokenInformation(TokenIntegrityLevel)")); + } + Ok(()) + } +} + +/// Reads this process's seven mitigation policy words. +pub(super) fn mitigation_words() -> Result { + let read = |policy: PROCESS_MITIGATION_POLICY, name: &str| -> Result { + let mut word = 0u32; + let ok = unsafe { + GetProcessMitigationPolicy( + GetCurrentProcess(), + policy, + (&mut word as *mut u32).cast(), + size_of::(), + ) + }; + if ok == 0 { + return Err(last(&format!("GetProcessMitigationPolicy({name})"))); + } + Ok(word) + }; + Ok(MitigationWords { + dynamic_code: read(ProcessDynamicCodePolicy, "dynamic code")?, + signature: read(ProcessSignaturePolicy, "signature")?, + image_load: read(ProcessImageLoadPolicy, "image load")?, + system_call: read(ProcessSystemCallDisablePolicy, "system call disable")?, + strict_handle: read(ProcessStrictHandleCheckPolicy, "strict handle check")?, + extension_point: read(ProcessExtensionPointDisablePolicy, "extension point")?, + child_process: read(ProcessChildProcessPolicy, "child process")?, + }) +} + +/// The values of this process's standard input, output and error handles. +pub(super) fn standard_handles() -> [usize; 3] { + [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] + .map(|which| unsafe { GetStdHandle(which) } as usize) +} + +/// One entry of `PROCESS_HANDLE_SNAPSHOT_INFORMATION`. +#[repr(C)] +#[derive(Clone, Copy)] +pub(super) struct SnapshotEntry { + pub(super) handle: HANDLE, + handle_count: usize, + pointer_count: usize, + pub(super) access: u32, + pub(super) type_index: u32, + pub(super) attributes: u32, + reserved: u32, +} + +/// Lists this process's handle table. Only the values, types and flags are +/// read: no snapshot handle is used as a query target, because another +/// thread can close it meanwhile, and strict handle checks make a call on a +/// closed handle end the process. +pub(super) fn handle_snapshot() -> Result> { + let mut bytes = 64 * 1024u32; + for _ in 0..8 { + let mut buffer = vec![0usize; bytes as usize / size_of::()]; + let mut returned = 0; + let result = unsafe { + NtQueryInformationProcess( + GetCurrentProcess(), + PROCESS_HANDLE_INFORMATION, + buffer.as_mut_ptr().cast(), + bytes, + &mut returned, + ) + }; + if result == STATUS_INFO_LENGTH_MISMATCH { + // Handles can be created between the two calls; leave room. + bytes = returned.max(bytes).saturating_mul(2); + continue; + } + if result < 0 { + return Err(status( + "NtQueryInformationProcess(ProcessHandleInformation)", + result, + )); + } + // The header is two pointer-sized fields: the count and a reserved + // field. The entries follow. + let count = buffer[0]; + let capacity = (bytes as usize - 2 * size_of::()) / size_of::(); + if count > capacity { + return Err(format!("handle snapshot count {count} exceeds its buffer")); + } + let entries = unsafe { + std::slice::from_raw_parts(buffer.as_ptr().add(2).cast::(), count) + }; + return Ok(entries.to_vec()); + } + Err("the handle snapshot kept growing".into()) +} + +/// One entry of `OBJECT_TYPES_INFORMATION`, the fixed part of +/// `OBJECT_TYPE_INFORMATION`. The type's name follows it. +#[repr(C)] +struct ObjectTypeInformation { + name: UnicodeString, + counters: [u32; 13], + generic_mapping: [u32; 4], + valid_access_mask: u32, + security_required: u8, + maintain_handle_count: u8, + type_index: u8, + reserved: u8, + pool: [u32; 3], +} + +/// Every object type's name by its index, the index handle tables use. +pub(super) fn object_types() -> Result> { + let mut bytes = 64 * 1024u32; + for _ in 0..8 { + let mut buffer = vec![0usize; bytes as usize / size_of::()]; + let mut returned = 0; + let result = unsafe { + NtQueryObject( + null_mut(), + OBJECT_TYPES_INFORMATION, + buffer.as_mut_ptr().cast(), + bytes, + &mut returned, + ) + }; + if result == STATUS_INFO_LENGTH_MISMATCH { + bytes = returned.max(bytes).saturating_mul(2); + continue; + } + if result < 0 { + return Err(status("NtQueryObject(ObjectTypesInformation)", result)); + } + let base = buffer.as_ptr().cast::(); + let end = (returned as usize).min(bytes as usize); + let count = unsafe { *base.cast::() }; + // The first entry is aligned to a pointer after the 4-byte count; each + // later one follows the previous entry's name, aligned the same way. + let mut offset = size_of::(); + let mut types = BTreeMap::new(); + for _ in 0..count { + if offset + size_of::() > end { + return Err("truncated ObjectTypesInformation".into()); + } + let info = unsafe { &*base.add(offset).cast::() }; + types.insert(u32::from(info.type_index), unsafe { info.name.text() }); + offset = (offset + + size_of::() + + usize::from(info.name.maximum_length)) + .next_multiple_of(size_of::()); + } + return Ok(types); + } + Err("the object type list kept growing".into()) +} + +/// The object name behind a handle, read through a duplicate so that the +/// table's own handle is never a query target. +pub(super) fn object_name_via_duplicate(handle: HANDLE) -> Result { + unsafe { + let process = GetCurrentProcess(); + let mut duplicate = null_mut(); + if DuplicateHandle( + process, + handle, + process, + &mut duplicate, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) == 0 + { + return Err(last("DuplicateHandle(directory)")); + } + let name = object_name(duplicate); + close(duplicate, "directory duplicate")?; + name + } +} + +fn object_name(handle: HANDLE) -> Result { + let mut bytes = 1024u32; + for _ in 0..2 { + let mut buffer = vec![0usize; bytes as usize / size_of::()]; + let mut returned = 0; + let result = unsafe { + NtQueryObject( + handle, + OBJECT_NAME_INFORMATION, + buffer.as_mut_ptr().cast(), + bytes, + &mut returned, + ) + }; + if (result == STATUS_INFO_LENGTH_MISMATCH || result == STATUS_BUFFER_OVERFLOW) + && returned > bytes + { + bytes = returned.next_multiple_of(size_of::() as u32); + continue; + } + if result < 0 { + return Err(status("NtQueryObject(ObjectNameInformation)", result)); + } + // The name's characters are in the same buffer, after the header. + let name = unsafe { &*buffer.as_ptr().cast::() }; + let start = name.buffer as usize; + let base = buffer.as_ptr() as usize; + if !name.buffer.is_null() + && (start < base || start + usize::from(name.length) > base + bytes as usize) + { + return Err("object name outside its buffer".into()); + } + return Ok(unsafe { name.text() }); + } + Err("the object name kept growing".into()) +} + +/// Which standard handle `value` is. +pub(super) fn stdio_slot(value: usize, standard: &[usize; 3]) -> Option { + [Stdio::Input, Stdio::Output, Stdio::Error] + .into_iter() + .zip(standard) + .find(|(_, handle)| **handle == value) + .map(|(slot, _)| slot) +} + +/// The handle table as the allowlist judges it. +pub(super) fn handle_table() -> Result> { + let types = object_types()?; + let standard = standard_handles(); + let snapshot = handle_snapshot()?; + let mut table: Vec = snapshot + .iter() + .map(|entry| HandleEntry { + value: entry.handle as usize, + type_name: types + .get(&entry.type_index) + .cloned() + .unwrap_or_else(|| format!("unknown type {}", entry.type_index)), + access: entry.access, + inheritable: entry.attributes & OBJ_INHERIT != 0, + stdio: stdio_slot(entry.handle as usize, &standard), + name: None, + }) + .collect(); + // Only a single Directory handle is named. With two or more the table is + // refused anyway, and nothing is queried. + let directories: Vec = (0..table.len()) + .filter(|&index| table[index].type_name == "Directory") + .collect(); + if let [index] = directories[..] { + table[index].name = Some(object_name_via_duplicate(snapshot[index].handle)?); + } + Ok(table) +} diff --git a/crates/basal-worker/src/confinement/windows/startup.rs b/crates/basal-worker/src/confinement/windows/startup.rs new file mode 100644 index 0000000..a71f228 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/startup.rs @@ -0,0 +1,133 @@ +//! The startup sequence: the six checks in order, before the first frame +//! read. See the parent module for what each step guards. + +use super::allowlist::{self, closed_at_startup}; +use super::native::{self, CURRENT_PROCESS_TOKEN, OBJ_INHERIT, PackageSid, close, last}; +use super::{Arguments, Refusal, attest, mitigation, reason}; +use crate::confinement::{ConfinementError, Entered}; +use basal_proto::Confinement; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::{ERROR_NO_TOKEN, GetLastError, HANDLE}; +use windows_sys::Win32::Security::{RevertToSelf, TOKEN_ADJUST_DEFAULT, TOKEN_QUERY}; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, GetCurrentThread, OpenProcessToken, OpenThreadToken, +}; + +/// Runs the six startup checks. On success the worker is fully confined and +/// may read input; the report it sends in its welcome is all true, because a +/// worker that failed any check has already exited. +pub fn enter(package: &PackageSid, arguments: &Arguments) -> Result { + run(package, arguments).map_err(ConfinementError::Windows)?; + Ok(Entered { + confinement: Confinement::Windows { + lpac: true, + untrusted: true, + no_thread_token: true, + mitigations: true, + handle_table: true, + }, + descriptors: Vec::new(), + }) +} + +fn run(package: &PackageSid, arguments: &Arguments) -> Result<(), Refusal> { + // The handle used to lower the primary token is opened first, while the + // main thread still impersonates the start-up token. Once the thread has + // reverted, access checks use the restricted primary, which is not + // allowed to open its own token for adjustment. + // A failure here is reported by step 2, after step 1 has run. + let adjustment = open_primary(if arguments.opens_primary_read_only() { + TOKEN_QUERY + } else { + TOKEN_QUERY | TOKEN_ADJUST_DEFAULT + }); + drop_thread_token(arguments.keeps_thread_token())?; + lower_integrity(adjustment, arguments.skips_lowering())?; + close_startup_leftovers()?; + attest::check(&native::primary_facts(CURRENT_PROCESS_TOKEN, package))?; + let words = native::mitigation_words() + .map_err(|error| Refusal::new(reason::MITIGATION_MISMATCH, error))?; + mitigation::validate(&words) + .map_err(|error| Refusal::new(reason::MITIGATION_MISMATCH, error))?; + let table = + native::handle_table().map_err(|error| Refusal::new(reason::HANDLE_NOT_ALLOWED, error))?; + allowlist::check(&table).map_err(|error| Refusal::new(reason::HANDLE_NOT_ALLOWED, error))?; + Ok(()) +} + +/// A token handle closed when dropped, unless closed explicitly first. +struct Token(HANDLE); + +impl Drop for Token { + fn drop(&mut self) { + if !self.0.is_null() { + let _ = close(self.0, "token"); + } + } +} + +fn open_primary(access: u32) -> Result { + let mut token = null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), access, &mut token) } == 0 { + return Err(last("OpenProcessToken(own primary)")); + } + Ok(Token(token)) +} + +/// Step 1: stop impersonating the start-up token, then require that the +/// thread has no token at all (`ERROR_NO_TOKEN`). +fn drop_thread_token(keep: bool) -> Result<(), Refusal> { + let refuse = |detail: String| Refusal::new(reason::THREAD_TOKEN_PRESENT, detail); + if !keep && unsafe { RevertToSelf() } == 0 { + return Err(refuse(last("RevertToSelf"))); + } + let mut token = null_mut(); + // Open-as-self checks access against the process's primary token rather + // than against whatever token the thread may still carry. + if unsafe { OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, 1, &mut token) } != 0 { + let _ = close(token, "thread token"); + return Err(refuse("the main thread still has a token".into())); + } + let error = unsafe { GetLastError() }; + if error != ERROR_NO_TOKEN { + return Err(refuse(format!( + "OpenThreadToken: Win32 {error}, not ERROR_NO_TOKEN" + ))); + } + Ok(()) +} + +/// Step 2: lower the actual primary from Low to Untrusted, then close the +/// handle that could adjust it, so none survives into the input phase. +fn lower_integrity(adjustment: Result, skip: bool) -> Result<(), Refusal> { + let refuse = |detail: String| Refusal::new(reason::INTEGRITY_LOWER_FAILED, detail); + let mut token = adjustment.map_err(refuse)?; + if !skip { + native::set_untrusted(token.0).map_err(refuse)?; + } + let handle = std::mem::replace(&mut token.0, null_mut()); + close(handle, "primary adjustment handle").map_err(refuse) +} + +/// Step 3: close the ALPC port and private File handles the loader left (see +/// [`closed_at_startup`]). A handle that cannot be listed or closed refuses +/// startup as `handle-not-allowed`, the check that would otherwise see it. +fn close_startup_leftovers() -> Result<(), Refusal> { + let refuse = |detail: String| Refusal::new(reason::HANDLE_NOT_ALLOWED, detail); + let types = native::object_types().map_err(refuse)?; + let standard = native::standard_handles(); + for entry in native::handle_snapshot().map_err(refuse)? { + let Some(type_name) = types.get(&entry.type_index) else { + continue; + }; + let value = entry.handle as usize; + if closed_at_startup( + type_name, + standard.contains(&value), + entry.attributes & OBJ_INHERIT != 0, + ) { + close(entry.handle, &format!("{type_name} {value:#x}")).map_err(refuse)?; + } + } + Ok(()) +} diff --git a/crates/basal-worker/src/main.rs b/crates/basal-worker/src/main.rs index 0382b3b..cb86981 100644 --- a/crates/basal-worker/src/main.rs +++ b/crates/basal-worker/src/main.rs @@ -1,9 +1,16 @@ //! Entry point for `ck-basal-worker`. //! -//! Started by basal with an explicit Landlock policy on Linux, the worker confines itself and then -//! serves frames on stdin and stdout. Logs go to stderr. The other modes are -//! `--confinement-probe`, which reports what the sandbox denies, and -//! `--version`, which prints the version and the build revision. +//! Started by basal with an explicit Landlock policy on Linux, and through +//! basal-launch with `--package-sid=` on Windows, the worker confines +//! itself and then serves frames on stdin and stdout. Logs go to stderr. The +//! other modes are `--confinement-probe`, which reports what the sandbox +//! denies, and `--version`, which prints the version and the build revision. +//! +//! On Windows the image is a GUI-subsystem one, so that no console host is +//! started for it: under the confined token, console initialisation fails in +//! the loader (`STATUS_DLL_INIT_FAILED`) before the worker's first +//! instruction. The worker talks only through its three inherited pipes. +#![cfg_attr(windows, windows_subsystem = "windows")] use std::io::{self, BufWriter}; use std::process::ExitCode; @@ -30,6 +37,10 @@ fn main() -> ExitCode { } #[cfg(target_os = "linux")] Some("--landlock=required" | "--landlock=optional") if args.len() == 1 => {} + // The Windows engine arguments are checked in full below. + #[cfg(windows)] + Some(_) => {} + #[cfg(not(windows))] Some(other) => { eprintln!("ck-basal-worker: unknown argument {other}"); return ExitCode::from(64); @@ -49,7 +60,30 @@ fn main() -> ExitCode { ), Err(confinement::linux::ArgumentError::Usage) => return ExitCode::from(64), }; - #[cfg(not(target_os = "linux"))] + #[cfg(windows)] + let result = { + use confinement::windows::{self, ArgumentError, PackageSid, Refusal, reason}; + match windows::engine_arguments(&args) { + Ok(arguments) => match PackageSid::parse(&arguments.package_sid) { + Ok(package) => windows::enter(&package, &arguments), + Err(error) => { + eprintln!("ck-basal-worker: unparsable --package-sid: {error}"); + return ExitCode::from(64); + } + }, + Err(ArgumentError::Missing) => { + Err(confinement::ConfinementError::Windows(Refusal::new( + reason::PACKAGE_SID_ARGUMENT_MISSING, + "no --package-sid= argument", + ))) + } + Err(ArgumentError::Usage(detail)) => { + eprintln!("ck-basal-worker: {detail}"); + return ExitCode::from(64); + } + } + }; + #[cfg(not(any(target_os = "linux", windows)))] let result = confinement::enter(); let entered = match result { Ok(entered) => entered, diff --git a/crates/basal-worker/tests/budgets.rs b/crates/basal-worker/tests/budgets.rs index 167bef4..ff966d1 100644 --- a/crates/basal-worker/tests/budgets.rs +++ b/crates/basal-worker/tests/budgets.rs @@ -1,5 +1,8 @@ //! Per-activation limits: JS time, memory, stack, and stalled promises. +// Windows: gated until basal-testkit builds there. +#![cfg(not(windows))] + mod common; use std::time::Duration; diff --git a/crates/basal-worker/tests/clock.rs b/crates/basal-worker/tests/clock.rs index 227b452..b4059f0 100644 --- a/crates/basal-worker/tests/clock.rs +++ b/crates/basal-worker/tests/clock.rs @@ -1,6 +1,9 @@ //! Clock reads and random samples are host calls: journaled on the first //! run, replayed from the journal afterwards. +// Windows: gated until basal-testkit builds there. +#![cfg(not(windows))] + mod common; use std::time::Duration; diff --git a/crates/basal-worker/tests/codemode.rs b/crates/basal-worker/tests/codemode.rs index c748e61..264c548 100644 --- a/crates/basal-worker/tests/codemode.rs +++ b/crates/basal-worker/tests/codemode.rs @@ -1,4 +1,8 @@ //! Codemode's confined wire contract, without the flow parent's journal or hosts. + +// Windows: gated until basal-testkit builds there. +#![cfg(not(windows))] + mod common; use std::time::Duration; diff --git a/crates/basal-worker/tests/fuzz.rs b/crates/basal-worker/tests/fuzz.rs index d9f9f65..0bce3d6 100644 --- a/crates/basal-worker/tests/fuzz.rs +++ b/crates/basal-worker/tests/fuzz.rs @@ -2,6 +2,9 @@ //! the JS time budget. Every activation must end with a typed result and the //! worker must survive all of them. +// Windows: gated until basal-testkit builds there. +#![cfg(not(windows))] + mod common; use std::time::Duration; diff --git a/crates/basal-worker/tests/inherited_descriptors.rs b/crates/basal-worker/tests/inherited_descriptors.rs index a34d6cf..5800718 100644 --- a/crates/basal-worker/tests/inherited_descriptors.rs +++ b/crates/basal-worker/tests/inherited_descriptors.rs @@ -1,5 +1,8 @@ //! The real worker must discard inherited authority even in probe mode. +// Windows: gated until basal-testkit builds there. +#![cfg(not(windows))] + mod common; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; diff --git a/crates/basal-worker/tests/ipc.rs b/crates/basal-worker/tests/ipc.rs index 3ea67c9..caaa581 100644 --- a/crates/basal-worker/tests/ipc.rs +++ b/crates/basal-worker/tests/ipc.rs @@ -1,6 +1,9 @@ //! The channel: malformed, oversized and out-of-order frames are refused //! with typed errors, and none of them crashes the worker. +// Windows: gated until basal-testkit builds there. +#![cfg(not(windows))] + mod common; use std::time::Duration; diff --git a/crates/basal-worker/tests/lockdown.rs b/crates/basal-worker/tests/lockdown.rs index e0a1316..bfa3a76 100644 --- a/crates/basal-worker/tests/lockdown.rs +++ b/crates/basal-worker/tests/lockdown.rs @@ -2,6 +2,9 @@ //! operator-approved script that basal runs unattended; the lockdown keeps //! it to the globals listed below and the host calls basal journals. +// Windows: gated until basal-testkit builds there. +#![cfg(not(windows))] + mod common; use basal_proto::{CallKind, JsonText, Primitive}; diff --git a/crates/basal-worker/tests/profile.rs b/crates/basal-worker/tests/profile.rs index e2bc4c6..927a32a 100644 --- a/crates/basal-worker/tests/profile.rs +++ b/crates/basal-worker/tests/profile.rs @@ -3,6 +3,9 @@ //! engine unit test `raw_bridge_cannot_issue_sh_in_flow_profile` also checks the //! Rust refusal when a call bypasses the script's API. +// Windows: gated until basal-testkit builds there. +#![cfg(not(windows))] + mod common; use basal_proto::{CallKind, Primitive, Profile}; diff --git a/crates/basal-worker/tests/replay.rs b/crates/basal-worker/tests/replay.rs index cd9999e..acb63e9 100644 --- a/crates/basal-worker/tests/replay.rs +++ b/crates/basal-worker/tests/replay.rs @@ -1,5 +1,8 @@ //! Replay of the recorded prefix inside the worker. +// Windows: gated until basal-testkit builds there. +#![cfg(not(windows))] + mod common; use basal_proto::{ diff --git a/crates/basal-worker/tests/sandbox_integrity.rs b/crates/basal-worker/tests/sandbox_integrity.rs index 4870a2b..04ae253 100644 --- a/crates/basal-worker/tests/sandbox_integrity.rs +++ b/crates/basal-worker/tests/sandbox_integrity.rs @@ -1,4 +1,8 @@ //! Adversarial scripts at the sandbox's function and exception boundaries. + +// Windows: gated until basal-testkit builds there. +#![cfg(not(windows))] + mod common; use basal_proto::{ActivationResult, Failure}; diff --git a/crates/basal-worker/tests/windows_image.rs b/crates/basal-worker/tests/windows_image.rs new file mode 100644 index 0000000..a618b5e --- /dev/null +++ b/crates/basal-worker/tests/windows_image.rs @@ -0,0 +1,211 @@ +//! The built worker image: a GUI-subsystem executable that imports no DLL +//! which would load User32, GDI or COM into the confined process, and no C +//! runtime DLL. +//! +//! A static Userenv or Ole32 import once pulled User32, GDI and Win32u into +//! the confined child and left it with over a hundred handles before input; +//! those DLLs are loaded at run time in the parent only. The C runtime is +//! linked statically (`+crt-static` for this target in `.cargo/config.toml`), +//! so the worker never depends on a separately installed runtime DLL. The +//! console subsystem would start a console host the confined token cannot +//! initialise. +#![cfg(windows)] + +use std::path::Path; + +/// `IMAGE_SUBSYSTEM_WINDOWS_GUI`. +const GUI_SUBSYSTEM: u16 = 2; + +/// DLLs whose import would load GUI or COM code into the worker. +const FORBIDDEN: [&str; 5] = [ + "userenv.dll", + "ole32.dll", + "user32.dll", + "gdi32.dll", + "win32u.dll", +]; + +/// What the import test reads from a PE image. +struct Image { + subsystem: u16, + imports: Vec, + delay_imports: Vec, +} + +fn u16_at(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes(bytes[offset..offset + 2].try_into().expect("two bytes")) +} + +fn u32_at(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("four bytes")) +} + +fn u64_at(bytes: &[u8], offset: usize) -> u64 { + u64::from_le_bytes(bytes[offset..offset + 8].try_into().expect("eight bytes")) +} + +/// Reads the subsystem and the import and delay-import DLL names of a +/// 64-bit PE image. +fn parse(bytes: &[u8]) -> Image { + assert_eq!(&bytes[..2], b"MZ", "not a PE image"); + let pe = u32_at(bytes, 0x3c) as usize; + assert_eq!(&bytes[pe..pe + 4], b"PE\0\0", "no PE signature"); + let coff = pe + 4; + let sections = u16_at(bytes, coff + 2) as usize; + let optional_size = u16_at(bytes, coff + 16) as usize; + let optional = coff + 20; + assert_eq!(u16_at(bytes, optional), 0x20b, "not a PE32+ image"); + let subsystem = u16_at(bytes, optional + 68); + let image_base = u64_at(bytes, optional + 24); + let directories = u32_at(bytes, optional + 108) as usize; + let directory = |index: usize| -> (u32, u32) { + if index >= directories { + return (0, 0); + } + let entry = optional + 112 + index * 8; + (u32_at(bytes, entry), u32_at(bytes, entry + 4)) + }; + + // (virtual address, virtual size, raw size, raw offset) per section. + let table = optional + optional_size; + let sections: Vec<(u32, u32, u32, u32)> = (0..sections) + .map(|index| { + let header = table + index * 40; + ( + u32_at(bytes, header + 12), + u32_at(bytes, header + 8), + u32_at(bytes, header + 16), + u32_at(bytes, header + 20), + ) + }) + .collect(); + let offset = |rva: u32| -> usize { + let (address, _, _, raw) = sections + .iter() + .copied() + .find(|&(address, virtual_size, raw_size, _)| { + rva >= address && rva < address + virtual_size.max(raw_size) + }) + .unwrap_or_else(|| panic!("RVA {rva:#x} is in no section")); + (rva - address + raw) as usize + }; + let name = |rva: u32| -> String { + let start = offset(rva); + let end = bytes[start..] + .iter() + .position(|&byte| byte == 0) + .expect("a NUL-terminated name"); + String::from_utf8_lossy(&bytes[start..start + end]).into_owned() + }; + + let mut imports = Vec::new(); + let (import_rva, import_size) = directory(1); + if import_rva != 0 && import_size != 0 { + // IMAGE_IMPORT_DESCRIPTOR, 20 bytes, the name RVA at offset 12; the + // list ends with an all-zero descriptor. + let mut descriptor = offset(import_rva); + while bytes[descriptor..descriptor + 20] + .iter() + .any(|&byte| byte != 0) + { + imports.push(name(u32_at(bytes, descriptor + 12))); + descriptor += 20; + } + } + let mut delay_imports = Vec::new(); + let (delay_rva, delay_size) = directory(13); + if delay_rva != 0 && delay_size != 0 { + // IMAGE_DELAYLOAD_DESCRIPTOR, 32 bytes: attributes, then the name. + // Attribute bit 0 clear means the name is a virtual address. + let mut descriptor = offset(delay_rva); + while bytes[descriptor..descriptor + 32] + .iter() + .any(|&byte| byte != 0) + { + let attributes = u32_at(bytes, descriptor); + let mut rva = u32_at(bytes, descriptor + 4); + if attributes & 1 == 0 { + rva = (u64::from(rva) - image_base) as u32; + } + delay_imports.push(name(rva)); + descriptor += 32; + } + } + Image { + subsystem, + imports, + delay_imports, + } +} + +fn is_c_runtime(name: &str) -> bool { + (name.starts_with("vcruntime") && name.ends_with(".dll")) + || (name.starts_with("msvcp") && name.ends_with(".dll")) + || name == "ucrtbase.dll" + || name.starts_with("api-ms-win-crt-") +} + +#[test] +fn the_worker_is_a_gui_image_without_gui_com_or_c_runtime_imports() { + let path = Path::new(env!("CARGO_BIN_EXE_ck-basal-worker")); + let image = parse(&std::fs::read(path).expect("read the worker image")); + println!("subsystem: {}", image.subsystem); + println!("imports: {:?}", image.imports); + println!("delay imports: {:?}", image.delay_imports); + assert_eq!(image.subsystem, GUI_SUBSYSTEM); + + let all: Vec = image + .imports + .iter() + .chain(&image.delay_imports) + .map(|name| name.to_ascii_lowercase()) + .collect(); + // The parse found the import table: every Windows program imports + // kernel32, so an empty or misread table cannot pass the checks below. + assert!(all.iter().any(|name| name == "kernel32.dll"), "{all:?}"); + for name in &all { + assert!(!FORBIDDEN.contains(&name.as_str()), "imports {name}"); + assert!(!is_c_runtime(name), "imports the C runtime DLL {name}"); + } + + // Once the import list read from a production build of the worker is + // committed at this path, every import must be on it, so a new DLL + // dependency is a reviewed change. API-set names (`api-ms-win-*`) are names, not paths. + let listed = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/windows-imports.txt"); + if let Ok(text) = std::fs::read_to_string(&listed) { + let allowed: Vec = text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_ascii_lowercase) + .collect(); + for name in &all { + assert!( + allowed.contains(name), + "{name} is not in {}", + listed.display() + ); + } + } +} + +#[test] +fn the_c_runtime_names_are_recognised() { + for name in [ + "vcruntime140.dll", + "vcruntime140_1.dll", + "msvcp140.dll", + "ucrtbase.dll", + "api-ms-win-crt-runtime-l1-1-0.dll", + ] { + assert!(is_c_runtime(name), "{name}"); + } + for name in [ + "kernel32.dll", + "ntdll.dll", + "advapi32.dll", + "api-ms-win-core-synch-l1-2-0.dll", + ] { + assert!(!is_c_runtime(name), "{name}"); + } +} diff --git a/crates/basal-worker/tests/windows_startup.rs b/crates/basal-worker/tests/windows_startup.rs new file mode 100644 index 0000000..3e18e66 --- /dev/null +++ b/crates/basal-worker/tests/windows_startup.rs @@ -0,0 +1,383 @@ +//! The worker's Windows startup, run for real: every spawn but two goes +//! through basal-launch under the full confinement, or under one of its test +//! variants that breaks exactly one property a startup check guards. +//! +//! The two exceptions start the worker directly, without a package SID or +//! with an unparsable one. The worker refuses those before it confines +//! itself or reads anything, and the launcher always passes a valid SID, so +//! it cannot produce them. +#![cfg(windows)] + +use basal_launch::{ + ConfinedProcess, Deviation, LaunchOptions, create_or_open_profile, grant_test_binary_directory, + launch, +}; +use basal_proto::{ + ActivationRequest, ActivationResult, Budgets, CallKind, Confinement, FLOW_JOB_COMMIT_BYTES, + JsonText, Outcome, PROTOCOL_VERSION, ParentMessage, Primitive, Profile, Settlement, + WorkerMessage, read_worker_message, write_parent_message, +}; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::OnceLock; + +/// The worker binary cargo built for these tests. +fn built_worker() -> &'static Path { + Path::new(env!("CARGO_BIN_EXE_ck-basal-worker")) +} + +/// The worker, copied into a directory of its own so that the package grant +/// changes no ACL but that directory's. A build with the `deviations` feature +/// gets its own directory, so the two test runs never replace each other's +/// image. +fn placed_worker() -> &'static Path { + static PLACED: OnceLock = OnceLock::new(); + PLACED.get_or_init(|| { + let directory = built_worker() + .parent() + .expect("the worker has a directory") + .join(if cfg!(feature = "deviations") { + "basal-worker-placed-deviations" + } else { + "basal-worker-placed" + }); + std::fs::create_dir_all(&directory).expect("create the placement directory"); + let placed = directory.join("ck-basal-worker.exe"); + std::fs::copy(built_worker(), &placed).expect("place the worker"); + 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) -> ConfinedProcess { + let options = LaunchOptions::new(placed_worker(), FLOW_JOB_COMMIT_BYTES).deviation(deviation); + launch(&options).unwrap_or_else(|error| panic!("launch {deviation}: {error}")) +} + +/// How a worker ended: its exit code and everything it wrote to stderr. +struct Ended { + code: u32, + stderr: String, +} + +impl Ended { + /// The reason token in a startup refusal line, + /// `ck-basal-worker: refusing to run unconfined: : `. + fn reason(&self) -> Option<&str> { + let rest = self.stderr.split("refusing to run unconfined: ").nth(1)?; + rest.split(':').next() + } +} + +/// Closes the worker's stdin and waits for it to end. A worker that refused +/// to start has already exited; one that started anyway reads end of input +/// and exits 0, so a missing refusal shows up as a wrong exit code rather +/// than a test that never finishes. +fn end(mut child: ConfinedProcess) -> Ended { + drop(child.stdin.take()); + let mut stderr = String::new(); + child + .stderr + .take() + .expect("stderr") + .read_to_string(&mut stderr) + .expect("read the worker's stderr"); + let code = child.wait().expect("wait for the worker"); + Ended { code, stderr } +} + +/// Ends the worker and fails the test with what it wrote to stderr. +fn fail(child: ConfinedProcess, what: String) -> ! { + let ended = end(child); + panic!( + "{what}; worker exit {:#x}, stderr: {}", + ended.code, ended.stderr + ) +} + +/// Launches the worker under `deviation` and requires it to refuse at the +/// startup check that variant breaks: exit 70, that reason on stderr. +fn assert_refused(deviation: Deviation) { + let expected = deviation + .worker_reason() + .unwrap_or_else(|| panic!("{deviation} is not a worker check")); + let ended = end(start(deviation)); + println!( + "{deviation}: exit {} stderr {}", + ended.code, + ended.stderr.trim() + ); + assert_eq!(ended.code, 70, "{deviation}: {}", ended.stderr); + assert_eq!(ended.reason(), Some(expected), "{}", ended.stderr); +} + +/// A Windows report with every property true: the only report a worker that +/// passed its startup checks sends. +const CONFINED: Confinement = Confinement::Windows { + lpac: true, + untrusted: true, + no_thread_token: true, + mitigations: true, + handle_table: true, +}; + +/// The time the test parent answers a clock read with: 2026-01-01T00:00:00Z. +const NOW_MS: &str = "1767225600000"; + +/// A real activation under the full confinement: the worker passes all six +/// startup checks with the launcher's environment, reports the Windows +/// confinement, runs a script that makes a synchronous host call, returns its +/// result and shuts down cleanly. The parent also reads the worker's primary +/// back and finds it Untrusted, which only the worker's own lowering makes it. +#[test] +fn an_activation_completes_under_the_full_confinement() { + let mut child = start(Deviation::Full); + let mut stdin = child.stdin.take().expect("stdin"); + let mut stdout = child.stdout.take().expect("stdout"); + + write_parent_message( + &mut stdin, + &ParentMessage::Hello { + protocol_version: PROTOCOL_VERSION, + }, + ) + .expect("send hello"); + let welcome = match read_worker_message(&mut stdout) { + Ok(WorkerMessage::Welcome(welcome)) => welcome, + other => fail(child, format!("no welcome: {other:?}")), + }; + println!("full: welcome {welcome:?}"); + assert_eq!(welcome.confinement, CONFINED); + + let token = child.primary_token().expect("read the worker's primary"); + println!("full: primary after startup {token:?}"); + assert_eq!(token.integrity, "S-1-16-0", "the worker lowered itself"); + + let script = r#" + const now = Date.now(); + const total = [1, 2, 3, 4].map((n) => n * n).reduce((a, b) => a + b, 0); + const text = JSON.stringify({ nested: [total, "x"] }); + return { now, total, text, parsed: JSON.parse(text).nested[1] }; + "#; + let request = ActivationRequest { + activation_id: 7, + profile: Profile::Flow, + tools: vec![], + prelude_hash: welcome.prelude_hash, + script: script.into(), + trigger: JsonText::null(), + self_input: JsonText::null(), + budgets: Budgets::default(), + prefix: vec![], + }; + write_parent_message(&mut stdin, &ParentMessage::Activate(Box::new(request))) + .expect("send the activation"); + let mut host_calls = Vec::new(); + let result = loop { + match read_worker_message(&mut stdout) { + Ok(WorkerMessage::HostCall(call)) => { + assert_eq!(call.kind, CallKind::Primitive(Primitive::Now), "{call:?}"); + let reply = ParentMessage::Deliver(Outcome { + position: call.position, + settlement: Settlement::Fulfilled, + value: JsonText::new(NOW_MS).expect("small"), + delivery_order: host_calls.len() as u64, + }); + host_calls.push(call); + write_parent_message(&mut stdin, &reply).expect("deliver the clock read"); + } + Ok(WorkerMessage::Finished { + activation_id, + result, + }) => { + assert_eq!(activation_id, 7); + break result; + } + other => fail(child, format!("unexpected reply: {other:?}")), + } + }; + println!("full: {} host call(s), result {result:?}", host_calls.len()); + assert_eq!(host_calls.len(), 1); + let value = match result { + ActivationResult::Completed { value } => value, + other => fail(child, format!("the activation did not complete: {other:?}")), + }; + let value: serde_json::Value = serde_json::from_str(value.as_str()).expect("JSON result"); + assert_eq!( + value, + serde_json::json!({ + "now": 1767225600000u64, + "total": 30, + "text": "{\"nested\":[30,\"x\"]}", + "parsed": "x", + }) + ); + + write_parent_message(&mut stdin, &ParentMessage::Shutdown).expect("send shutdown"); + drop(stdin); + drop(stdout); + let ended = end(child); + println!("full: shut down with exit {}", ended.code); + assert_eq!(ended.code, 0, "{}", ended.stderr); +} + +// Worker checks of token, mitigation and handle properties the launcher can +// break when it creates the process. In these test variants the parent's own +// pre-resume checks expect the broken property, so the worker is resumed and +// must notice and refuse by itself. + +#[test] +fn a_primary_without_the_lpac_claim_is_refused_as_not_lpac() { + assert_refused(Deviation::NotLpac); +} + +#[test] +fn a_primary_with_a_capability_is_refused_as_capabilities_present() { + assert_refused(Deviation::CapabilitiesPresent); +} + +#[test] +fn a_second_restricting_sid_is_refused_as_restricting_sid_mismatch() { + assert_refused(Deviation::RestrictingSidMismatch); +} + +#[test] +fn an_enabled_logon_group_is_refused_as_group_not_deny_only() { + assert_refused(Deviation::GroupNotDenyOnly); +} + +#[test] +fn a_primary_with_privileges_is_refused_as_privileges_present() { + assert_refused(Deviation::PrivilegesPresent); +} + +#[test] +fn allowed_dynamic_code_is_refused_as_mitigation_mismatch() { + assert_refused(Deviation::MitigationMismatch); +} + +/// Without the explicit inherited-handle list the worker inherits every +/// inheritable handle of this process. One is planted here, so the worker's +/// table holds an inheritable file that is not one of its pipes. +#[test] +fn an_inherited_handle_beyond_the_pipes_is_refused_as_handle_not_allowed() { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::{HANDLE_FLAG_INHERIT, SetHandleInformation}; + + let fixture = built_worker() + .parent() + .expect("the worker has a directory") + .join(format!("basal-worker-planted-{}", std::process::id())); + let planted = File::create(&fixture).expect("create the planted file"); + let ok = unsafe { + SetHandleInformation( + planted.as_raw_handle(), + HANDLE_FLAG_INHERIT, + HANDLE_FLAG_INHERIT, + ) + }; + assert_ne!( + ok, + 0, + "SetHandleInformation: {}", + std::io::Error::last_os_error() + ); + assert_refused(Deviation::HandleNotAllowed); + drop(planted); + let _ = std::fs::remove_file(&fixture); +} + +/// A production worker has no way to skip a startup check: it refuses the +/// request the launcher's test variants add as an unknown argument, exit 64, +/// before any check runs. +#[cfg(not(feature = "deviations"))] +#[test] +fn a_production_worker_refuses_the_deviation_argument() { + for deviation in [ + Deviation::ThreadTokenPresent, + Deviation::IntegrityLowerFailed, + Deviation::IntegrityNotUntrusted, + ] { + let argument = deviation.child_argument().expect("a worker argument"); + let ended = end(start(deviation)); + println!( + "{deviation}: exit {} stderr {}", + ended.code, + ended.stderr.trim() + ); + assert_eq!(ended.code, 64, "{deviation}: {}", ended.stderr); + assert!( + ended + .stderr + .contains(&format!("unknown argument {argument}")), + "{}", + ended.stderr + ); + } +} + +// Worker checks of steps the worker performs itself (dropping the start-up +// thread token, lowering its integrity), which the parent cannot break when +// it creates the process. A worker built with the `deviations` feature skips +// or breaks that step when the launcher's command line asks it to. + +#[cfg(feature = "deviations")] +#[test] +fn a_kept_start_up_token_is_refused_as_thread_token_present() { + assert_refused(Deviation::ThreadTokenPresent); +} + +#[cfg(feature = "deviations")] +#[test] +fn a_failed_lowering_is_refused_as_integrity_lower_failed() { + assert_refused(Deviation::IntegrityLowerFailed); +} + +#[cfg(feature = "deviations")] +#[test] +fn a_skipped_lowering_is_refused_as_integrity_not_untrusted() { + assert_refused(Deviation::IntegrityNotUntrusted); +} + +/// Started without a package SID, the worker refuses before confining +/// itself, as it does without its Landlock argument on Linux. +#[test] +fn a_missing_package_sid_exits_70_package_sid_argument_missing() { + let output = Command::new(built_worker()) + .stdin(Stdio::null()) + .output() + .expect("run the worker"); + let ended = Ended { + code: output.status.code().expect("an exit code") as u32, + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }; + println!( + "missing: exit {} stderr {}", + ended.code, + ended.stderr.trim() + ); + assert_eq!(ended.code, 70, "{}", ended.stderr); + assert_eq!( + ended.reason(), + Some("package-sid-argument-missing"), + "{}", + ended.stderr + ); +} + +/// A package SID the system's SID parser rejects is a usage error. +#[test] +fn an_unparsable_package_sid_exits_64() { + let output = Command::new(built_worker()) + .arg("--package-sid=S-1-15-2-not-a-sid") + .stdin(Stdio::null()) + .output() + .expect("run the worker"); + let stderr = String::from_utf8_lossy(&output.stderr); + println!("unparsable: {:?} stderr {}", output.status, stderr.trim()); + assert_eq!(output.status.code(), Some(64), "{stderr}"); + assert!(stderr.contains("unparsable --package-sid"), "{stderr}"); +} From 719a92863d45cf0d9234d3fd4fbe88f55b37eada Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:46:07 +0200 Subject: [PATCH 13/13] mason: run the Windows worker tests under a development name The executable-name fence requires test processes to run a ckdev- copy of the worker, never a ck- named binary. The Windows worker tests copy the built worker to ckdev-basal-worker.exe (their own helper, since basal-testkit does not build on Windows yet) and launch or inspect that. The worker-depends-on-a-subc-crate control anchors on basal-worker's Cargo.lock dependency list, which now also names windows-sys; its anchor and replacement include that entry. --- .../basal-worker/tests/windows_common/mod.rs | 33 ++++++++++++++ crates/basal-worker/tests/windows_image.rs | 4 +- crates/basal-worker/tests/windows_startup.rs | 44 +++++++------------ mutations.toml | 2 +- 4 files changed, 54 insertions(+), 29 deletions(-) create mode 100644 crates/basal-worker/tests/windows_common/mod.rs diff --git a/crates/basal-worker/tests/windows_common/mod.rs b/crates/basal-worker/tests/windows_common/mod.rs new file mode 100644 index 0000000..0602c35 --- /dev/null +++ b/crates/basal-worker/tests/windows_common/mod.rs @@ -0,0 +1,33 @@ +//! Shared by the Windows worker tests. +//! +//! Test processes never run under a production executable name (`ck-*`), so +//! a test process can't be mistaken for the placed production fleet. The +//! worker is copied to a `ckdev-` name before it is run or inspected, as +//! basal-testkit's `dev_binary` does on the other systems. + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// A `ckdev-` named copy of the built worker, in a directory of its own so +/// that granting the worker's package access changes no other ACL. A build +/// with the `deviations` feature gets its own directory, so the two test runs +/// never replace each other's image. +pub fn dev_binary(built: &str) -> &'static Path { + static COPY: OnceLock = OnceLock::new(); + COPY.get_or_init(|| { + let built = Path::new(built); + let directory = built.parent().expect("the worker has a directory").join( + if cfg!(feature = "deviations") { + "ckdev-placed-deviations" + } else { + "ckdev-placed" + }, + ); + std::fs::create_dir_all(&directory).expect("create the placement directory"); + let copy = directory.join("ckdev-basal-worker.exe"); + std::fs::copy(built, ©).expect("copy the worker"); + copy + }) +} diff --git a/crates/basal-worker/tests/windows_image.rs b/crates/basal-worker/tests/windows_image.rs index a618b5e..9c20365 100644 --- a/crates/basal-worker/tests/windows_image.rs +++ b/crates/basal-worker/tests/windows_image.rs @@ -13,6 +13,8 @@ use std::path::Path; +mod windows_common; + /// `IMAGE_SUBSYSTEM_WINDOWS_GUI`. const GUI_SUBSYSTEM: u16 = 2; @@ -147,7 +149,7 @@ fn is_c_runtime(name: &str) -> bool { #[test] fn the_worker_is_a_gui_image_without_gui_com_or_c_runtime_imports() { - let path = Path::new(env!("CARGO_BIN_EXE_ck-basal-worker")); + let path = windows_common::dev_binary(env!("CARGO_BIN_EXE_ck-basal-worker")); let image = parse(&std::fs::read(path).expect("read the worker image")); println!("subsystem: {}", image.subsystem); println!("imports: {:?}", image.imports); diff --git a/crates/basal-worker/tests/windows_startup.rs b/crates/basal-worker/tests/windows_startup.rs index 3e18e66..f0ad376 100644 --- a/crates/basal-worker/tests/windows_startup.rs +++ b/crates/basal-worker/tests/windows_startup.rs @@ -19,37 +19,27 @@ use basal_proto::{ }; use std::fs::File; use std::io::Read; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::{Command, Stdio}; use std::sync::OnceLock; -/// The worker binary cargo built for these tests. -fn built_worker() -> &'static Path { - Path::new(env!("CARGO_BIN_EXE_ck-basal-worker")) +mod windows_common; + +/// The worker cargo built for these tests, under a development name. +fn worker() -> &'static Path { + windows_common::dev_binary(env!("CARGO_BIN_EXE_ck-basal-worker")) } -/// The worker, copied into a directory of its own so that the package grant -/// changes no ACL but that directory's. A build with the `deviations` feature -/// gets its own directory, so the two test runs never replace each other's -/// image. +/// The worker, with its directory readable and executable by the worker's +/// package, so that the confined process can load its own image. fn placed_worker() -> &'static Path { - static PLACED: OnceLock = OnceLock::new(); - PLACED.get_or_init(|| { - let directory = built_worker() - .parent() - .expect("the worker has a directory") - .join(if cfg!(feature = "deviations") { - "basal-worker-placed-deviations" - } else { - "basal-worker-placed" - }); - std::fs::create_dir_all(&directory).expect("create the placement directory"); - let placed = directory.join("ck-basal-worker.exe"); - std::fs::copy(built_worker(), &placed).expect("place the worker"); + static GRANTED: OnceLock<()> = OnceLock::new(); + GRANTED.get_or_init(|| { let package = create_or_open_profile().expect("worker profile"); - grant_test_binary_directory(&placed, &package).expect("grant the package read and execute"); - placed - }) + grant_test_binary_directory(worker(), &package) + .expect("grant the package read and execute"); + }); + worker() } fn start(deviation: Deviation) -> ConfinedProcess { @@ -267,7 +257,7 @@ fn an_inherited_handle_beyond_the_pipes_is_refused_as_handle_not_allowed() { use std::os::windows::io::AsRawHandle; use windows_sys::Win32::Foundation::{HANDLE_FLAG_INHERIT, SetHandleInformation}; - let fixture = built_worker() + let fixture = worker() .parent() .expect("the worker has a directory") .join(format!("basal-worker-planted-{}", std::process::id())); @@ -346,7 +336,7 @@ fn a_skipped_lowering_is_refused_as_integrity_not_untrusted() { /// itself, as it does without its Landlock argument on Linux. #[test] fn a_missing_package_sid_exits_70_package_sid_argument_missing() { - let output = Command::new(built_worker()) + let output = Command::new(worker()) .stdin(Stdio::null()) .output() .expect("run the worker"); @@ -371,7 +361,7 @@ fn a_missing_package_sid_exits_70_package_sid_argument_missing() { /// A package SID the system's SID parser rejects is a usage error. #[test] fn an_unparsable_package_sid_exits_64() { - let output = Command::new(built_worker()) + let output = Command::new(worker()) .arg("--package-sid=S-1-15-2-not-a-sid") .stdin(Stdio::null()) .output() diff --git a/mutations.toml b/mutations.toml index df6ae46..65bd603 100644 --- a/mutations.toml +++ b/mutations.toml @@ -547,7 +547,7 @@ edits = [ { file = "Cargo.lock", old = " \"serde\",\n \"serde_json\",\n \"sha2\",\n \"subc-client-rs\",\n \"subc-os\",\n \"subc-protocol\",\n \"subc-transport\",\n \"tokio\",\n \"tracing\",\n", new = " \"serde\",\n \"serde_json\",\n \"sha2\",\n \"subc-client-rs\",\n \"subc-os\",\n \"subc-protocol 0.30.0\",\n \"subc-transport\",\n \"tokio\",\n \"tracing\",\n" }, { file = "Cargo.lock", old = " \"rcgen\",\n \"rusqlite\",\n \"rustls\",\n \"serde_json\",\n \"subc-protocol\",\n", new = " \"rcgen\",\n \"rusqlite\",\n \"rustls\",\n \"serde_json\",\n \"subc-protocol 0.30.0\",\n" }, { file = "Cargo.lock", old = " \"rusqlite\",\n \"serde_json\",\n \"sha2\",\n \"subc-client-rs\",\n \"subc-protocol\",\n \"tokio\",\n]\n", new = " \"rusqlite\",\n \"serde_json\",\n \"sha2\",\n \"subc-client-rs\",\n \"subc-protocol 0.30.0\",\n \"tokio\",\n]\n" }, - { file = "Cargo.lock", old = " \"rquickjs\",\n \"seccompiler\",\n \"serde_json\",\n]\n\n[[package]]\n", new = " \"rquickjs\",\n \"seccompiler\",\n \"serde_json\",\n \"subc-protocol 0.24.1\",\n]\n\n[[package]]\n" }, + { file = "Cargo.lock", old = " \"rquickjs\",\n \"seccompiler\",\n \"serde_json\",\n \"windows-sys 0.61.2\",\n]\n\n[[package]]\n", new = " \"rquickjs\",\n \"seccompiler\",\n \"serde_json\",\n \"subc-protocol 0.24.1\",\n \"windows-sys 0.61.2\",\n]\n\n[[package]]\n" }, { file = "Cargo.lock", old = " \"serde_json\",\n \"subc-control\",\n \"subc-os\",\n \"subc-protocol\",\n \"subc-transport\",\n \"tokio\",\n \"tokio-util\",\n", new = " \"serde_json\",\n \"subc-control\",\n \"subc-os\",\n \"subc-protocol 0.30.0\",\n \"subc-transport\",\n \"tokio\",\n \"tokio-util\",\n" }, { file = "Cargo.lock", old = "dependencies = [\n \"serde\",\n \"serde_json\",\n \"subc-protocol\",\n]\n", new = "dependencies = [\n \"serde\",\n \"serde_json\",\n \"subc-protocol 0.30.0\",\n]\n" }, { file = "Cargo.lock", old = "dependencies = [\n \"libc\",\n \"rustix\",\n]\n\n[[package]]\n", new = "dependencies = [\n \"libc\",\n \"rustix\",\n]\n\n[[package]]\nname = \"subc-protocol\"\nversion = \"0.24.1\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"3b0fb1bc9305afe14d1202970b7e361253dba6108525b356a7e10274436bc80d\"\ndependencies = [\n \"serde\",\n \"serde_json\",\n]\n\n[[package]]\n" },