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/15] 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/15] 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/15] 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/15] 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 56218974aba616bf95cdeb5d3a98a63a3b22bf1b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:46:29 +0200 Subject: [PATCH 05/15] mason: implement Windows host git module and job containment --- crates/basal-host/src/builtins/git.rs | 38 +- crates/basal-host/src/builtins/git/tests.rs | 4 + crates/basal-host/src/builtins/git/windows.rs | 1505 +++++++++++++++++ 3 files changed, 1544 insertions(+), 3 deletions(-) create mode 100644 crates/basal-host/src/builtins/git/windows.rs diff --git a/crates/basal-host/src/builtins/git.rs b/crates/basal-host/src/builtins/git.rs index c718e66..8dea4b7 100644 --- a/crates/basal-host/src/builtins/git.rs +++ b/crates/basal-host/src/builtins/git.rs @@ -15,14 +15,26 @@ #[cfg(test)] mod tests; +pub mod windows; + +#[cfg(not(windows))] use std::io::Read; +#[cfg(not(windows))] use std::os::fd::AsRawFd; +#[cfg(not(windows))] use std::os::unix::process::CommandExt; use std::path::{Component, Path, PathBuf}; -use std::process::{Child, Command, ExitStatus, Stdio}; +#[cfg(not(windows))] +use std::process::Command; +#[cfg(not(windows))] +use std::process::{Child, ExitStatus, Stdio}; +#[cfg(not(windows))] use std::sync::mpsc::{self, Sender}; +#[cfg(not(windows))] use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant}; +use std::time::Duration; +#[cfg(not(windows))] +use std::time::Instant; use basal_proto::Primitive; use serde_json::{Value, json}; @@ -31,7 +43,7 @@ use super::{Denial, codes, expand_home, options, string_arg}; /// How long one git command may run. pub const TIMEOUT: Duration = Duration::from_secs(20); -const STDERR_BYTES: usize = 4096; +pub(crate) const STDERR_BYTES: usize = 4096; /// The most output one git command may produce: a log, a blob or a diff. pub const MAX_OUTPUT_BYTES: usize = super::MAX_TEXT_RESULT_BYTES; /// `git.log`'s default and largest entry counts. @@ -246,6 +258,7 @@ pub fn repo(repo: &str, repos: &[String]) -> Result { /// /// Repositories must belong to the service uid. Global `safe.directory` /// exceptions are deliberately ignored, rather than trusting foreign config. +#[cfg(not(windows))] pub fn hardened_command(repo: &Path) -> Command { let mut command = Command::new("git"); command.env_clear(); @@ -287,6 +300,9 @@ pub fn hardened_command(repo: &Path) -> Command { command } +#[cfg(windows)] +pub use windows::hardened_command; + /// How a git command ended. pub struct Ran { pub success: bool, @@ -297,10 +313,15 @@ pub struct Ran { /// Runs a git command under [`TIMEOUT`], refusing output over /// [`MAX_OUTPUT_BYTES`]. +#[cfg(not(windows))] pub fn run_command(command: Command) -> Result { run_command_until(command, None) } +#[cfg(windows)] +pub use windows::run_command; + +#[cfg(not(windows))] fn run_command_until(mut command: Command, max_lines: Option) -> Result { command.process_group(0); let child = command @@ -383,6 +404,7 @@ fn run_command_until(mut command: Command, max_lines: Option) -> Result Denial { Denial::new( codes::TIMEOUT, @@ -390,17 +412,20 @@ fn timeout_denial() -> Denial { ) } +#[cfg(not(windows))] enum Completion { Exit(std::io::Result), Stdout(Result), Stderr(Result), } +#[cfg(not(windows))] struct ProcessGroup { pid: u32, child: Option, waiter: Option>, } +#[cfg(not(windows))] impl ProcessGroup { fn new(child: Child) -> Self { Self { @@ -425,6 +450,7 @@ impl ProcessGroup { } } } +#[cfg(not(windows))] impl Drop for ProcessGroup { fn drop(&mut self) { self.kill(); @@ -437,11 +463,13 @@ impl Drop for ProcessGroup { } } +#[cfg(not(windows))] struct PipeOutput { bytes: Vec, limit_reached: bool, } +#[cfg(not(windows))] fn drain_pipe( mut pipe: Option, cap: usize, @@ -522,10 +550,14 @@ fn git(repo: &Path, args: &[&str]) -> Result { run_command(command) } +#[cfg(not(windows))] fn run_tags_command(command: Command) -> Result { run_command_until(command, Some(MAX_TAGS)) } +#[cfg(windows)] +use windows::run_tags_command; + fn failed(ran: &Ran) -> Denial { Denial::new(codes::GIT, ran.stderr.trim().to_owned()) } diff --git a/crates/basal-host/src/builtins/git/tests.rs b/crates/basal-host/src/builtins/git/tests.rs index 53862f9..08d1147 100644 --- a/crates/basal-host/src/builtins/git/tests.rs +++ b/crates/basal-host/src/builtins/git/tests.rs @@ -1,4 +1,6 @@ use super::*; +#[cfg(unix)] +use std::process::Stdio; #[test] fn overflowing_or_malformed_log_timestamps_are_refused_not_dropped() { @@ -22,6 +24,7 @@ fn show_distinguishes_missing_blob_from_broken_repository() { } #[test] +#[cfg(unix)] fn tag_output_is_bounded_while_reading_not_after_completion() { let mut command = Command::new("/bin/sh"); command @@ -41,6 +44,7 @@ fn tag_output_is_bounded_while_reading_not_after_completion() { } #[test] +#[cfg(unix)] fn git_waiter_timeout_kills_the_process_group_and_reaps_the_child() { let directory = std::env::temp_dir().join(format!("basal-git-timeout-{}", std::process::id())); std::fs::create_dir_all(&directory).unwrap(); diff --git a/crates/basal-host/src/builtins/git/windows.rs b/crates/basal-host/src/builtins/git/windows.rs new file mode 100644 index 0000000..9338201 --- /dev/null +++ b/crates/basal-host/src/builtins/git/windows.rs @@ -0,0 +1,1505 @@ +//! Windows-specific implementation of git execution, hardening and job containment. +//! +//! Spawns `git.exe` in its own Job Object via `CreateProcessW` + `STARTUPINFOEXW` + +//! `PROC_THREAD_ATTRIBUTE_JOB_LIST`. +//! Isolates environment and configuration using owned empty files/directories. +//! Enforces timeout and output caps by terminating the job object before joining drain threads. + +#![cfg_attr(not(windows), allow(unused))] + +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{OnceLock, mpsc}; +use std::thread; +use std::time::Instant; + +use super::STDERR_BYTES; +pub use super::{MAX_OUTPUT_BYTES, MAX_TAGS, TIMEOUT}; +use super::{Ran, codes}; +use crate::builtins::Denial; + +/// Strips `\\?\` or `//?/` verbatim prefix from Windows paths. +pub fn strip_verbatim_prefix(path: &Path) -> PathBuf { + let s = path.to_string_lossy(); + if let Some(stripped) = s.strip_prefix(r"\\?\") { + PathBuf::from(stripped) + } else if let Some(stripped) = s.strip_prefix("//?/") { + PathBuf::from(stripped) + } else { + path.to_path_buf() + } +} + +/// Checks whether a path is absolute in Windows syntax (e.g. `C:\...` or `\\server\share`). +fn is_windows_absolute(path: &Path) -> bool { + let s = path.to_string_lossy(); + let bytes = s.as_bytes(); + if bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && (bytes[2] == b'\\' || bytes[2] == b'/') + { + return true; + } + if s.starts_with(r"\\") || s.starts_with("//") { + return true; + } + path.is_absolute() +} + +struct IsolationPaths { + _dir: PathBuf, + config_file: PathBuf, + hooks_dir: PathBuf, +} + +static ISOLATION: OnceLock = OnceLock::new(); + +fn isolation_paths() -> &'static IsolationPaths { + ISOLATION.get_or_init(|| { + let dir = std::env::temp_dir().join(format!("basal-git-isolation-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let config_file = dir.join("empty.config"); + let hooks_dir = dir.join("empty.hooks"); + if !config_file.exists() { + let _ = std::fs::write(&config_file, b""); + } + let _ = std::fs::create_dir_all(&hooks_dir); + IsolationPaths { + _dir: dir, + config_file, + hooks_dir, + } + }) +} + +/// The owned empty configuration file used for `GIT_CONFIG_GLOBAL`. +pub fn empty_config_file() -> &'static Path { + &isolation_paths().config_file +} + +/// The owned empty hooks directory used for `core.hooksPath`. +pub fn empty_hooks_dir() -> &'static Path { + &isolation_paths().hooks_dir +} + +/// Resolves git to an absolute `.exe` path, refusing `.bat` or `.cmd` shims. +pub fn resolve_git_binary(program: &str, path_env: Option<&OsStr>) -> Result { + let p = Path::new(program); + // If explicit extension is .bat or .cmd, refuse immediately. + if let Some(ext) = p.extension().and_then(|e| e.to_str()) { + if ext.eq_ignore_ascii_case("bat") || ext.eq_ignore_ascii_case("cmd") { + return Err(Denial::new( + codes::GIT, + format!("git binary {program:?} is a .{ext} script, which is refused"), + )); + } + } + + // If an absolute path is provided, verify it is a valid .exe file. + if is_windows_absolute(p) { + let is_exe = p + .extension() + .and_then(|e| e.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("exe")) + .unwrap_or(false); + if !is_exe { + return Err(Denial::new( + codes::GIT, + format!("git binary at {program:?} is not a .exe executable"), + )); + } + if p.is_file() { + return Ok(strip_verbatim_prefix(p)); + } + return Err(Denial::new( + codes::GIT, + format!("git binary at {program:?} does not exist"), + )); + } + + // Search PATH directories. + let base_name = p.file_stem().and_then(|s| s.to_str()).unwrap_or(program); + + if let Some(path_val) = path_env { + let dirs: Vec = if cfg!(windows) { + std::env::split_paths(path_val).collect() + } else { + let s = path_val.to_string_lossy(); + let delimiter = if s.contains(';') { ';' } else { ':' }; + s.split(delimiter).map(PathBuf::from).collect() + }; + + for dir in dirs { + let exe_candidate = dir.join(format!("{base_name}.exe")); + let cmd_candidate = dir.join(format!("{base_name}.cmd")); + let bat_candidate = dir.join(format!("{base_name}.bat")); + + if exe_candidate.is_file() { + return Ok(strip_verbatim_prefix(&exe_candidate)); + } + if cmd_candidate.is_file() { + return Err(Denial::new( + codes::GIT, + format!( + "git resolved to {cmd_candidate:?}, which is a .cmd script and is refused" + ), + )); + } + if bat_candidate.is_file() { + return Err(Denial::new( + codes::GIT, + format!( + "git resolved to {bat_candidate:?}, which is a .bat script and is refused" + ), + )); + } + } + } + + // Check standard Windows Git locations as fallback + #[cfg(windows)] + for standard_path in [ + r"C:\Program Files\Git\cmd\git.exe", + r"C:\Program Files\Git\bin\git.exe", + r"C:\Program Files (x86)\Git\cmd\git.exe", + r"C:\Program Files (x86)\Git\bin\git.exe", + ] { + let pb = PathBuf::from(standard_path); + if pb.is_file() { + return Ok(pb); + } + } + + Err(Denial::new( + codes::GIT, + "git.exe could not be found in PATH or standard install locations".to_string(), + )) +} + +static RESOLVED_GIT: OnceLock> = OnceLock::new(); + +/// Resolves `git.exe` once to an absolute `.exe` path. +pub fn resolve_git() -> Result<&'static Path, Denial> { + let res = + RESOLVED_GIT.get_or_init(|| resolve_git_binary("git", std::env::var_os("PATH").as_deref())); + match res { + Ok(p) => Ok(p.as_path()), + Err(e) => Err(e.clone()), + } +} + +/// Computes the parent directory of a Windows path, supporting both `\` and `/`. +pub fn windows_parent(path: &Path) -> Option { + if cfg!(windows) { + path.parent().map(|p| p.to_path_buf()) + } else { + let s = path.to_string_lossy(); + if let Some(idx) = s.rfind(|c| c == '\\' || c == '/') { + if idx == 0 { + Some(PathBuf::from(&s[..1])) + } else { + Some(PathBuf::from(&s[..idx])) + } + } else { + None + } + } +} + +/// The only way the built-ins run git on Windows: a `git -C ` command +/// that can read the repository and run nothing else. +pub fn hardened_command(repo: &Path) -> Command { + let git_prog = resolve_git() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|_| PathBuf::from("git.exe")); + let mut command = Command::new(git_prog); + command.env_clear(); + for var in ["PATH", "SystemRoot", "USERPROFILE"] { + if let Some(val) = std::env::var_os(var) { + command.env(var, val); + } + } + command + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", empty_config_file()) + .env("LC_ALL", "C") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_NO_LAZY_FETCH", "1"); + let clean_repo = strip_verbatim_prefix(repo); + if let Some(parent) = windows_parent(&clean_repo) { + command.env("GIT_CEILING_DIRECTORIES", parent); + } + let hooks_arg = format!("core.hooksPath={}", empty_hooks_dir().display()); + command + .arg("-C") + .arg(clean_repo) + .args([ + "--no-optional-locks", + "--no-pager", + "--literal-pathspecs", + "-c", + "core.fsmonitor=false", + "-c", + &hooks_arg, + "-c", + "core.pager=cat", + "-c", + "log.showSignature=false", + "-c", + "protocol.allow=never", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command +} + +/// Escapes a single argument according to Microsoft `CommandLineToArgvW` rules. +pub fn append_windows_arg(cmd: &mut String, arg: &str) { + if !arg.is_empty() && !arg.contains([' ', '\t', '\n', '\x0b', '\"']) { + cmd.push_str(arg); + return; + } + cmd.push('"'); + let mut backslashes: usize = 0; + for c in arg.chars() { + if c == '\\' { + backslashes += 1; + } else { + if c == '"' { + for _ in 0..backslashes * 2 + 1 { + cmd.push('\\'); + } + } else { + for _ in 0..backslashes { + cmd.push('\\'); + } + cmd.push(c); + } + backslashes = 0; + } + } + for _ in 0..backslashes * 2 { + cmd.push('\\'); + } + cmd.push('"'); +} + +fn to_wide_null(s: impl AsRef) -> Vec { + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + s.as_ref().encode_wide().chain(Some(0)).collect() + } + #[cfg(not(windows))] + { + s.as_ref() + .to_str() + .unwrap_or("") + .encode_utf16() + .chain(Some(0)) + .collect() + } +} + +fn timeout_denial() -> Denial { + Denial::new( + codes::TIMEOUT, + format!("git ran longer than {} s", TIMEOUT.as_secs()), + ) +} + +#[cfg(windows)] +mod ffi { + pub use std::ffi::c_void; + + pub type BOOL = i32; + pub type HANDLE = *mut c_void; + pub const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE; + + pub const TRUE: BOOL = 1; + pub const FALSE: BOOL = 0; + + pub const STILL_ACTIVE: u32 = 259; + pub const WAIT_OBJECT_0: u32 = 0; + pub const INFINITE: u32 = 0xFFFFFFFF; + + pub const HANDLE_FLAG_INHERIT: u32 = 0x00000001; + + pub const STARTF_USESTDHANDLES: u32 = 0x00000100; + pub const EXTENDED_STARTUPINFO_PRESENT: u32 = 0x00080000; + pub const CREATE_NO_WINDOW: u32 = 0x08000000; + pub const CREATE_UNICODE_ENVIRONMENT: u32 = 0x00000400; + + pub const PROC_THREAD_ATTRIBUTE_JOB_LIST: usize = 0x0002000D; + pub const PROC_THREAD_ATTRIBUTE_HANDLE_LIST: usize = 0x00020002; + + pub const JobObjectExtendedLimitInformation: u32 = 9; + pub const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: u32 = 0x00002000; + + pub const FILE_GENERIC_READ: u32 = 0x00120089; + pub const FILE_SHARE_READ: u32 = 0x00000001; + pub const FILE_SHARE_WRITE: u32 = 0x00000002; + pub const FILE_SHARE_DELETE: u32 = 0x00000004; + pub const OPEN_EXISTING: u32 = 3; + pub const FILE_ATTRIBUTE_NORMAL: u32 = 0x00000080; + + pub const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + pub const SYNCHRONIZE: u32 = 0x00100000; + + #[repr(C)] + pub struct SECURITY_ATTRIBUTES { + pub nLength: u32, + pub lpSecurityDescriptor: *mut c_void, + pub bInheritHandle: BOOL, + } + + #[repr(C)] + #[derive(Default, Clone, Copy)] + pub struct IO_COUNTERS { + pub ReadOperationCount: u64, + pub WriteOperationCount: u64, + pub OtherOperationCount: u64, + pub ReadTransferCount: u64, + pub WriteTransferCount: u64, + pub OtherTransferCount: u64, + } + + #[repr(C)] + #[derive(Default, Clone, Copy)] + pub struct JOBOBJECT_BASIC_LIMIT_INFORMATION { + pub PerProcessUserTimeLimit: i64, + pub PerJobUserTimeLimit: i64, + pub LimitFlags: u32, + pub MinimumWorkingSetSize: usize, + pub MaximumWorkingSetSize: usize, + pub ActiveProcessLimit: u32, + pub Affinity: usize, + pub PriorityClass: u32, + pub SchedulingClass: u32, + } + + #[repr(C)] + #[derive(Default, Clone, Copy)] + pub struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION { + pub BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION, + pub IoInfo: IO_COUNTERS, + pub ProcessMemoryLimit: usize, + pub JobMemoryLimit: usize, + pub PeakProcessMemoryUsed: usize, + pub PeakJobMemoryUsed: usize, + } + + #[repr(C)] + pub struct STARTUPINFOW { + pub cb: u32, + pub lpReserved: *mut u16, + pub lpDesktop: *mut u16, + pub lpTitle: *mut u16, + pub dwX: u32, + pub dwY: u32, + pub dwXSize: u32, + pub dwYSize: u32, + pub dwXCountChars: u32, + pub dwYCountChars: u32, + pub dwFillAttribute: u32, + pub dwFlags: u32, + pub wShowWindow: u16, + pub cbReserved2: u16, + pub lpReserved2: *mut u8, + pub hStdInput: HANDLE, + pub hStdOutput: HANDLE, + pub hStdError: HANDLE, + } + + #[repr(C)] + pub struct STARTUPINFOEXW { + pub StartupInfo: STARTUPINFOW, + pub lpAttributeList: *mut c_void, + } + + #[repr(C)] + pub struct PROCESS_INFORMATION { + pub hProcess: HANDLE, + pub hThread: HANDLE, + pub dwProcessId: u32, + pub dwThreadId: u32, + } + + unsafe extern "system" { + pub fn CloseHandle(hObject: HANDLE) -> BOOL; + pub fn SetHandleInformation(hObject: HANDLE, dwMask: u32, dwFlags: u32) -> BOOL; + + pub fn CreatePipe( + hReadPipe: *mut HANDLE, + hWritePipe: *mut HANDLE, + lpPipeAttributes: *const SECURITY_ATTRIBUTES, + nSize: u32, + ) -> BOOL; + + pub fn ReadFile( + hFile: HANDLE, + lpBuffer: *mut c_void, + nNumberOfBytesToRead: u32, + lpNumberOfBytesRead: *mut u32, + lpOverlapped: *mut c_void, + ) -> BOOL; + + pub fn CreateFileW( + lpFileName: *const u16, + dwDesiredAccess: u32, + dwShareMode: u32, + lpSecurityAttributes: *const SECURITY_ATTRIBUTES, + dwCreationDisposition: u32, + dwFlagsAndAttributes: u32, + hTemplateFile: HANDLE, + ) -> HANDLE; + + pub fn CreateJobObjectW( + lpJobAttributes: *const SECURITY_ATTRIBUTES, + lpName: *const u16, + ) -> HANDLE; + + pub fn SetInformationJobObject( + hJob: HANDLE, + JobObjectInformationClass: u32, + lpJobObjectInformation: *const c_void, + cbJobObjectInformationLength: u32, + ) -> BOOL; + + pub fn TerminateJobObject(hJob: HANDLE, uExitCode: u32) -> BOOL; + + pub fn InitializeProcThreadAttributeList( + lpAttributeList: *mut c_void, + dwAttributeCount: u32, + dwFlags: u32, + lpSize: *mut usize, + ) -> BOOL; + + pub fn UpdateProcThreadAttribute( + lpAttributeList: *mut c_void, + dwFlags: u32, + Attribute: usize, + lpValue: *const c_void, + cbSize: usize, + lpPreviousValue: *mut c_void, + lpReturnSize: *const usize, + ) -> BOOL; + + pub fn DeleteProcThreadAttributeList(lpAttributeList: *mut c_void); + + pub fn CreateProcessW( + lpApplicationName: *const u16, + lpCommandLine: *mut u16, + lpProcessAttributes: *const SECURITY_ATTRIBUTES, + lpThreadAttributes: *const SECURITY_ATTRIBUTES, + bInheritHandles: BOOL, + dwCreationFlags: u32, + lpEnvironment: *const c_void, + lpCurrentDirectory: *const u16, + lpStartupInfo: *const STARTUPINFOEXW, + lpProcessInformation: *mut PROCESS_INFORMATION, + ) -> BOOL; + + pub fn WaitForSingleObject(hHandle: HANDLE, dwMilliseconds: u32) -> u32; + + pub fn GetExitCodeProcess(hProcess: HANDLE, lpExitCode: *mut u32) -> BOOL; + + pub fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: BOOL, dwProcessId: u32) -> HANDLE; + } +} + +#[cfg(windows)] +pub struct OwnedHandle(pub ffi::HANDLE); + +#[cfg(windows)] +unsafe impl Send for OwnedHandle {} + +#[cfg(windows)] +impl OwnedHandle { + pub fn raw(&self) -> ffi::HANDLE { + self.0 + } +} + +#[cfg(windows)] +impl Drop for OwnedHandle { + fn drop(&mut self) { + if !self.0.is_null() && self.0 != ffi::INVALID_HANDLE_VALUE { + unsafe { ffi::CloseHandle(self.0) }; + self.0 = ffi::INVALID_HANDLE_VALUE; + } + } +} + +#[cfg(windows)] +struct AttributeList { + buffer: Vec, +} + +#[cfg(windows)] +impl AttributeList { + fn new(count: u32) -> Result { + let mut size: usize = 0; + unsafe { + ffi::InitializeProcThreadAttributeList(std::ptr::null_mut(), count, 0, &mut size); + } + let mut buffer = vec![0usize; size.div_ceil(std::mem::size_of::())]; + let ok = unsafe { + ffi::InitializeProcThreadAttributeList(buffer.as_mut_ptr().cast(), count, 0, &mut size) + }; + if ok == 0 { + return Err(Denial::new( + codes::GIT, + format!( + "InitializeProcThreadAttributeList failed: {}", + std::io::Error::last_os_error() + ), + )); + } + Ok(Self { buffer }) + } + + fn as_mut_ptr(&mut self) -> *mut std::ffi::c_void { + self.buffer.as_mut_ptr().cast() + } + + fn set_job_list(&mut self, job: ffi::HANDLE) -> Result<(), Denial> { + let mut jobs = [job]; + let ok = unsafe { + ffi::UpdateProcThreadAttribute( + self.as_mut_ptr(), + 0, + ffi::PROC_THREAD_ATTRIBUTE_JOB_LIST, + jobs.as_mut_ptr().cast(), + std::mem::size_of::(), + std::ptr::null_mut(), + std::ptr::null(), + ) + }; + if ok == 0 { + return Err(Denial::new( + codes::GIT, + format!( + "UpdateProcThreadAttribute(JOB_LIST) failed: {}", + std::io::Error::last_os_error() + ), + )); + } + Ok(()) + } + + fn set_handle_list(&mut self, handles: &[ffi::HANDLE]) -> Result<(), Denial> { + let ok = unsafe { + ffi::UpdateProcThreadAttribute( + self.as_mut_ptr(), + 0, + ffi::PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + handles.as_ptr().cast(), + handles.len() * std::mem::size_of::(), + std::ptr::null_mut(), + std::ptr::null(), + ) + }; + if ok == 0 { + return Err(Denial::new( + codes::GIT, + format!( + "UpdateProcThreadAttribute(HANDLE_LIST) failed: {}", + std::io::Error::last_os_error() + ), + )); + } + Ok(()) + } +} + +#[cfg(windows)] +impl Drop for AttributeList { + fn drop(&mut self) { + unsafe { + ffi::DeleteProcThreadAttributeList(self.as_mut_ptr()); + } + } +} + +#[cfg(windows)] +pub struct ProcessJob { + pub process: ffi::HANDLE, + pub job: ffi::HANDLE, + terminated: bool, +} + +#[cfg(windows)] +unsafe impl Send for ProcessJob {} + +#[cfg(windows)] +impl ProcessJob { + pub fn new(process: ffi::HANDLE, job: ffi::HANDLE) -> Self { + Self { + process, + job, + terminated: false, + } + } + + pub fn terminate(&mut self) { + if !self.terminated { + self.terminated = true; + if !self.job.is_null() && self.job != ffi::INVALID_HANDLE_VALUE { + unsafe { + ffi::TerminateJobObject(self.job, 1); + } + } + } + } +} + +#[cfg(windows)] +impl Drop for ProcessJob { + fn drop(&mut self) { + self.terminate(); + if !self.process.is_null() && self.process != ffi::INVALID_HANDLE_VALUE { + unsafe { + ffi::CloseHandle(self.process); + } + self.process = ffi::INVALID_HANDLE_VALUE; + } + if !self.job.is_null() && self.job != ffi::INVALID_HANDLE_VALUE { + unsafe { + ffi::CloseHandle(self.job); + } + self.job = ffi::INVALID_HANDLE_VALUE; + } + } +} + +struct PipeOutput { + bytes: Vec, + limit_reached: bool, +} + +#[cfg(windows)] +fn drain_pipe( + handle: ffi::HANDLE, + cap: usize, + max_lines: Option, +) -> Result { + let mut output = PipeOutput { + bytes: Vec::new(), + limit_reached: false, + }; + if handle.is_null() || handle == ffi::INVALID_HANDLE_VALUE { + return Ok(output); + } + let mut chunk = [0u8; STDERR_BYTES]; + loop { + let mut bytes_read: u32 = 0; + let ok = unsafe { + ffi::ReadFile( + handle, + chunk.as_mut_ptr().cast(), + chunk.len() as u32, + &mut bytes_read, + std::ptr::null_mut(), + ) + }; + if ok == 0 || bytes_read == 0 { + return Ok(output); + } + let n = bytes_read as usize; + let room = cap.saturating_sub(output.bytes.len()); + output.bytes.extend_from_slice(&chunk[..n.min(room)]); + if let Some(limit) = max_lines + && let Some((last, _)) = output + .bytes + .iter() + .enumerate() + .filter(|(_, byte)| **byte == b'\n') + .nth(limit - 1) + { + output.bytes.truncate(last + 1); + output.limit_reached = true; + return Ok(output); + } + if cap > STDERR_BYTES && output.bytes.len() > MAX_OUTPUT_BYTES { + return Err(Denial::new( + codes::TOO_LARGE, + format!("git's output is larger than {MAX_OUTPUT_BYTES} bytes"), + )); + } + } +} + +#[cfg(windows)] +fn spawn_job_command(command: Command) -> Result<(ProcessJob, OwnedHandle, OwnedHandle), Denial> { + let program = command.get_program(); + let program_str = program.to_string_lossy(); + let lower_prog = program_str.to_ascii_lowercase(); + + if lower_prog.ends_with(".bat") || lower_prog.ends_with(".cmd") { + return Err(Denial::new( + codes::GIT, + format!("{program_str:?} is a .bat or .cmd script, which is refused"), + )); + } + + let resolved_prog = if lower_prog == "git" || lower_prog == "git.exe" { + resolve_git()?.to_path_buf() + } else { + PathBuf::from(program) + }; + + let mut cmdline = String::new(); + append_windows_arg(&mut cmdline, &resolved_prog.to_string_lossy()); + for arg in command.get_args() { + cmdline.push(' '); + append_windows_arg(&mut cmdline, &arg.to_string_lossy()); + } + let mut cmdline_wide: Vec = to_wide_null(&cmdline); + + let mut env_map = BTreeMap::new(); + let is_hardened = command + .get_envs() + .any(|(k, v)| k == "GIT_CONFIG_NOSYSTEM" && v.is_some()); + if !is_hardened { + for (k, v) in std::env::vars_os() { + env_map.insert(k, v); + } + } + for (k, v) in command.get_envs() { + if let Some(val) = v { + env_map.insert(k.to_os_string(), val.to_os_string()); + } else { + env_map.remove(k); + } + } + + let mut entries: Vec<(OsString, OsString)> = env_map.into_iter().collect(); + entries.sort_by(|(k1, _), (k2, _)| { + k1.to_string_lossy() + .to_ascii_uppercase() + .cmp(&k2.to_string_lossy().to_ascii_uppercase()) + }); + + let mut env_block: Vec = Vec::new(); + for (k, v) in &entries { + let k_str = k.to_string_lossy(); + let v_str = v.to_string_lossy(); + for ch in k_str.encode_utf16() { + env_block.push(ch); + } + env_block.push('=' as u16); + for ch in v_str.encode_utf16() { + env_block.push(ch); + } + env_block.push(0); + } + env_block.push(0); + + let cwd_wide = command.get_current_dir().map(|p| { + let clean = strip_verbatim_prefix(p); + to_wide_null(&clean) + }); + let cwd_ptr = cwd_wide + .as_ref() + .map(|v| v.as_ptr()) + .unwrap_or(std::ptr::null()); + + let job = unsafe { ffi::CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if job.is_null() { + return Err(Denial::new( + codes::GIT, + format!( + "CreateJobObjectW failed: {}", + std::io::Error::last_os_error() + ), + )); + } + + let mut limits: ffi::JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + limits.BasicLimitInformation.LimitFlags = ffi::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let ok = unsafe { + ffi::SetInformationJobObject( + job, + ffi::JobObjectExtendedLimitInformation, + &limits as *const _ as *const ffi::c_void, + std::mem::size_of::() as u32, + ) + }; + if ok == 0 { + let err = std::io::Error::last_os_error(); + unsafe { ffi::CloseHandle(job) }; + return Err(Denial::new( + codes::GIT, + format!("SetInformationJobObject failed: {err}"), + )); + } + + let sa_inherit = ffi::SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: std::ptr::null_mut(), + bInheritHandle: ffi::TRUE, + }; + + let empty_file_wide = to_wide_null(empty_config_file()); + let mut stdin_handle = unsafe { + ffi::CreateFileW( + empty_file_wide.as_ptr(), + ffi::FILE_GENERIC_READ, + ffi::FILE_SHARE_READ | ffi::FILE_SHARE_WRITE | ffi::FILE_SHARE_DELETE, + &sa_inherit, + ffi::OPEN_EXISTING, + ffi::FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ) + }; + if stdin_handle == ffi::INVALID_HANDLE_VALUE { + let mut pipe_read = std::ptr::null_mut(); + let mut pipe_write = std::ptr::null_mut(); + unsafe { + ffi::CreatePipe(&mut pipe_read, &mut pipe_write, &sa_inherit, 0); + ffi::CloseHandle(pipe_write); + } + stdin_handle = pipe_read; + } + + let mut stdout_read = std::ptr::null_mut(); + let mut stdout_write = std::ptr::null_mut(); + if unsafe { ffi::CreatePipe(&mut stdout_read, &mut stdout_write, &sa_inherit, 0) } == 0 { + let err = std::io::Error::last_os_error(); + unsafe { + if stdin_handle != ffi::INVALID_HANDLE_VALUE { + ffi::CloseHandle(stdin_handle); + } + ffi::CloseHandle(job); + } + return Err(Denial::new( + codes::GIT, + format!("CreatePipe stdout failed: {err}"), + )); + } + unsafe { ffi::SetHandleInformation(stdout_read, ffi::HANDLE_FLAG_INHERIT, 0) }; + + let mut stderr_read = std::ptr::null_mut(); + let mut stderr_write = std::ptr::null_mut(); + if unsafe { ffi::CreatePipe(&mut stderr_read, &mut stderr_write, &sa_inherit, 0) } == 0 { + let err = std::io::Error::last_os_error(); + unsafe { + if stdin_handle != ffi::INVALID_HANDLE_VALUE { + ffi::CloseHandle(stdin_handle); + } + ffi::CloseHandle(stdout_read); + ffi::CloseHandle(stdout_write); + ffi::CloseHandle(job); + } + return Err(Denial::new( + codes::GIT, + format!("CreatePipe stderr failed: {err}"), + )); + } + unsafe { ffi::SetHandleInformation(stderr_read, ffi::HANDLE_FLAG_INHERIT, 0) }; + + let mut attr_list = match AttributeList::new(2) { + Ok(list) => list, + Err(e) => { + unsafe { + if stdin_handle != ffi::INVALID_HANDLE_VALUE { + ffi::CloseHandle(stdin_handle); + } + ffi::CloseHandle(stdout_read); + ffi::CloseHandle(stdout_write); + ffi::CloseHandle(stderr_read); + ffi::CloseHandle(stderr_write); + ffi::CloseHandle(job); + } + return Err(e); + } + }; + if let Err(e) = attr_list.set_job_list(job) { + unsafe { + if stdin_handle != ffi::INVALID_HANDLE_VALUE { + ffi::CloseHandle(stdin_handle); + } + ffi::CloseHandle(stdout_read); + ffi::CloseHandle(stdout_write); + ffi::CloseHandle(stderr_read); + ffi::CloseHandle(stderr_write); + ffi::CloseHandle(job); + } + return Err(e); + } + let handles = [stdin_handle, stdout_write, stderr_write]; + if let Err(e) = attr_list.set_handle_list(&handles) { + unsafe { + if stdin_handle != ffi::INVALID_HANDLE_VALUE { + ffi::CloseHandle(stdin_handle); + } + ffi::CloseHandle(stdout_read); + ffi::CloseHandle(stdout_write); + ffi::CloseHandle(stderr_read); + ffi::CloseHandle(stderr_write); + ffi::CloseHandle(job); + } + return Err(e); + } + + let mut startup: ffi::STARTUPINFOEXW = unsafe { std::mem::zeroed() }; + startup.StartupInfo.cb = std::mem::size_of::() as u32; + startup.StartupInfo.dwFlags = ffi::STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = stdin_handle; + startup.StartupInfo.hStdOutput = stdout_write; + startup.StartupInfo.hStdError = stderr_write; + startup.lpAttributeList = attr_list.as_mut_ptr(); + + let mut pi: ffi::PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + let creation_flags = + ffi::EXTENDED_STARTUPINFO_PRESENT | ffi::CREATE_NO_WINDOW | ffi::CREATE_UNICODE_ENVIRONMENT; + + let ok = unsafe { + ffi::CreateProcessW( + std::ptr::null(), + cmdline_wide.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + ffi::TRUE, + creation_flags, + env_block.as_ptr().cast(), + cwd_ptr, + &startup, + &mut pi, + ) + }; + + unsafe { + if stdin_handle != ffi::INVALID_HANDLE_VALUE { + ffi::CloseHandle(stdin_handle); + } + ffi::CloseHandle(stdout_write); + ffi::CloseHandle(stderr_write); + } + + if ok == 0 { + let err = std::io::Error::last_os_error(); + unsafe { + ffi::CloseHandle(stdout_read); + ffi::CloseHandle(stderr_read); + ffi::CloseHandle(job); + } + return Err(Denial::new( + codes::GIT, + format!("git could not start: {err}"), + )); + } + + unsafe { + ffi::CloseHandle(pi.hThread); + } + + let guard = ProcessJob::new(pi.hProcess, job); + let stdout_pipe = OwnedHandle(stdout_read); + let stderr_pipe = OwnedHandle(stderr_read); + Ok((guard, stdout_pipe, stderr_pipe)) +} + +#[cfg(windows)] +enum Completion { + Exit(Result), + Stdout(Result), + Stderr(Result), +} + +#[cfg(windows)] +pub fn run_command_until(command: Command, max_lines: Option) -> Result { + let (mut guard, stdout_read, stderr_read) = spawn_job_command(command)?; + let stdout_raw = stdout_read.raw() as usize; + let stderr_raw = stderr_read.raw() as usize; + let process_raw = guard.process as usize; + + let deadline = Instant::now() + TIMEOUT; + let (tx, completed) = mpsc::channel(); + + thread::scope(|scope| { + let exit_tx = tx.clone(); + scope.spawn(move || { + let process_handle = process_raw as ffi::HANDLE; + let wait_res = unsafe { ffi::WaitForSingleObject(process_handle, ffi::INFINITE) }; + if wait_res == ffi::WAIT_OBJECT_0 { + let mut exit_code: u32 = 0; + let ok = unsafe { ffi::GetExitCodeProcess(process_handle, &mut exit_code) }; + if ok != 0 { + let _ = exit_tx.send(Completion::Exit(Ok(exit_code))); + } else { + let _ = exit_tx.send(Completion::Exit(Err(Denial::new( + codes::GIT, + format!( + "GetExitCodeProcess failed: {}", + std::io::Error::last_os_error() + ), + )))); + } + } else { + let _ = exit_tx.send(Completion::Exit(Err(Denial::new( + codes::GIT, + "WaitForSingleObject on git process failed", + )))); + } + }); + + let out_tx = tx.clone(); + scope.spawn(move || { + let stdout_handle = stdout_raw as ffi::HANDLE; + let res = drain_pipe(stdout_handle, MAX_OUTPUT_BYTES + 1, max_lines); + let _ = out_tx.send(Completion::Stdout(res)); + }); + + let err_tx = tx.clone(); + scope.spawn(move || { + let stderr_handle = stderr_raw as ffi::HANDLE; + let res = drain_pipe(stderr_handle, STDERR_BYTES, None); + let _ = err_tx.send(Completion::Stderr(res)); + }); + + let mut exit_code: Option = None; + let mut stdout: Option> = None; + let mut stderr: Option> = None; + let mut limit_reached = false; + let mut early_err: Option = None; + + while exit_code.is_none() || stdout.is_none() || stderr.is_none() { + let remaining = deadline.saturating_duration_since(Instant::now()); + match completed.recv_timeout(remaining) { + Ok(Completion::Exit(result)) => { + match result { + Ok(code) => exit_code = Some(code), + Err(e) => { + if early_err.is_none() { + early_err = Some(e); + } + exit_code = Some(1); + } + } + guard.terminate(); + } + Ok(Completion::Stdout(result)) => match result { + Ok(output) => { + limit_reached = output.limit_reached; + if limit_reached { + guard.terminate(); + } + stdout = Some(output.bytes); + } + Err(e) => { + if early_err.is_none() { + early_err = Some(e); + } + guard.terminate(); + stdout = Some(Vec::new()); + } + }, + Ok(Completion::Stderr(result)) => match result { + Ok(output) => stderr = Some(output.bytes), + Err(e) => { + if early_err.is_none() { + early_err = Some(e); + } + guard.terminate(); + stderr = Some(Vec::new()); + } + }, + Err(mpsc::RecvTimeoutError::Timeout) => { + guard.terminate(); + return Err(timeout_denial()); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + guard.terminate(); + return Err(Denial::new(codes::GIT, "git waiter disconnected")); + } + } + } + + // The job is terminated before any drain thread is joined + guard.terminate(); + + if let Some(err) = early_err { + return Err(err); + } + + let code = exit_code.unwrap(); + let success = limit_reached || code == 0; + Ok(Ran { + success, + code: if limit_reached { + Some(0) + } else { + Some(code as i32) + }, + stdout: stdout.unwrap(), + stderr: String::from_utf8_lossy(&stderr.unwrap()).into_owned(), + }) + }) +} + +#[cfg(windows)] +pub fn run_command(command: Command) -> Result { + run_command_until(command, None) +} + +#[cfg(windows)] +pub fn run_tags_command(command: Command) -> Result { + run_command_until(command, Some(MAX_TAGS)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_strip_verbatim_prefix() { + assert_eq!( + strip_verbatim_prefix(Path::new(r"\\?\C:\repo")), + PathBuf::from(r"C:\repo") + ); + assert_eq!( + strip_verbatim_prefix(Path::new("//?/C:/repo")), + PathBuf::from("C:/repo") + ); + assert_eq!( + strip_verbatim_prefix(Path::new(r"C:\repo")), + PathBuf::from(r"C:\repo") + ); + } + + #[test] + fn windows_git_resolution_refuses_bat_and_cmd() { + assert!( + resolve_git_binary("git.bat", None) + .unwrap_err() + .message + .contains("refused") + ); + assert!( + resolve_git_binary("git.cmd", None) + .unwrap_err() + .message + .contains("refused") + ); + assert!( + resolve_git_binary(r"C:\bin\git.bat", None) + .unwrap_err() + .message + .contains("refused") + ); + assert!( + resolve_git_binary(r"C:\bin\git.cmd", None) + .unwrap_err() + .message + .contains("refused") + ); + assert!( + resolve_git_binary(r"C:\bin\git.BAT", None) + .unwrap_err() + .message + .contains("refused") + ); + assert!( + resolve_git_binary(r"C:\bin\git.CMD", None) + .unwrap_err() + .message + .contains("refused") + ); + + let temp = std::env::temp_dir().join(format!("basal-git-test-res-{}", std::process::id())); + let _ = std::fs::create_dir_all(&temp); + let bad_dir = temp.join("bad"); + let good_dir = temp.join("good"); + std::fs::create_dir_all(&bad_dir).unwrap(); + std::fs::create_dir_all(&good_dir).unwrap(); + + std::fs::write(bad_dir.join("git.cmd"), b"@echo off\r\n").unwrap(); + std::fs::write(good_dir.join("git.exe"), b"MZ...").unwrap(); + + let path_bad_first = format!("{};{}", bad_dir.display(), good_dir.display()); + let res_bad = resolve_git_binary("git", Some(OsStr::new(&path_bad_first))); + assert!( + res_bad.as_ref().unwrap_err().message.contains("refused"), + "Must refuse git.cmd earlier in PATH" + ); + + let path_good_first = format!("{};{}", good_dir.display(), bad_dir.display()); + let res_good = resolve_git_binary("git", Some(OsStr::new(&path_good_first))); + assert!(res_good.is_ok(), "Must resolve git.exe"); + assert_eq!(res_good.unwrap(), good_dir.join("git.exe")); + + let _ = std::fs::remove_dir_all(&temp); + } + + #[test] + fn windows_argv_and_environment() { + let repo = Path::new(r"\\?\C:\test\repo"); + let command = hardened_command(repo); + + let prog = command.get_program().to_string_lossy(); + assert!( + prog.ends_with(".exe") || prog == "git.exe", + "Program must be .exe, got: {prog}" + ); + assert!(!prog.ends_with(".bat")); + assert!(!prog.ends_with(".cmd")); + + let args: Vec = command + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + + assert_eq!(args[0], "-C"); + assert_eq!(args[1], r"C:\test\repo"); + assert!(!args[1].starts_with(r"\\?\")); + assert!(!args[1].starts_with("//?/")); + + assert!(args.contains(&"--no-optional-locks".to_string())); + assert!(args.contains(&"--no-pager".to_string())); + assert!(args.contains(&"--literal-pathspecs".to_string())); + assert!(args.contains(&"core.fsmonitor=false".to_string())); + assert!(args.contains(&"core.pager=cat".to_string())); + assert!(args.contains(&"log.showSignature=false".to_string())); + assert!(args.contains(&"protocol.allow=never".to_string())); + + let hooks_arg = args + .iter() + .find(|a| a.starts_with("core.hooksPath=")) + .expect("core.hooksPath argument must be present"); + let hooks_val = hooks_arg.strip_prefix("core.hooksPath=").unwrap(); + assert_ne!(hooks_val, "/dev/null"); + assert_ne!(hooks_val, "NUL"); + assert_ne!(hooks_val, "nul"); + let hooks_path = Path::new(hooks_val); + assert!( + hooks_path.is_dir(), + "core.hooksPath must be an existing directory" + ); + assert_eq!( + std::fs::read_dir(hooks_path).unwrap().count(), + 0, + "core.hooksPath must be empty" + ); + + let envs: BTreeMap> = command + .get_envs() + .map(|(k, v)| { + ( + k.to_string_lossy().into_owned(), + v.map(|s| s.to_string_lossy().into_owned()), + ) + }) + .collect(); + + assert_eq!( + envs.get("GIT_CONFIG_NOSYSTEM"), + Some(&Some("1".to_string())) + ); + assert_eq!(envs.get("LC_ALL"), Some(&Some("C".to_string()))); + assert_eq!( + envs.get("GIT_TERMINAL_PROMPT"), + Some(&Some("0".to_string())) + ); + assert_eq!(envs.get("GIT_NO_LAZY_FETCH"), Some(&Some("1".to_string()))); + + let ceiling = envs + .get("GIT_CEILING_DIRECTORIES") + .expect("GIT_CEILING_DIRECTORIES must be set") + .as_ref() + .unwrap(); + assert_eq!(ceiling, r"C:\test"); + assert!(!ceiling.starts_with(r"\\?\")); + + let global_config = envs + .get("GIT_CONFIG_GLOBAL") + .expect("GIT_CONFIG_GLOBAL must be set") + .as_ref() + .unwrap(); + assert_ne!(global_config, "/dev/null"); + assert_ne!(global_config, "NUL"); + assert_ne!(global_config, "nul"); + let cfg_path = Path::new(global_config); + assert!( + cfg_path.is_file(), + "GIT_CONFIG_GLOBAL must be an existing file" + ); + assert_eq!( + std::fs::metadata(cfg_path).unwrap().len(), + 0, + "GIT_CONFIG_GLOBAL must be 0 bytes" + ); + + assert!(!envs.contains_key("HOME")); + } + + #[test] + fn windows_config_and_hooks_isolation_paths() { + let cfg = empty_config_file(); + assert!(cfg.is_file()); + assert_eq!(std::fs::metadata(cfg).unwrap().len(), 0); + + let hooks = empty_hooks_dir(); + assert!(hooks.is_dir()); + assert_eq!(std::fs::read_dir(hooks).unwrap().count(), 0); + } + + #[test] + #[cfg(windows)] + fn windows_timeout_proves_descendant_died() { + let directory = + std::env::temp_dir().join(format!("basal-git-win-timeout-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let pid_file = directory.join("pid"); + let survivor = directory.join("survivor"); + + let script = format!( + "$p = Start-Process cmd.exe -ArgumentList '/c ping -n 35 127.0.0.1 >nul & echo survived > \"{}\"' -PassThru; $p.Id | Out-File -FilePath \"{}\" -Encoding ascii -NoNewline; $p.WaitForExit()", + survivor.display(), + pid_file.display() + ); + + let mut command = Command::new("powershell"); + command + .args(["-NoProfile", "-Command", &script]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let error = run_command(command).err().expect("command must time out"); + assert_eq!(error.code, codes::TIMEOUT); + + let pid_str = std::fs::read_to_string(&pid_file).expect("pid file must be written"); + let pid: u32 = pid_str.trim().parse().expect("pid must be a valid integer"); + + let survived = survivor.exists(); + assert!(!survived, "git's descendant survived the timeout"); + + let handle = unsafe { + ffi::OpenProcess( + ffi::PROCESS_QUERY_LIMITED_INFORMATION | ffi::SYNCHRONIZE, + ffi::FALSE, + pid, + ) + }; + if !handle.is_null() { + let wait_res = unsafe { ffi::WaitForSingleObject(handle, 0) }; + assert_eq!( + wait_res, + ffi::WAIT_OBJECT_0, + "descendant process must be signaled (dead)" + ); + let mut exit_code: u32 = 0; + let ok = unsafe { ffi::GetExitCodeProcess(handle, &mut exit_code) }; + assert_ne!(ok, 0, "GetExitCodeProcess should succeed"); + assert_ne!( + exit_code, + ffi::STILL_ACTIVE, + "descendant process must not be STILL_ACTIVE" + ); + unsafe { ffi::CloseHandle(handle) }; + } + + let _ = std::fs::remove_dir_all(directory); + } + + #[test] + #[cfg(windows)] + fn windows_output_cap_refuses_oversized_output() { + let mut command = Command::new("cmd"); + command + .args([ + "/c", + "for /L %i in (1,1,300000) do @echo 0123456789012345678901234567890123456789", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let error = run_command(command) + .err() + .expect("command must exceed output cap"); + assert_eq!(error.code, codes::TOO_LARGE); + } + + #[test] + #[cfg(windows)] + fn windows_tags_output_is_bounded_while_reading() { + let mut command = Command::new("cmd"); + command + .args(["/c", "for /L %i in (1,1,4000) do @echo tag-%i"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let result = run_tags_command(command).expect("tag command must succeed"); + assert!(result.success); + assert_eq!( + String::from_utf8(result.stdout).unwrap().lines().count(), + MAX_TAGS + ); + } + + #[test] + #[cfg(windows)] + fn windows_git_hook_suppression_and_config_isolation() { + if resolve_git().is_err() { + return; + } + + let temp_dir = + std::env::temp_dir().join(format!("basal-git-win-test-repo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let run_setup = |args: &[&str]| { + let status = Command::new(resolve_git().unwrap()) + .args(args) + .current_dir(&temp_dir) + .status() + .unwrap(); + assert!(status.success()); + }; + + run_setup(&["init"]); + run_setup(&["config", "user.name", "TestUser"]); + run_setup(&["config", "user.email", "test@example.com"]); + + let fake_hooks_dir = temp_dir.join("fake_hooks"); + std::fs::create_dir_all(&fake_hooks_dir).unwrap(); + let marker_file = temp_dir.join("hook_was_run.marker"); + let fake_hook = fake_hooks_dir.join("post-checkout.bat"); + std::fs::write( + &fake_hook, + format!("@echo ran > \"{}\"\r\n", marker_file.display()), + ) + .unwrap(); + + run_setup(&[ + "config", + "core.hooksPath", + &fake_hooks_dir.to_string_lossy(), + ]); + run_setup(&["config", "core.fsmonitor", "false"]); + + let file_path = temp_dir.join("file.txt"); + std::fs::write(&file_path, b"hello\n").unwrap(); + run_setup(&["add", "file.txt"]); + run_setup(&["commit", "-m", "init"]); + + let ran = super::super::git(&temp_dir, &["log", "-1"]).expect("git log must succeed"); + assert!(ran.success); + + assert!( + !marker_file.exists(), + "Planted hook must NOT have run due to core.hooksPath override" + ); + + let _ = std::fs::remove_dir_all(temp_dir); + } +} 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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 10/15] 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 11/15] 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 12/15] 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 0eb28a00fec7f0669a986570a35bb7a60dfba5f9 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:47:10 +0200 Subject: [PATCH 13/15] mason: confine Windows git reads and pin their approved directories Retain both process-attribute payloads through attribute deletion, create suspended, verify membership in the exact kill-on-close job, then resume. Use an explicit canonical drive image and a UTF-16 CRT encoder. Give each call exclusively created private config/hooks resources and read-only NUL stdin. Reuse the fs worker's no-delete-sharing handle walk via a small PinnedDirectory seam; the guard survives every git child in the operation. Retry failed job termination and put kill/close plus bounded worker cancellation inside the thread scope. Replace handwritten ABI declarations with windows-sys. Replace the original recipe-only or vacuous Windows tests with native argv round trips, job/handle observations, pinned-path controls, live descendant death, byte/line-cap writers, exceptional cleanup watchdogs, and real helper/global-config positive and negative controls. Hook controls now perform checkout rather than log, and tag writers exceed the byte budget rather than merely truncating small output. Mac and Linux basal-host tests/clippy pass; mounted MSVC lib/tests clippy and child fixture metadata checks pass with warnings denied. Native Windows execution is pending the parent-owned push/CI gate. The shared Unix canonicalize-then-git-C path race is deliberately unchanged in this slice. --- Cargo.lock | 1 + crates/basal-host/Cargo.toml | 3 + crates/basal-host/src/builtins/fs/windows.rs | 38 + crates/basal-host/src/builtins/git.rs | 36 +- .../builtins/git/fixtures/windows_child.rs | 80 + crates/basal-host/src/builtins/git/windows.rs | 2080 ++++++----------- .../src/builtins/git/windows/tests.rs | 805 +++++++ 7 files changed, 1713 insertions(+), 1330 deletions(-) create mode 100644 crates/basal-host/src/builtins/git/fixtures/windows_child.rs create mode 100644 crates/basal-host/src/builtins/git/windows/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 75a7c6a..dfba880 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,6 +90,7 @@ dependencies = [ "tokio", "tracing", "webpki-roots", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/basal-host/Cargo.toml b/crates/basal-host/Cargo.toml index 4cadb55..79b7cce 100644 --- a/crates/basal-host/Cargo.toml +++ b/crates/basal-host/Cargo.toml @@ -36,5 +36,8 @@ tokio.workspace = true tracing.workspace = true webpki-roots.workspace = true +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_System_Threading"] } + [dev-dependencies] subc-transport.workspace = true diff --git a/crates/basal-host/src/builtins/fs/windows.rs b/crates/basal-host/src/builtins/fs/windows.rs index ced6dd2..bf43b82 100644 --- a/crates/basal-host/src/builtins/fs/windows.rs +++ b/crates/basal-host/src/builtins/fs/windows.rs @@ -819,6 +819,44 @@ struct VerifiedRoot { ancestors: Vec, } +/// A directory and every ancestor held against rename or replacement while a +/// reader such as git opens the directory by path. Omitting delete sharing +/// prevents another process from moving any of those directories. +#[cfg(windows)] +pub(crate) struct PinnedDirectory(VerifiedRoot); + +#[cfg(windows)] +impl PinnedDirectory { + pub(crate) fn path(&self) -> &Path { + Path::new(&self.0.manifest) + } + + pub(crate) fn same_identity(&self, other: &Self) -> bool { + guid_path_inside_component_wise(&self.0.guid_path, &other.0.guid_path, false) + } +} + +#[cfg(windows)] +impl std::ops::Deref for PinnedDirectory { + type Target = Path; + + fn deref(&self) -> &Path { + self.path() + } +} + +/// Uses the filesystem builtin's walk, which refuses reparse points, retaining +/// every directory handle until the returned guard is dropped. The volume-GUID identity is +/// obtained from the opened directory, never from canonicalized input text. +#[cfg(windows)] +pub(crate) fn pin_directory(path: &str) -> Result { + let root = walk_from_volume_root(path, SHARE_NO_DELETE)?; + if !root.is_directory { + return Err(Denial::denied("the pinned path is not a directory")); + } + Ok(PinnedDirectory(root)) +} + /// The denial for a failed open of `comp` while walking to `manifest_root`. #[cfg(windows)] fn root_walk_denial(e: OpenError, comp: &str, manifest_root: &str) -> Denial { diff --git a/crates/basal-host/src/builtins/git.rs b/crates/basal-host/src/builtins/git.rs index 8dea4b7..e472a9d 100644 --- a/crates/basal-host/src/builtins/git.rs +++ b/crates/basal-host/src/builtins/git.rs @@ -15,6 +15,7 @@ #[cfg(test)] mod tests; +#[cfg(windows)] pub mod windows; #[cfg(not(windows))] @@ -23,7 +24,9 @@ use std::io::Read; use std::os::fd::AsRawFd; #[cfg(not(windows))] use std::os::unix::process::CommandExt; -use std::path::{Component, Path, PathBuf}; +#[cfg(not(windows))] +use std::path::PathBuf; +use std::path::{Component, Path}; #[cfg(not(windows))] use std::process::Command; #[cfg(not(windows))] @@ -222,6 +225,7 @@ pub fn parse(primitive: Primitive, args: &Value) -> Result { /// The repository's real path, which must be one of the approved /// repositories (each resolved the same way). A subdirectory of an approved /// repository is not itself approved. +#[cfg(not(windows))] pub fn repo(repo: &str, repos: &[String]) -> Result { let path = expand_home(repo) .ok_or_else(|| Denial::invalid(format!("{repo:?} is not an absolute path")))?; @@ -235,6 +239,30 @@ pub fn repo(repo: &str, repos: &[String]) -> Result { if approved { Ok(real) } else { Err(refused()) } } +#[cfg(windows)] +pub(crate) fn repo( + repo: &str, + repos: &[String], +) -> Result { + use super::fs::windows::pin_directory; + let path = expand_home(repo).ok_or_else(|| Denial::invalid("repository must be absolute"))?; + let path = path + .to_str() + .ok_or_else(|| Denial::invalid("repository is not Unicode"))?; + let pinned = pin_directory(path)?; + for approved in repos.iter().filter_map(|r| expand_home(r)) { + if let Some(approved) = approved.to_str() + && let Ok(root) = pin_directory(approved) + && pinned.same_identity(&root) + { + return Ok(pinned); + } + } + Err(Denial::denied( + "repository is outside the manifest's repositories or missing", + )) +} + /// The only way the built-ins run git: a `git -C ` command that can /// read the repository and run nothing else. /// @@ -545,7 +573,10 @@ fn drain_pipe( } fn git(repo: &Path, args: &[&str]) -> Result { + #[cfg(not(windows))] let mut command = hardened_command(repo); + #[cfg(windows)] + let mut command = hardened_command(repo)?; command.args(args); run_command(command) } @@ -687,7 +718,10 @@ pub fn run(repo_arg: &str, repos: &[String], op: &Op) -> Result { if let Some(p) = pattern { list.push(p); } + #[cfg(not(windows))] let mut command = hardened_command(&dir); + #[cfg(windows)] + let mut command = hardened_command(&dir)?; command.args(&list); let ran = run_tags_command(command)?; if !ran.success { diff --git a/crates/basal-host/src/builtins/git/fixtures/windows_child.rs b/crates/basal-host/src/builtins/git/fixtures/windows_child.rs new file mode 100644 index 0000000..5f3a830 --- /dev/null +++ b/crates/basal-host/src/builtins/git/fixtures/windows_child.rs @@ -0,0 +1,80 @@ +// Compiled natively by the Windows tests. This child uses Rust's real Windows +// argv parser, independently of the encoder in the parent. +use std::io::{Read, Write}; +use std::os::windows::{ffi::OsStrExt, process::CommandExt}; +use std::process::{Command, Stdio}; +use std::time::Duration; + +fn main() { + let mut arguments = std::env::args_os().skip(1); + let mode = arguments.next().unwrap(); + match mode.to_str().unwrap() { + "argv" => { + for argument in arguments { + for unit in argument.encode_wide() { + print!("{unit:04x}"); + } + println!(); + } + } + "mark" => std::fs::write(arguments.next().unwrap(), "worked").unwrap(), + "stdin" => { + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes).unwrap(); + assert!(bytes.is_empty()); + println!("inert"); + } + "hold" => { + std::fs::write(arguments.next().unwrap(), std::process::id().to_string()).unwrap(); + std::thread::sleep(Duration::from_secs(300)); + } + "spawn" | "post-exit" => { + let pid = arguments.next().unwrap(); + let ready = arguments.next().unwrap(); + let mut child = Command::new(std::env::current_exe().unwrap()) + .arg("hold") + .arg(&pid) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + while !std::path::Path::new(&pid).exists() { + std::thread::sleep(Duration::from_millis(2)); + } + std::fs::write(&ready, "ready").unwrap(); + if mode == "post-exit" { + while !std::path::Path::new(&ready) + .with_extension("release") + .exists() + { + std::thread::sleep(Duration::from_millis(2)); + } + } else { + let _ = child.wait(); + } + } + "lines" => { + let width: usize = arguments.next().unwrap().to_str().unwrap().parse().unwrap(); + let mut stdout = std::io::stdout().lock(); + // More bytes than the cap even with only 199 completed lines when + // width is large. An unbounded collector never reaches process exit. + for _ in 0..4000 { + writeln!(stdout, "{}", "x".repeat(width)).unwrap(); + } + stdout.flush().unwrap(); + std::thread::sleep(Duration::from_secs(300)); + } + "breakaway" => { + let marker = arguments.next().unwrap(); + let flags: u32 = arguments.next().unwrap().to_str().unwrap().parse().unwrap(); + let status = Command::new(std::env::current_exe().unwrap()) + .arg("mark") + .arg(marker) + .creation_flags(flags) + .status(); + assert!(status.is_err(), "breakaway child was allowed"); + println!("breakaway refused"); + } + other => panic!("unknown mode {other}"), + } +} diff --git a/crates/basal-host/src/builtins/git/windows.rs b/crates/basal-host/src/builtins/git/windows.rs index 9338201..57446d1 100644 --- a/crates/basal-host/src/builtins/git/windows.rs +++ b/crates/basal-host/src/builtins/git/windows.rs @@ -1,241 +1,331 @@ -//! Windows-specific implementation of git execution, hardening and job containment. -//! -//! Spawns `git.exe` in its own Job Object via `CreateProcessW` + `STARTUPINFOEXW` + -//! `PROC_THREAD_ATTRIBUTE_JOB_LIST`. -//! Isolates environment and configuration using owned empty files/directories. -//! Enforces timeout and output caps by terminating the job object before joining drain threads. - -#![cfg_attr(not(windows), allow(unused))] +//! Windows git reads use a private empty global config, explicit executable +//! paths, and a non-breakaway kill-on-close job. The child remains suspended +//! until membership in that exact job is verified. use std::collections::BTreeMap; use std::ffi::{OsStr, OsString}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::sync::{OnceLock, mpsc}; +use std::os::windows::ffi::{OsStrExt, OsStringExt}; +use std::path::{Component, Path, PathBuf, Prefix}; +use std::process::Command; +use std::sync::{ + OnceLock, + atomic::{AtomicBool, Ordering}, + mpsc, +}; use std::thread; -use std::time::Instant; - -use super::STDERR_BYTES; -pub use super::{MAX_OUTPUT_BYTES, MAX_TAGS, TIMEOUT}; -use super::{Ran, codes}; -use crate::builtins::Denial; +use std::time::{Duration, Instant}; + +use windows_sys::Win32::{ + Foundation::{ + CloseHandle, ERROR_BROKEN_PIPE, HANDLE, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, + LocalFree, SetHandleInformation, WAIT_OBJECT_0, WAIT_TIMEOUT, + }, + Security::{ + Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW, + Cryptography::{BCRYPT_USE_SYSTEM_PREFERRED_RNG, BCryptGenRandom}, + SECURITY_ATTRIBUTES, + }, + Storage::FileSystem::{ + CREATE_NEW, CreateDirectoryW, CreateFileW, FILE_ATTRIBUTE_NORMAL, + FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_SHARE_READ, FILE_SHARE_WRITE, + OPEN_EXISTING, ReadFile, + }, + System::{ + JobObjects::{ + CreateJobObjectW, IsProcessInJob, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, + }, + Pipes::{CreatePipe, PeekNamedPipe}, + Threading::{ + CREATE_NO_WINDOW, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, + DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess, + InitializeProcThreadAttributeList, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + PROC_THREAD_ATTRIBUTE_JOB_LIST, PROCESS_INFORMATION, ResumeThread, + STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute, + WaitForSingleObject, + }, + }, +}; + +use super::{MAX_OUTPUT_BYTES, MAX_TAGS, Ran, STDERR_BYTES, TIMEOUT, codes}; +use crate::builtins::{ + Denial, + fs::windows::{PinnedDirectory, pin_directory}, +}; + +fn native_error(what: &str) -> Denial { + Denial::new( + codes::GIT, + format!("{what}: {}", std::io::Error::last_os_error()), + ) +} -/// Strips `\\?\` or `//?/` verbatim prefix from Windows paths. -pub fn strip_verbatim_prefix(path: &Path) -> PathBuf { - let s = path.to_string_lossy(); - if let Some(stripped) = s.strip_prefix(r"\\?\") { - PathBuf::from(stripped) - } else if let Some(stripped) = s.strip_prefix("//?/") { - PathBuf::from(stripped) - } else { - path.to_path_buf() +fn wide(s: impl AsRef) -> Result, Denial> { + let mut value: Vec = s.as_ref().encode_wide().collect(); + if value.contains(&0) { + return Err(Denial::invalid("NUL in Windows process input")); } + value.push(0); + Ok(value) } -/// Checks whether a path is absolute in Windows syntax (e.g. `C:\...` or `\\server\share`). -fn is_windows_absolute(path: &Path) -> bool { - let s = path.to_string_lossy(); - let bytes = s.as_bytes(); - if bytes.len() >= 3 - && bytes[0].is_ascii_alphabetic() - && bytes[1] == b':' - && (bytes[2] == b'\\' || bytes[2] == b'/') - { - return true; - } - if s.starts_with(r"\\") || s.starts_with("//") { - return true; +/// Only a verbatim drive path can be converted to an ordinary drive path. +/// UNC, device, volume and drive-relative names are not supported by the fs policy. +fn drive_path(path: &Path) -> Result { + let mut components = path.components(); + match (components.next(), components.next()) { + (Some(Component::Prefix(prefix)), Some(Component::RootDir)) => match prefix.kind() { + Prefix::Disk(_) => Ok(path.to_path_buf()), + Prefix::VerbatimDisk(_) => { + let units: Vec = path.as_os_str().encode_wide().skip(4).collect(); + Ok(PathBuf::from(OsString::from_wide(&units))) + } + _ => Err(Denial::invalid("only absolute drive paths are supported")), + }, + _ => Err(Denial::invalid("only absolute drive paths are supported")), } - path.is_absolute() } -struct IsolationPaths { - _dir: PathBuf, - config_file: PathBuf, - hooks_dir: PathBuf, +fn canonical_image(path: &Path) -> Result { + let path = drive_path(path)?; + if !path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) + || !path.is_file() + { + return Err(Denial::new( + codes::GIT, + "the image must be an existing .exe", + )); + } + drive_path(&std::fs::canonicalize(path).map_err(|e| Denial::new(codes::GIT, e.to_string()))?) } -static ISOLATION: OnceLock = OnceLock::new(); - -fn isolation_paths() -> &'static IsolationPaths { - ISOLATION.get_or_init(|| { - let dir = std::env::temp_dir().join(format!("basal-git-isolation-{}", std::process::id())); - let _ = std::fs::create_dir_all(&dir); - let config_file = dir.join("empty.config"); - let hooks_dir = dir.join("empty.hooks"); - if !config_file.exists() { - let _ = std::fs::write(&config_file, b""); - } - let _ = std::fs::create_dir_all(&hooks_dir); - IsolationPaths { - _dir: dir, - config_file, - hooks_dir, +/// PATH is operator-controlled; only absolute directories participate. This +/// establishes a stable image path, not a signature/trust check of the binary. +fn resolve_git_binary(program: &OsStr, path_env: Option<&OsStr>) -> Result { + let p = Path::new(program); + if p.extension() + .is_some_and(|e| e.eq_ignore_ascii_case("bat") || e.eq_ignore_ascii_case("cmd")) + { + return Err(Denial::new(codes::GIT, "git scripts are refused")); + } + if program != "git" && program != "git.exe" { + return canonical_image(p); + } + if let Some(path_env) = path_env { + for directory in std::env::split_paths(path_env) { + let Ok(directory) = drive_path(&directory) else { + continue; + }; + let image = directory.join("git.exe"); + if image.is_file() { + return canonical_image(&image); + } + if directory.join("git.cmd").is_file() || directory.join("git.bat").is_file() { + return Err(Denial::new(codes::GIT, "git PATH entry is a script")); + } } - }) + } + Err(Denial::new( + codes::GIT, + "git.exe was not found in absolute PATH directories", + )) } -/// The owned empty configuration file used for `GIT_CONFIG_GLOBAL`. -pub fn empty_config_file() -> &'static Path { - &isolation_paths().config_file +fn resolve_git() -> Result<&'static Path, Denial> { + static IMAGE: OnceLock> = OnceLock::new(); + match IMAGE + .get_or_init(|| resolve_git_binary(OsStr::new("git"), std::env::var_os("PATH").as_deref())) + { + Ok(image) => Ok(image), + Err(error) => Err(error.clone()), + } } -/// The owned empty hooks directory used for `core.hooksPath`. -pub fn empty_hooks_dir() -> &'static Path { - &isolation_paths().hooks_dir +struct OwnedHandle(HANDLE); +// SAFETY: kernel handles may move across threads. Each owner closes once. +unsafe impl Send for OwnedHandle {} +impl Drop for OwnedHandle { + fn drop(&mut self) { + // SAFETY: all constructors check for invalid handles before taking ownership. + unsafe { + CloseHandle(self.0); + } + } } -/// Resolves git to an absolute `.exe` path, refusing `.bat` or `.cmd` shims. -pub fn resolve_git_binary(program: &str, path_env: Option<&OsStr>) -> Result { - let p = Path::new(program); - // If explicit extension is .bat or .cmd, refuse immediately. - if let Some(ext) = p.extension().and_then(|e| e.to_str()) { - if ext.eq_ignore_ascii_case("bat") || ext.eq_ignore_ascii_case("cmd") { - return Err(Denial::new( - codes::GIT, - format!("git binary {program:?} is a .{ext} script, which is refused"), - )); +struct SecurityDescriptor(*mut std::ffi::c_void); +impl SecurityDescriptor { + fn private() -> Result { + let sddl = wide("D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;OW)")?; + let mut descriptor = std::ptr::null_mut(); + // SAFETY: the null-terminated SDDL and output pointer live through the call. + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + 1, + &mut descriptor, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(native_error("private DACL")); + } + Ok(Self(descriptor)) + } + fn attributes(&self) -> SECURITY_ATTRIBUTES { + SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: self.0, + bInheritHandle: 0, } } +} +impl Drop for SecurityDescriptor { + fn drop(&mut self) { + // SAFETY: the conversion API allocated this descriptor with LocalAlloc. + unsafe { + LocalFree(self.0); + } + } +} - // If an absolute path is provided, verify it is a valid .exe file. - if is_windows_absolute(p) { - let is_exe = p - .extension() - .and_then(|e| e.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("exe")) - .unwrap_or(false); - if !is_exe { +/// Guards precede cleanup: the directory cannot be replaced and the zero-byte +/// file cannot be written/deleted while git reads it. Each call gets a new DACL +/// protected directory, created exclusively using a cryptographically random name. +struct Isolation { + directory: PathBuf, + pins: Vec, + config: Option, +} +impl Isolation { + fn new() -> Result { + let base = drive_path(&std::env::temp_dir())?; + let base = base + .to_str() + .ok_or_else(|| Denial::invalid("temp directory is not Unicode"))?; + let base_pin = pin_directory(base)?; + let descriptor = SecurityDescriptor::private()?; + let attributes = descriptor.attributes(); + let mut random = [0u8; 16]; + // SAFETY: random is writable for exactly the requested byte count. + if unsafe { + BCryptGenRandom( + std::ptr::null_mut(), + random.as_mut_ptr(), + random.len() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + } < 0 + { return Err(Denial::new( codes::GIT, - format!("git binary at {program:?} is not a .exe executable"), + "private directory randomness failed", )); } - if p.is_file() { - return Ok(strip_verbatim_prefix(p)); - } - return Err(Denial::new( - codes::GIT, - format!("git binary at {program:?} does not exist"), - )); - } - - // Search PATH directories. - let base_name = p.file_stem().and_then(|s| s.to_str()).unwrap_or(program); - - if let Some(path_val) = path_env { - let dirs: Vec = if cfg!(windows) { - std::env::split_paths(path_val).collect() - } else { - let s = path_val.to_string_lossy(); - let delimiter = if s.contains(';') { ';' } else { ':' }; - s.split(delimiter).map(PathBuf::from).collect() + let name: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let directory = base_pin.path().join(format!("basal-git-{name}")); + let path = wide(&directory)?; + // SAFETY: path and private security descriptor live through the call. + // CreateDirectory refuses an existing name; no existing contents are trusted. + if unsafe { CreateDirectoryW(path.as_ptr(), &attributes) } == 0 { + return Err(native_error("create private git directory")); + } + let mut isolation = Self { + directory, + pins: vec![base_pin], + config: None, }; - - for dir in dirs { - let exe_candidate = dir.join(format!("{base_name}.exe")); - let cmd_candidate = dir.join(format!("{base_name}.cmd")); - let bat_candidate = dir.join(format!("{base_name}.bat")); - - if exe_candidate.is_file() { - return Ok(strip_verbatim_prefix(&exe_candidate)); - } - if cmd_candidate.is_file() { - return Err(Denial::new( - codes::GIT, - format!( - "git resolved to {cmd_candidate:?}, which is a .cmd script and is refused" - ), - )); - } - if bat_candidate.is_file() { - return Err(Denial::new( - codes::GIT, - format!( - "git resolved to {bat_candidate:?}, which is a .bat script and is refused" - ), - )); - } + isolation.pins.push(pin_directory( + isolation + .directory + .to_str() + .ok_or_else(|| Denial::invalid("private directory is not Unicode"))?, + )?); + let hooks = isolation.directory.join("hooks"); + if unsafe { CreateDirectoryW(wide(&hooks)?.as_ptr(), &attributes) } == 0 { + return Err(native_error("create private hooks directory")); + } + isolation.pins.push(pin_directory( + hooks + .to_str() + .ok_or_else(|| Denial::invalid("hooks directory is not Unicode"))?, + )?); + let config = wide(isolation.directory.join("global.config"))?; + // SAFETY: CREATE_NEW is exclusive, OPEN_REPARSE_POINT never follows a link, + // and read sharing only prevents both writes and deletion for the call. + let handle = unsafe { + CreateFileW( + config.as_ptr(), + FILE_GENERIC_READ, + FILE_SHARE_READ, + &attributes, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(native_error("create empty global config")); } + isolation.config = Some(OwnedHandle(handle)); + Ok(isolation) } - - // Check standard Windows Git locations as fallback - #[cfg(windows)] - for standard_path in [ - r"C:\Program Files\Git\cmd\git.exe", - r"C:\Program Files\Git\bin\git.exe", - r"C:\Program Files (x86)\Git\cmd\git.exe", - r"C:\Program Files (x86)\Git\bin\git.exe", - ] { - let pb = PathBuf::from(standard_path); - if pb.is_file() { - return Ok(pb); - } +} +impl Drop for Isolation { + fn drop(&mut self) { + self.config.take(); + self.pins.clear(); + let _ = std::fs::remove_dir_all(&self.directory); } - - Err(Denial::new( - codes::GIT, - "git.exe could not be found in PATH or standard install locations".to_string(), - )) } -static RESOLVED_GIT: OnceLock> = OnceLock::new(); - -/// Resolves `git.exe` once to an absolute `.exe` path. -pub fn resolve_git() -> Result<&'static Path, Denial> { - let res = - RESOLVED_GIT.get_or_init(|| resolve_git_binary("git", std::env::var_os("PATH").as_deref())); - match res { - Ok(p) => Ok(p.as_path()), - Err(e) => Err(e.clone()), +pub struct HardenedCommand { + command: Command, + _isolation: Isolation, +} +impl std::ops::Deref for HardenedCommand { + type Target = Command; + fn deref(&self) -> &Command { + &self.command } } - -/// Computes the parent directory of a Windows path, supporting both `\` and `/`. -pub fn windows_parent(path: &Path) -> Option { - if cfg!(windows) { - path.parent().map(|p| p.to_path_buf()) - } else { - let s = path.to_string_lossy(); - if let Some(idx) = s.rfind(|c| c == '\\' || c == '/') { - if idx == 0 { - Some(PathBuf::from(&s[..1])) - } else { - Some(PathBuf::from(&s[..idx])) - } - } else { - None - } +impl std::ops::DerefMut for HardenedCommand { + fn deref_mut(&mut self) -> &mut Command { + &mut self.command } } -/// The only way the built-ins run git on Windows: a `git -C ` command -/// that can read the repository and run nothing else. -pub fn hardened_command(repo: &Path) -> Command { - let git_prog = resolve_git() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|_| PathBuf::from("git.exe")); - let mut command = Command::new(git_prog); +/// The resources selected by this recipe are owned until the child tree exits. +pub fn hardened_command(repo: &Path) -> Result { + let isolation = Isolation::new()?; + let repo = drive_path(repo)?; + let mut command = Command::new(resolve_git()?); command.env_clear(); - for var in ["PATH", "SystemRoot", "USERPROFILE"] { - if let Some(val) = std::env::var_os(var) { - command.env(var, val); + for key in ["PATH", "SystemRoot", "USERPROFILE"] { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); } } command .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", empty_config_file()) + .env( + "GIT_CONFIG_GLOBAL", + isolation.directory.join("global.config"), + ) .env("LC_ALL", "C") .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_NO_LAZY_FETCH", "1"); - let clean_repo = strip_verbatim_prefix(repo); - if let Some(parent) = windows_parent(&clean_repo) { + if let Some(parent) = repo.parent() { command.env("GIT_CEILING_DIRECTORIES", parent); } - let hooks_arg = format!("core.hooksPath={}", empty_hooks_dir().display()); + let mut hooks = OsString::from("core.hooksPath="); + hooks.push(isolation.directory.join("hooks")); command .arg("-C") - .arg(clean_repo) + .arg(repo) .args([ "--no-optional-locks", "--no-pager", @@ -243,1263 +333,595 @@ pub fn hardened_command(repo: &Path) -> Command { "-c", "core.fsmonitor=false", "-c", - &hooks_arg, + ]) + .arg(hooks) + .args([ "-c", "core.pager=cat", "-c", "log.showSignature=false", "-c", "protocol.allow=never", - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - command + ]); + Ok(HardenedCommand { + command, + _isolation: isolation, + }) } -/// Escapes a single argument according to Microsoft `CommandLineToArgvW` rules. -pub fn append_windows_arg(cmd: &mut String, arg: &str) { - if !arg.is_empty() && !arg.contains([' ', '\t', '\n', '\x0b', '\"']) { - cmd.push_str(arg); - return; - } - cmd.push('"'); - let mut backslashes: usize = 0; - for c in arg.chars() { - if c == '\\' { - backslashes += 1; +/// Microsoft CRT encoding over UTF-16 units (including unpaired surrogates). +/// Backslashes before quotes and before the closing quote must be doubled. +fn append_windows_arg(command: &mut Vec, argument: &OsStr) -> Result<(), Denial> { + let units: Vec = argument.encode_wide().collect(); + if units.contains(&0) { + return Err(Denial::invalid("NUL in process argument")); + } + command.push(b'"' as u16); + let mut slashes = 0; + for unit in units { + if unit == b'\\' as u16 { + slashes += 1; + continue; + } + let count = if unit == b'"' as u16 { + 2 * slashes + 1 } else { - if c == '"' { - for _ in 0..backslashes * 2 + 1 { - cmd.push('\\'); - } - } else { - for _ in 0..backslashes { - cmd.push('\\'); - } - cmd.push(c); - } - backslashes = 0; - } - } - for _ in 0..backslashes * 2 { - cmd.push('\\'); - } - cmd.push('"'); -} - -fn to_wide_null(s: impl AsRef) -> Vec { - #[cfg(windows)] - { - use std::os::windows::ffi::OsStrExt; - s.as_ref().encode_wide().chain(Some(0)).collect() - } - #[cfg(not(windows))] - { - s.as_ref() - .to_str() - .unwrap_or("") - .encode_utf16() - .chain(Some(0)) - .collect() - } -} - -fn timeout_denial() -> Denial { - Denial::new( - codes::TIMEOUT, - format!("git ran longer than {} s", TIMEOUT.as_secs()), - ) -} - -#[cfg(windows)] -mod ffi { - pub use std::ffi::c_void; - - pub type BOOL = i32; - pub type HANDLE = *mut c_void; - pub const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE; - - pub const TRUE: BOOL = 1; - pub const FALSE: BOOL = 0; - - pub const STILL_ACTIVE: u32 = 259; - pub const WAIT_OBJECT_0: u32 = 0; - pub const INFINITE: u32 = 0xFFFFFFFF; - - pub const HANDLE_FLAG_INHERIT: u32 = 0x00000001; - - pub const STARTF_USESTDHANDLES: u32 = 0x00000100; - pub const EXTENDED_STARTUPINFO_PRESENT: u32 = 0x00080000; - pub const CREATE_NO_WINDOW: u32 = 0x08000000; - pub const CREATE_UNICODE_ENVIRONMENT: u32 = 0x00000400; - - pub const PROC_THREAD_ATTRIBUTE_JOB_LIST: usize = 0x0002000D; - pub const PROC_THREAD_ATTRIBUTE_HANDLE_LIST: usize = 0x00020002; - - pub const JobObjectExtendedLimitInformation: u32 = 9; - pub const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: u32 = 0x00002000; - - pub const FILE_GENERIC_READ: u32 = 0x00120089; - pub const FILE_SHARE_READ: u32 = 0x00000001; - pub const FILE_SHARE_WRITE: u32 = 0x00000002; - pub const FILE_SHARE_DELETE: u32 = 0x00000004; - pub const OPEN_EXISTING: u32 = 3; - pub const FILE_ATTRIBUTE_NORMAL: u32 = 0x00000080; - - pub const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; - pub const SYNCHRONIZE: u32 = 0x00100000; - - #[repr(C)] - pub struct SECURITY_ATTRIBUTES { - pub nLength: u32, - pub lpSecurityDescriptor: *mut c_void, - pub bInheritHandle: BOOL, - } - - #[repr(C)] - #[derive(Default, Clone, Copy)] - pub struct IO_COUNTERS { - pub ReadOperationCount: u64, - pub WriteOperationCount: u64, - pub OtherOperationCount: u64, - pub ReadTransferCount: u64, - pub WriteTransferCount: u64, - pub OtherTransferCount: u64, - } - - #[repr(C)] - #[derive(Default, Clone, Copy)] - pub struct JOBOBJECT_BASIC_LIMIT_INFORMATION { - pub PerProcessUserTimeLimit: i64, - pub PerJobUserTimeLimit: i64, - pub LimitFlags: u32, - pub MinimumWorkingSetSize: usize, - pub MaximumWorkingSetSize: usize, - pub ActiveProcessLimit: u32, - pub Affinity: usize, - pub PriorityClass: u32, - pub SchedulingClass: u32, - } - - #[repr(C)] - #[derive(Default, Clone, Copy)] - pub struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION { - pub BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION, - pub IoInfo: IO_COUNTERS, - pub ProcessMemoryLimit: usize, - pub JobMemoryLimit: usize, - pub PeakProcessMemoryUsed: usize, - pub PeakJobMemoryUsed: usize, - } - - #[repr(C)] - pub struct STARTUPINFOW { - pub cb: u32, - pub lpReserved: *mut u16, - pub lpDesktop: *mut u16, - pub lpTitle: *mut u16, - pub dwX: u32, - pub dwY: u32, - pub dwXSize: u32, - pub dwYSize: u32, - pub dwXCountChars: u32, - pub dwYCountChars: u32, - pub dwFillAttribute: u32, - pub dwFlags: u32, - pub wShowWindow: u16, - pub cbReserved2: u16, - pub lpReserved2: *mut u8, - pub hStdInput: HANDLE, - pub hStdOutput: HANDLE, - pub hStdError: HANDLE, - } - - #[repr(C)] - pub struct STARTUPINFOEXW { - pub StartupInfo: STARTUPINFOW, - pub lpAttributeList: *mut c_void, - } - - #[repr(C)] - pub struct PROCESS_INFORMATION { - pub hProcess: HANDLE, - pub hThread: HANDLE, - pub dwProcessId: u32, - pub dwThreadId: u32, - } - - unsafe extern "system" { - pub fn CloseHandle(hObject: HANDLE) -> BOOL; - pub fn SetHandleInformation(hObject: HANDLE, dwMask: u32, dwFlags: u32) -> BOOL; - - pub fn CreatePipe( - hReadPipe: *mut HANDLE, - hWritePipe: *mut HANDLE, - lpPipeAttributes: *const SECURITY_ATTRIBUTES, - nSize: u32, - ) -> BOOL; - - pub fn ReadFile( - hFile: HANDLE, - lpBuffer: *mut c_void, - nNumberOfBytesToRead: u32, - lpNumberOfBytesRead: *mut u32, - lpOverlapped: *mut c_void, - ) -> BOOL; - - pub fn CreateFileW( - lpFileName: *const u16, - dwDesiredAccess: u32, - dwShareMode: u32, - lpSecurityAttributes: *const SECURITY_ATTRIBUTES, - dwCreationDisposition: u32, - dwFlagsAndAttributes: u32, - hTemplateFile: HANDLE, - ) -> HANDLE; - - pub fn CreateJobObjectW( - lpJobAttributes: *const SECURITY_ATTRIBUTES, - lpName: *const u16, - ) -> HANDLE; - - pub fn SetInformationJobObject( - hJob: HANDLE, - JobObjectInformationClass: u32, - lpJobObjectInformation: *const c_void, - cbJobObjectInformationLength: u32, - ) -> BOOL; - - pub fn TerminateJobObject(hJob: HANDLE, uExitCode: u32) -> BOOL; - - pub fn InitializeProcThreadAttributeList( - lpAttributeList: *mut c_void, - dwAttributeCount: u32, - dwFlags: u32, - lpSize: *mut usize, - ) -> BOOL; - - pub fn UpdateProcThreadAttribute( - lpAttributeList: *mut c_void, - dwFlags: u32, - Attribute: usize, - lpValue: *const c_void, - cbSize: usize, - lpPreviousValue: *mut c_void, - lpReturnSize: *const usize, - ) -> BOOL; - - pub fn DeleteProcThreadAttributeList(lpAttributeList: *mut c_void); - - pub fn CreateProcessW( - lpApplicationName: *const u16, - lpCommandLine: *mut u16, - lpProcessAttributes: *const SECURITY_ATTRIBUTES, - lpThreadAttributes: *const SECURITY_ATTRIBUTES, - bInheritHandles: BOOL, - dwCreationFlags: u32, - lpEnvironment: *const c_void, - lpCurrentDirectory: *const u16, - lpStartupInfo: *const STARTUPINFOEXW, - lpProcessInformation: *mut PROCESS_INFORMATION, - ) -> BOOL; - - pub fn WaitForSingleObject(hHandle: HANDLE, dwMilliseconds: u32) -> u32; - - pub fn GetExitCodeProcess(hProcess: HANDLE, lpExitCode: *mut u32) -> BOOL; - - pub fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: BOOL, dwProcessId: u32) -> HANDLE; - } -} - -#[cfg(windows)] -pub struct OwnedHandle(pub ffi::HANDLE); - -#[cfg(windows)] -unsafe impl Send for OwnedHandle {} - -#[cfg(windows)] -impl OwnedHandle { - pub fn raw(&self) -> ffi::HANDLE { - self.0 - } -} - -#[cfg(windows)] -impl Drop for OwnedHandle { - fn drop(&mut self) { - if !self.0.is_null() && self.0 != ffi::INVALID_HANDLE_VALUE { - unsafe { ffi::CloseHandle(self.0) }; - self.0 = ffi::INVALID_HANDLE_VALUE; - } + slashes + }; + command.extend(std::iter::repeat_n(b'\\' as u16, count)); + command.push(unit); + slashes = 0; } + command.extend(std::iter::repeat_n(b'\\' as u16, 2 * slashes)); + command.push(b'"' as u16); + Ok(()) } -#[cfg(windows)] struct AttributeList { buffer: Vec, + jobs: Box<[HANDLE; 1]>, + handles: Box<[HANDLE; 3]>, } - -#[cfg(windows)] impl AttributeList { - fn new(count: u32) -> Result { - let mut size: usize = 0; + fn new(job: HANDLE, handles: [HANDLE; 3]) -> Result { + let mut size = 0; + // SAFETY: the first call queries storage size; no attribute list exists yet. unsafe { - ffi::InitializeProcThreadAttributeList(std::ptr::null_mut(), count, 0, &mut size); + InitializeProcThreadAttributeList(std::ptr::null_mut(), 2, 0, &mut size); } - let mut buffer = vec![0usize; size.div_ceil(std::mem::size_of::())]; - let ok = unsafe { - ffi::InitializeProcThreadAttributeList(buffer.as_mut_ptr().cast(), count, 0, &mut size) - }; - if ok == 0 { - return Err(Denial::new( - codes::GIT, - format!( - "InitializeProcThreadAttributeList failed: {}", - std::io::Error::last_os_error() - ), - )); + if size == 0 { + return Err(native_error("attribute list size")); } - Ok(Self { buffer }) - } - - fn as_mut_ptr(&mut self) -> *mut std::ffi::c_void { - self.buffer.as_mut_ptr().cast() - } - - fn set_job_list(&mut self, job: ffi::HANDLE) -> Result<(), Denial> { - let mut jobs = [job]; - let ok = unsafe { - ffi::UpdateProcThreadAttribute( - self.as_mut_ptr(), - 0, - ffi::PROC_THREAD_ATTRIBUTE_JOB_LIST, - jobs.as_mut_ptr().cast(), - std::mem::size_of::(), - std::ptr::null_mut(), - std::ptr::null(), - ) - }; - if ok == 0 { - return Err(Denial::new( - codes::GIT, - format!( - "UpdateProcThreadAttribute(JOB_LIST) failed: {}", - std::io::Error::last_os_error() - ), - )); + let mut buffer = vec![0; size.div_ceil(std::mem::size_of::())]; + if unsafe { InitializeProcThreadAttributeList(buffer.as_mut_ptr().cast(), 2, 0, &mut size) } + == 0 + { + return Err(native_error("initialize attribute list")); } - Ok(()) - } - - fn set_handle_list(&mut self, handles: &[ffi::HANDLE]) -> Result<(), Denial> { - let ok = unsafe { - ffi::UpdateProcThreadAttribute( - self.as_mut_ptr(), - 0, - ffi::PROC_THREAD_ATTRIBUTE_HANDLE_LIST, - handles.as_ptr().cast(), - handles.len() * std::mem::size_of::(), - std::ptr::null_mut(), - std::ptr::null(), - ) + let mut list = Self { + buffer, + jobs: Box::new([job]), + handles: Box::new(handles), }; - if ok == 0 { - return Err(Denial::new( - codes::GIT, - format!( - "UpdateProcThreadAttribute(HANDLE_LIST) failed: {}", - std::io::Error::last_os_error() - ), - )); + // SAFETY: both payloads have stable boxed addresses. Drop deletes the + // attribute list before Rust drops either payload or its backing storage. + for (attribute, payload, length) in [ + ( + PROC_THREAD_ATTRIBUTE_JOB_LIST as usize, + list.jobs.as_mut_ptr(), + std::mem::size_of_val(list.jobs.as_ref()), + ), + ( + PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, + list.handles.as_mut_ptr(), + std::mem::size_of_val(list.handles.as_ref()), + ), + ] { + if unsafe { + UpdateProcThreadAttribute( + list.pointer(), + 0, + attribute, + payload.cast(), + length, + std::ptr::null_mut(), + std::ptr::null(), + ) + } == 0 + { + return Err(native_error("set process attribute")); + } } - Ok(()) + Ok(list) + } + fn pointer(&mut self) -> *mut std::ffi::c_void { + self.buffer.as_mut_ptr().cast() } } - -#[cfg(windows)] impl Drop for AttributeList { fn drop(&mut self) { + // SAFETY: initialization succeeded and backing/payload storage is still live. unsafe { - ffi::DeleteProcThreadAttributeList(self.as_mut_ptr()); + DeleteProcThreadAttributeList(self.pointer()); } } } -#[cfg(windows)] -pub struct ProcessJob { - pub process: ffi::HANDLE, - pub job: ffi::HANDLE, +struct ProcessJob { + process: OwnedHandle, + job: Option, terminated: bool, + #[cfg(test)] + fail_termination_once: bool, } - -#[cfg(windows)] -unsafe impl Send for ProcessJob {} - -#[cfg(windows)] impl ProcessJob { - pub fn new(process: ffi::HANDLE, job: ffi::HANDLE) -> Self { - Self { - process, - job, - terminated: false, + fn terminate(&mut self) -> Result<(), Denial> { + if self.terminated { + return Ok(()); + } + #[cfg(test)] + if std::mem::take(&mut self.fail_termination_once) { + return Err(Denial::new(codes::GIT, "injected job termination failure")); + } + if let Some(job) = &self.job { + // SAFETY: the job is exclusively owned and cannot close during this call. + if unsafe { TerminateJobObject(job.0, 1) } == 0 { + return Err(native_error("terminate git job")); + } } + self.terminated = true; + Ok(()) } - - pub fn terminate(&mut self) { - if !self.terminated { - self.terminated = true; - if !self.job.is_null() && self.job != ffi::INVALID_HANDLE_VALUE { - unsafe { - ffi::TerminateJobObject(self.job, 1); - } - } + fn stop_tree(&mut self) { + if self.terminate().is_err() { + let _ = self.terminate(); } + // Last-handle close is the kill-on-close fallback, before any joins. + self.job.take(); } } - -#[cfg(windows)] impl Drop for ProcessJob { fn drop(&mut self) { - self.terminate(); - if !self.process.is_null() && self.process != ffi::INVALID_HANDLE_VALUE { - unsafe { - ffi::CloseHandle(self.process); - } - self.process = ffi::INVALID_HANDLE_VALUE; - } - if !self.job.is_null() && self.job != ffi::INVALID_HANDLE_VALUE { - unsafe { - ffi::CloseHandle(self.job); - } - self.job = ffi::INVALID_HANDLE_VALUE; - } + self.stop_tree(); } } -struct PipeOutput { - bytes: Vec, - limit_reached: bool, +fn pipe(attributes: &SECURITY_ATTRIBUTES) -> Result<(OwnedHandle, OwnedHandle), Denial> { + let mut read = std::ptr::null_mut(); + let mut write = std::ptr::null_mut(); + // SAFETY: output pointers and security attributes are valid for this call. + if unsafe { CreatePipe(&mut read, &mut write, attributes, 0) } == 0 { + return Err(native_error("create git pipe")); + } + let read = OwnedHandle(read); + let write = OwnedHandle(write); + if unsafe { SetHandleInformation(read.0, HANDLE_FLAG_INHERIT, 0) } == 0 { + return Err(native_error("clear pipe inheritance")); + } + Ok((read, write)) } -#[cfg(windows)] -fn drain_pipe( - handle: ffi::HANDLE, - cap: usize, - max_lines: Option, -) -> Result { - let mut output = PipeOutput { - bytes: Vec::new(), - limit_reached: false, - }; - if handle.is_null() || handle == ffi::INVALID_HANDLE_VALUE { - return Ok(output); - } - let mut chunk = [0u8; STDERR_BYTES]; - loop { - let mut bytes_read: u32 = 0; - let ok = unsafe { - ffi::ReadFile( - handle, - chunk.as_mut_ptr().cast(), - chunk.len() as u32, - &mut bytes_read, - std::ptr::null_mut(), - ) - }; - if ok == 0 || bytes_read == 0 { - return Ok(output); - } - let n = bytes_read as usize; - let room = cap.saturating_sub(output.bytes.len()); - output.bytes.extend_from_slice(&chunk[..n.min(room)]); - if let Some(limit) = max_lines - && let Some((last, _)) = output - .bytes - .iter() - .enumerate() - .filter(|(_, byte)| **byte == b'\n') - .nth(limit - 1) - { - output.bytes.truncate(last + 1); - output.limit_reached = true; - return Ok(output); - } - if cap > STDERR_BYTES && output.bytes.len() > MAX_OUTPUT_BYTES { - return Err(Denial::new( - codes::TOO_LARGE, - format!("git's output is larger than {MAX_OUTPUT_BYTES} bytes"), - )); +pub struct CommandInput { + command: Command, + _isolation: Option, +} +impl From for CommandInput { + fn from(value: HardenedCommand) -> Self { + Self { + command: value.command, + _isolation: Some(value._isolation), } } } - -#[cfg(windows)] -fn spawn_job_command(command: Command) -> Result<(ProcessJob, OwnedHandle, OwnedHandle), Denial> { - let program = command.get_program(); - let program_str = program.to_string_lossy(); - let lower_prog = program_str.to_ascii_lowercase(); - - if lower_prog.ends_with(".bat") || lower_prog.ends_with(".cmd") { - return Err(Denial::new( - codes::GIT, - format!("{program_str:?} is a .bat or .cmd script, which is refused"), - )); +impl From for CommandInput { + fn from(command: Command) -> Self { + Self { + command, + _isolation: None, + } } +} - let resolved_prog = if lower_prog == "git" || lower_prog == "git.exe" { - resolve_git()?.to_path_buf() - } else { - PathBuf::from(program) - }; - - let mut cmdline = String::new(); - append_windows_arg(&mut cmdline, &resolved_prog.to_string_lossy()); - for arg in command.get_args() { - cmdline.push(' '); - append_windows_arg(&mut cmdline, &arg.to_string_lossy()); +fn environment(command: &Command, hardened: bool) -> Result, Denial> { + fn key(value: &OsStr) -> Vec { + value + .encode_wide() + .map(|c| { + if (b'a' as u16..=b'z' as u16).contains(&c) { + c - 32 + } else { + c + } + }) + .collect() } - let mut cmdline_wide: Vec = to_wide_null(&cmdline); - - let mut env_map = BTreeMap::new(); - let is_hardened = command - .get_envs() - .any(|(k, v)| k == "GIT_CONFIG_NOSYSTEM" && v.is_some()); - if !is_hardened { - for (k, v) in std::env::vars_os() { - env_map.insert(k, v); + let mut entries = BTreeMap::new(); + if !hardened { + for (name, value) in std::env::vars_os() { + entries.insert(key(&name), (name, value)); } } - for (k, v) in command.get_envs() { - if let Some(val) = v { - env_map.insert(k.to_os_string(), val.to_os_string()); + for (name, value) in command.get_envs() { + if let Some(value) = value { + entries.insert(key(name), (name.to_owned(), value.to_owned())); } else { - env_map.remove(k); + entries.remove(&key(name)); } } - - let mut entries: Vec<(OsString, OsString)> = env_map.into_iter().collect(); - entries.sort_by(|(k1, _), (k2, _)| { - k1.to_string_lossy() - .to_ascii_uppercase() - .cmp(&k2.to_string_lossy().to_ascii_uppercase()) - }); - - let mut env_block: Vec = Vec::new(); - for (k, v) in &entries { - let k_str = k.to_string_lossy(); - let v_str = v.to_string_lossy(); - for ch in k_str.encode_utf16() { - env_block.push(ch); - } - env_block.push('=' as u16); - for ch in v_str.encode_utf16() { - env_block.push(ch); - } - env_block.push(0); + let mut block = Vec::new(); + for (_, (name, value)) in entries { + let name = wide(name)?; + block.extend_from_slice(&name[..name.len() - 1]); + block.push(b'=' as u16); + block.extend(wide(value)?); } - env_block.push(0); + if block.is_empty() { + block.push(0); + } + block.push(0); + Ok(block) +} - let cwd_wide = command.get_current_dir().map(|p| { - let clean = strip_verbatim_prefix(p); - to_wide_null(&clean) - }); - let cwd_ptr = cwd_wide - .as_ref() - .map(|v| v.as_ptr()) - .unwrap_or(std::ptr::null()); +/// The optional observer runs while the primary thread is still suspended. It +/// is used by native tests to inspect the exact job, not just any inherited job. +fn spawn_job_command( + input: &CommandInput, + observer: impl FnOnce(&ProcessJob) -> Result<(), Denial>, +) -> Result<(ProcessJob, OwnedHandle, OwnedHandle), Denial> { + spawn_with_argv0(input, observer, None) +} - let job = unsafe { ffi::CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; +fn spawn_with_argv0( + input: &CommandInput, + observer: impl FnOnce(&ProcessJob) -> Result<(), Denial>, + argv0: Option<&OsStr>, +) -> Result<(ProcessJob, OwnedHandle, OwnedHandle), Denial> { + let command = &input.command; + let program = command.get_program(); + let image = if program == "git" || program == "git.exe" { + resolve_git()?.to_path_buf() + } else { + canonical_image(Path::new(program))? + }; + let application = wide(&image)?; + let mut command_line = Vec::new(); + append_windows_arg(&mut command_line, argv0.unwrap_or(image.as_os_str()))?; + for argument in command.get_args() { + command_line.push(b' ' as u16); + append_windows_arg(&mut command_line, argument)?; + } + command_line.push(0); + if command_line.len() > 32767 { + return Err(Denial::invalid("Windows command line is too long")); + } + let env = environment(command, input._isolation.is_some())?; + let cwd = command + .get_current_dir() + .map(|p| drive_path(p).and_then(wide)) + .transpose()?; + let cwd_pointer = cwd.as_ref().map_or(std::ptr::null(), |p| p.as_ptr()); + let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; if job.is_null() { - return Err(Denial::new( - codes::GIT, - format!( - "CreateJobObjectW failed: {}", - std::io::Error::last_os_error() - ), - )); - } - - let mut limits: ffi::JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; - limits.BasicLimitInformation.LimitFlags = ffi::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - let ok = unsafe { - ffi::SetInformationJobObject( - job, - ffi::JobObjectExtendedLimitInformation, - &limits as *const _ as *const ffi::c_void, - std::mem::size_of::() as u32, + return Err(native_error("create git job")); + } + let job = OwnedHandle(job); + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if unsafe { + SetInformationJobObject( + job.0, + JobObjectExtendedLimitInformation, + (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + std::mem::size_of_val(&limits) as u32, ) - }; - if ok == 0 { - let err = std::io::Error::last_os_error(); - unsafe { ffi::CloseHandle(job) }; - return Err(Denial::new( - codes::GIT, - format!("SetInformationJobObject failed: {err}"), - )); + } == 0 + { + return Err(native_error("git job limits")); } - - let sa_inherit = ffi::SECURITY_ATTRIBUTES { - nLength: std::mem::size_of::() as u32, + let inherit = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, lpSecurityDescriptor: std::ptr::null_mut(), - bInheritHandle: ffi::TRUE, + bInheritHandle: 1, }; - - let empty_file_wide = to_wide_null(empty_config_file()); - let mut stdin_handle = unsafe { - ffi::CreateFileW( - empty_file_wide.as_ptr(), - ffi::FILE_GENERIC_READ, - ffi::FILE_SHARE_READ | ffi::FILE_SHARE_WRITE | ffi::FILE_SHARE_DELETE, - &sa_inherit, - ffi::OPEN_EXISTING, - ffi::FILE_ATTRIBUTE_NORMAL, + // NUL is a device, never a reopen of the trusted global configuration file. + let nul = wide("NUL")?; + let stdin = unsafe { + CreateFileW( + nul.as_ptr(), + FILE_GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, + &inherit, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, std::ptr::null_mut(), ) }; - if stdin_handle == ffi::INVALID_HANDLE_VALUE { - let mut pipe_read = std::ptr::null_mut(); - let mut pipe_write = std::ptr::null_mut(); - unsafe { - ffi::CreatePipe(&mut pipe_read, &mut pipe_write, &sa_inherit, 0); - ffi::CloseHandle(pipe_write); - } - stdin_handle = pipe_read; - } - - let mut stdout_read = std::ptr::null_mut(); - let mut stdout_write = std::ptr::null_mut(); - if unsafe { ffi::CreatePipe(&mut stdout_read, &mut stdout_write, &sa_inherit, 0) } == 0 { - let err = std::io::Error::last_os_error(); + if stdin == INVALID_HANDLE_VALUE { + return Err(native_error("open inert stdin")); + } + let stdin = OwnedHandle(stdin); + let (stdout, stdout_writer) = pipe(&inherit)?; + let (stderr, stderr_writer) = pipe(&inherit)?; + let mut attributes = AttributeList::new(job.0, [stdin.0, stdout_writer.0, stderr_writer.0])?; + let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() }; + startup.StartupInfo.cb = std::mem::size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = stdin.0; + startup.StartupInfo.hStdOutput = stdout_writer.0; + startup.StartupInfo.hStdError = stderr_writer.0; + startup.lpAttributeList = attributes.pointer(); + let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + // SAFETY: buffers, owned attribute payloads, handles and startup structure + // remain live through creation; lpApplicationName is the canonical image. + if unsafe { + CreateProcessW( + application.as_ptr(), + command_line.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 1, + EXTENDED_STARTUPINFO_PRESENT + | CREATE_NO_WINDOW + | CREATE_UNICODE_ENVIRONMENT + | CREATE_SUSPENDED, + env.as_ptr().cast(), + cwd_pointer, + &startup.StartupInfo, + &mut process, + ) + } == 0 + { + return Err(native_error("start git")); + } + let primary_thread = OwnedHandle(process.hThread); + let guard = ProcessJob { + process: OwnedHandle(process.hProcess), + job: Some(job), + terminated: false, + #[cfg(test)] + fail_termination_once: false, + }; + let mut member = 0; + // Verify the exact job before the child can perform any work. A failed + // verification drops the job and kills the still-suspended process. + if unsafe { IsProcessInJob(guard.process.0, guard.job.as_ref().unwrap().0, &mut member) } == 0 + || member == 0 + { + // If assignment ever fails, job closure cannot kill an unassigned child. + // It is still suspended and its primary thread has never done work. unsafe { - if stdin_handle != ffi::INVALID_HANDLE_VALUE { - ffi::CloseHandle(stdin_handle); - } - ffi::CloseHandle(job); + TerminateProcess(guard.process.0, 1); } return Err(Denial::new( codes::GIT, - format!("CreatePipe stdout failed: {err}"), + "git did not join its containment job", )); } - unsafe { ffi::SetHandleInformation(stdout_read, ffi::HANDLE_FLAG_INHERIT, 0) }; - - let mut stderr_read = std::ptr::null_mut(); - let mut stderr_write = std::ptr::null_mut(); - if unsafe { ffi::CreatePipe(&mut stderr_read, &mut stderr_write, &sa_inherit, 0) } == 0 { - let err = std::io::Error::last_os_error(); - unsafe { - if stdin_handle != ffi::INVALID_HANDLE_VALUE { - ffi::CloseHandle(stdin_handle); - } - ffi::CloseHandle(stdout_read); - ffi::CloseHandle(stdout_write); - ffi::CloseHandle(job); - } + observer(&guard)?; + if unsafe { ResumeThread(primary_thread.0) } != 1 { return Err(Denial::new( codes::GIT, - format!("CreatePipe stderr failed: {err}"), + "git primary thread was not suspended exactly once", )); } - unsafe { ffi::SetHandleInformation(stderr_read, ffi::HANDLE_FLAG_INHERIT, 0) }; + Ok((guard, stdout, stderr)) +} - let mut attr_list = match AttributeList::new(2) { - Ok(list) => list, - Err(e) => { - unsafe { - if stdin_handle != ffi::INVALID_HANDLE_VALUE { - ffi::CloseHandle(stdin_handle); - } - ffi::CloseHandle(stdout_read); - ffi::CloseHandle(stdout_write); - ffi::CloseHandle(stderr_read); - ffi::CloseHandle(stderr_write); - ffi::CloseHandle(job); - } - return Err(e); - } +struct PipeOutput { + bytes: Vec, + limit_reached: bool, +} + +fn drain_pipe( + handle: HANDLE, + cap: usize, + lines: Option, + cancelled: &AtomicBool, +) -> Result { + let mut output = PipeOutput { + bytes: Vec::new(), + limit_reached: false, }; - if let Err(e) = attr_list.set_job_list(job) { - unsafe { - if stdin_handle != ffi::INVALID_HANDLE_VALUE { - ffi::CloseHandle(stdin_handle); - } - ffi::CloseHandle(stdout_read); - ffi::CloseHandle(stdout_write); - ffi::CloseHandle(stderr_read); - ffi::CloseHandle(stderr_write); - ffi::CloseHandle(job); + let mut count = 0; + loop { + if cancelled.load(Ordering::Acquire) { + return Ok(output); } - return Err(e); - } - let handles = [stdin_handle, stdout_write, stderr_write]; - if let Err(e) = attr_list.set_handle_list(&handles) { - unsafe { - if stdin_handle != ffi::INVALID_HANDLE_VALUE { - ffi::CloseHandle(stdin_handle); + let mut available = 0; + // Peek avoids an uncancellable synchronous read when exceptional cleanup + // cannot terminate a process. Only this thread reads this pipe. + if unsafe { + PeekNamedPipe( + handle, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut available, + std::ptr::null_mut(), + ) + } == 0 + { + if std::io::Error::last_os_error().raw_os_error() == Some(ERROR_BROKEN_PIPE as i32) { + return Ok(output); } - ffi::CloseHandle(stdout_read); - ffi::CloseHandle(stdout_write); - ffi::CloseHandle(stderr_read); - ffi::CloseHandle(stderr_write); - ffi::CloseHandle(job); + return Err(native_error("peek git pipe")); } - return Err(e); - } - - let mut startup: ffi::STARTUPINFOEXW = unsafe { std::mem::zeroed() }; - startup.StartupInfo.cb = std::mem::size_of::() as u32; - startup.StartupInfo.dwFlags = ffi::STARTF_USESTDHANDLES; - startup.StartupInfo.hStdInput = stdin_handle; - startup.StartupInfo.hStdOutput = stdout_write; - startup.StartupInfo.hStdError = stderr_write; - startup.lpAttributeList = attr_list.as_mut_ptr(); - - let mut pi: ffi::PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; - let creation_flags = - ffi::EXTENDED_STARTUPINFO_PRESENT | ffi::CREATE_NO_WINDOW | ffi::CREATE_UNICODE_ENVIRONMENT; - - let ok = unsafe { - ffi::CreateProcessW( - std::ptr::null(), - cmdline_wide.as_mut_ptr(), - std::ptr::null(), - std::ptr::null(), - ffi::TRUE, - creation_flags, - env_block.as_ptr().cast(), - cwd_ptr, - &startup, - &mut pi, - ) - }; - - unsafe { - if stdin_handle != ffi::INVALID_HANDLE_VALUE { - ffi::CloseHandle(stdin_handle); + if available == 0 { + thread::sleep(Duration::from_millis(2)); + continue; } - ffi::CloseHandle(stdout_write); - ffi::CloseHandle(stderr_write); - } - - if ok == 0 { - let err = std::io::Error::last_os_error(); - unsafe { - ffi::CloseHandle(stdout_read); - ffi::CloseHandle(stderr_read); - ffi::CloseHandle(job); + let mut chunk = [0u8; STDERR_BYTES]; + let mut read = 0; + if unsafe { + ReadFile( + handle, + chunk.as_mut_ptr(), + available.min(chunk.len() as u32), + &mut read, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(native_error("read git pipe")); + } + for byte in &chunk[..read as usize] { + if output.bytes.len() < cap { + output.bytes.push(*byte); + } + if cap > STDERR_BYTES && output.bytes.len() > MAX_OUTPUT_BYTES { + return Err(Denial::new( + codes::TOO_LARGE, + "git output exceeds the byte cap", + )); + } + if *byte == b'\n' { + count += 1; + if lines.is_some_and(|limit| count >= limit) { + output.limit_reached = true; + return Ok(output); + } + } } - return Err(Denial::new( - codes::GIT, - format!("git could not start: {err}"), - )); } +} - unsafe { - ffi::CloseHandle(pi.hThread); +struct ScopeCleanup<'a> { + guard: &'a mut ProcessJob, + cancelled: &'a AtomicBool, +} +impl Drop for ScopeCleanup<'_> { + fn drop(&mut self) { + self.guard.stop_tree(); + self.cancelled.store(true, Ordering::Release); } - - let guard = ProcessJob::new(pi.hProcess, job); - let stdout_pipe = OwnedHandle(stdout_read); - let stderr_pipe = OwnedHandle(stderr_read); - Ok((guard, stdout_pipe, stderr_pipe)) } -#[cfg(windows)] enum Completion { Exit(Result), Stdout(Result), Stderr(Result), } +#[derive(Default)] +struct Faults { + #[cfg(test)] + fail_termination_once: bool, + #[cfg(test)] + panic_after_waiter: bool, +} -#[cfg(windows)] -pub fn run_command_until(command: Command, max_lines: Option) -> Result { - let (mut guard, stdout_read, stderr_read) = spawn_job_command(command)?; - let stdout_raw = stdout_read.raw() as usize; - let stderr_raw = stderr_read.raw() as usize; - let process_raw = guard.process as usize; - - let deadline = Instant::now() + TIMEOUT; - let (tx, completed) = mpsc::channel(); - +fn run_until( + input: CommandInput, + lines: Option, + timeout: Duration, + _faults: Faults, +) -> Result { + let (mut guard, stdout, stderr) = spawn_job_command(&input, |_| Ok(()))?; + #[cfg(test)] + { + guard.fail_termination_once = _faults.fail_termination_once; + } + let out = stdout.0 as usize; + let err = stderr.0 as usize; + let process = guard.process.0 as usize; + let deadline = Instant::now() + timeout; + let cancelled = AtomicBool::new(false); + let (sender, receiver) = mpsc::channel(); thread::scope(|scope| { - let exit_tx = tx.clone(); + // This guard drops on an early return or a panic during thread startup, + // BEFORE scope joins workers. The outer process/pipe owners outlive joins. + let cleanup = ScopeCleanup { + guard: &mut guard, + cancelled: &cancelled, + }; + let exit_sender = sender.clone(); + let cancel = &cancelled; scope.spawn(move || { - let process_handle = process_raw as ffi::HANDLE; - let wait_res = unsafe { ffi::WaitForSingleObject(process_handle, ffi::INFINITE) }; - if wait_res == ffi::WAIT_OBJECT_0 { - let mut exit_code: u32 = 0; - let ok = unsafe { ffi::GetExitCodeProcess(process_handle, &mut exit_code) }; - if ok != 0 { - let _ = exit_tx.send(Completion::Exit(Ok(exit_code))); - } else { - let _ = exit_tx.send(Completion::Exit(Err(Denial::new( - codes::GIT, - format!( - "GetExitCodeProcess failed: {}", - std::io::Error::last_os_error() - ), - )))); + let result = loop { + let wait = unsafe { WaitForSingleObject(process as HANDLE, 10) }; + if wait == WAIT_OBJECT_0 { + let mut code = 0; + break if unsafe { GetExitCodeProcess(process as HANDLE, &mut code) } == 0 { + Err(native_error("git exit code")) + } else { + Ok(code) + }; } - } else { - let _ = exit_tx.send(Completion::Exit(Err(Denial::new( - codes::GIT, - "WaitForSingleObject on git process failed", - )))); - } + if wait != WAIT_TIMEOUT { + break Err(native_error("wait for git")); + } + if cancel.load(Ordering::Acquire) { + break Err(Denial::new(codes::GIT, "git wait cancelled")); + } + }; + let _ = exit_sender.send(Completion::Exit(result)); }); - - let out_tx = tx.clone(); + #[cfg(test)] + if _faults.panic_after_waiter { + panic!("injected thread-start failure"); + } + let out_sender = sender.clone(); scope.spawn(move || { - let stdout_handle = stdout_raw as ffi::HANDLE; - let res = drain_pipe(stdout_handle, MAX_OUTPUT_BYTES + 1, max_lines); - let _ = out_tx.send(Completion::Stdout(res)); + let _ = out_sender.send(Completion::Stdout(drain_pipe( + out as HANDLE, + MAX_OUTPUT_BYTES + 1, + lines, + cancel, + ))); }); - - let err_tx = tx.clone(); scope.spawn(move || { - let stderr_handle = stderr_raw as ffi::HANDLE; - let res = drain_pipe(stderr_handle, STDERR_BYTES, None); - let _ = err_tx.send(Completion::Stderr(res)); + let _ = sender.send(Completion::Stderr(drain_pipe( + err as HANDLE, + STDERR_BYTES, + None, + cancel, + ))); }); - - let mut exit_code: Option = None; - let mut stdout: Option> = None; - let mut stderr: Option> = None; - let mut limit_reached = false; - let mut early_err: Option = None; - - while exit_code.is_none() || stdout.is_none() || stderr.is_none() { - let remaining = deadline.saturating_duration_since(Instant::now()); - match completed.recv_timeout(remaining) { - Ok(Completion::Exit(result)) => { - match result { - Ok(code) => exit_code = Some(code), - Err(e) => { - if early_err.is_none() { - early_err = Some(e); - } - exit_code = Some(1); - } - } - guard.terminate(); + let mut code = None; + let mut output = None; + let mut errors = None; + let mut limited = false; + while code.is_none() || output.is_none() || errors.is_none() { + let event = receiver + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .map_err(|_| { + Denial::new( + codes::TIMEOUT, + format!("git ran longer than {} s", timeout.as_secs()), + ) + })?; + match event { + Completion::Exit(result) => { + code = Some(result?); + cleanup.guard.stop_tree(); } - Ok(Completion::Stdout(result)) => match result { - Ok(output) => { - limit_reached = output.limit_reached; - if limit_reached { - guard.terminate(); - } - stdout = Some(output.bytes); + Completion::Stdout(result) => { + let result = result?; + limited = result.limit_reached; + if limited { + cleanup.guard.stop_tree(); } - Err(e) => { - if early_err.is_none() { - early_err = Some(e); - } - guard.terminate(); - stdout = Some(Vec::new()); - } - }, - Ok(Completion::Stderr(result)) => match result { - Ok(output) => stderr = Some(output.bytes), - Err(e) => { - if early_err.is_none() { - early_err = Some(e); - } - guard.terminate(); - stderr = Some(Vec::new()); - } - }, - Err(mpsc::RecvTimeoutError::Timeout) => { - guard.terminate(); - return Err(timeout_denial()); + output = Some(result.bytes); } - Err(mpsc::RecvTimeoutError::Disconnected) => { - guard.terminate(); - return Err(Denial::new(codes::GIT, "git waiter disconnected")); + Completion::Stderr(result) => { + errors = Some(result?.bytes); } } } - - // The job is terminated before any drain thread is joined - guard.terminate(); - - if let Some(err) = early_err { - return Err(err); - } - - let code = exit_code.unwrap(); - let success = limit_reached || code == 0; Ok(Ran { - success, - code: if limit_reached { - Some(0) - } else { - Some(code as i32) - }, - stdout: stdout.unwrap(), - stderr: String::from_utf8_lossy(&stderr.unwrap()).into_owned(), + success: limited || code == Some(0), + code: Some(if limited { 0 } else { code.unwrap() as i32 }), + stdout: output.unwrap(), + stderr: String::from_utf8_lossy(&errors.unwrap()).into_owned(), }) }) } -#[cfg(windows)] -pub fn run_command(command: Command) -> Result { - run_command_until(command, None) +pub fn run_command(command: impl Into) -> Result { + run_until(command.into(), None, TIMEOUT, Faults::default()) } - -#[cfg(windows)] -pub fn run_tags_command(command: Command) -> Result { - run_command_until(command, Some(MAX_TAGS)) +pub fn run_tags_command(command: impl Into) -> Result { + run_until(command.into(), Some(MAX_TAGS), TIMEOUT, Faults::default()) } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn windows_strip_verbatim_prefix() { - assert_eq!( - strip_verbatim_prefix(Path::new(r"\\?\C:\repo")), - PathBuf::from(r"C:\repo") - ); - assert_eq!( - strip_verbatim_prefix(Path::new("//?/C:/repo")), - PathBuf::from("C:/repo") - ); - assert_eq!( - strip_verbatim_prefix(Path::new(r"C:\repo")), - PathBuf::from(r"C:\repo") - ); - } - - #[test] - fn windows_git_resolution_refuses_bat_and_cmd() { - assert!( - resolve_git_binary("git.bat", None) - .unwrap_err() - .message - .contains("refused") - ); - assert!( - resolve_git_binary("git.cmd", None) - .unwrap_err() - .message - .contains("refused") - ); - assert!( - resolve_git_binary(r"C:\bin\git.bat", None) - .unwrap_err() - .message - .contains("refused") - ); - assert!( - resolve_git_binary(r"C:\bin\git.cmd", None) - .unwrap_err() - .message - .contains("refused") - ); - assert!( - resolve_git_binary(r"C:\bin\git.BAT", None) - .unwrap_err() - .message - .contains("refused") - ); - assert!( - resolve_git_binary(r"C:\bin\git.CMD", None) - .unwrap_err() - .message - .contains("refused") - ); - - let temp = std::env::temp_dir().join(format!("basal-git-test-res-{}", std::process::id())); - let _ = std::fs::create_dir_all(&temp); - let bad_dir = temp.join("bad"); - let good_dir = temp.join("good"); - std::fs::create_dir_all(&bad_dir).unwrap(); - std::fs::create_dir_all(&good_dir).unwrap(); - - std::fs::write(bad_dir.join("git.cmd"), b"@echo off\r\n").unwrap(); - std::fs::write(good_dir.join("git.exe"), b"MZ...").unwrap(); - - let path_bad_first = format!("{};{}", bad_dir.display(), good_dir.display()); - let res_bad = resolve_git_binary("git", Some(OsStr::new(&path_bad_first))); - assert!( - res_bad.as_ref().unwrap_err().message.contains("refused"), - "Must refuse git.cmd earlier in PATH" - ); - - let path_good_first = format!("{};{}", good_dir.display(), bad_dir.display()); - let res_good = resolve_git_binary("git", Some(OsStr::new(&path_good_first))); - assert!(res_good.is_ok(), "Must resolve git.exe"); - assert_eq!(res_good.unwrap(), good_dir.join("git.exe")); - - let _ = std::fs::remove_dir_all(&temp); - } - - #[test] - fn windows_argv_and_environment() { - let repo = Path::new(r"\\?\C:\test\repo"); - let command = hardened_command(repo); - - let prog = command.get_program().to_string_lossy(); - assert!( - prog.ends_with(".exe") || prog == "git.exe", - "Program must be .exe, got: {prog}" - ); - assert!(!prog.ends_with(".bat")); - assert!(!prog.ends_with(".cmd")); - - let args: Vec = command - .get_args() - .map(|a| a.to_string_lossy().into_owned()) - .collect(); - - assert_eq!(args[0], "-C"); - assert_eq!(args[1], r"C:\test\repo"); - assert!(!args[1].starts_with(r"\\?\")); - assert!(!args[1].starts_with("//?/")); - - assert!(args.contains(&"--no-optional-locks".to_string())); - assert!(args.contains(&"--no-pager".to_string())); - assert!(args.contains(&"--literal-pathspecs".to_string())); - assert!(args.contains(&"core.fsmonitor=false".to_string())); - assert!(args.contains(&"core.pager=cat".to_string())); - assert!(args.contains(&"log.showSignature=false".to_string())); - assert!(args.contains(&"protocol.allow=never".to_string())); - - let hooks_arg = args - .iter() - .find(|a| a.starts_with("core.hooksPath=")) - .expect("core.hooksPath argument must be present"); - let hooks_val = hooks_arg.strip_prefix("core.hooksPath=").unwrap(); - assert_ne!(hooks_val, "/dev/null"); - assert_ne!(hooks_val, "NUL"); - assert_ne!(hooks_val, "nul"); - let hooks_path = Path::new(hooks_val); - assert!( - hooks_path.is_dir(), - "core.hooksPath must be an existing directory" - ); - assert_eq!( - std::fs::read_dir(hooks_path).unwrap().count(), - 0, - "core.hooksPath must be empty" - ); - - let envs: BTreeMap> = command - .get_envs() - .map(|(k, v)| { - ( - k.to_string_lossy().into_owned(), - v.map(|s| s.to_string_lossy().into_owned()), - ) - }) - .collect(); - - assert_eq!( - envs.get("GIT_CONFIG_NOSYSTEM"), - Some(&Some("1".to_string())) - ); - assert_eq!(envs.get("LC_ALL"), Some(&Some("C".to_string()))); - assert_eq!( - envs.get("GIT_TERMINAL_PROMPT"), - Some(&Some("0".to_string())) - ); - assert_eq!(envs.get("GIT_NO_LAZY_FETCH"), Some(&Some("1".to_string()))); - - let ceiling = envs - .get("GIT_CEILING_DIRECTORIES") - .expect("GIT_CEILING_DIRECTORIES must be set") - .as_ref() - .unwrap(); - assert_eq!(ceiling, r"C:\test"); - assert!(!ceiling.starts_with(r"\\?\")); - - let global_config = envs - .get("GIT_CONFIG_GLOBAL") - .expect("GIT_CONFIG_GLOBAL must be set") - .as_ref() - .unwrap(); - assert_ne!(global_config, "/dev/null"); - assert_ne!(global_config, "NUL"); - assert_ne!(global_config, "nul"); - let cfg_path = Path::new(global_config); - assert!( - cfg_path.is_file(), - "GIT_CONFIG_GLOBAL must be an existing file" - ); - assert_eq!( - std::fs::metadata(cfg_path).unwrap().len(), - 0, - "GIT_CONFIG_GLOBAL must be 0 bytes" - ); - - assert!(!envs.contains_key("HOME")); - } - - #[test] - fn windows_config_and_hooks_isolation_paths() { - let cfg = empty_config_file(); - assert!(cfg.is_file()); - assert_eq!(std::fs::metadata(cfg).unwrap().len(), 0); - - let hooks = empty_hooks_dir(); - assert!(hooks.is_dir()); - assert_eq!(std::fs::read_dir(hooks).unwrap().count(), 0); - } - - #[test] - #[cfg(windows)] - fn windows_timeout_proves_descendant_died() { - let directory = - std::env::temp_dir().join(format!("basal-git-win-timeout-{}", std::process::id())); - std::fs::create_dir_all(&directory).unwrap(); - let pid_file = directory.join("pid"); - let survivor = directory.join("survivor"); - - let script = format!( - "$p = Start-Process cmd.exe -ArgumentList '/c ping -n 35 127.0.0.1 >nul & echo survived > \"{}\"' -PassThru; $p.Id | Out-File -FilePath \"{}\" -Encoding ascii -NoNewline; $p.WaitForExit()", - survivor.display(), - pid_file.display() - ); - - let mut command = Command::new("powershell"); - command - .args(["-NoProfile", "-Command", &script]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let error = run_command(command).err().expect("command must time out"); - assert_eq!(error.code, codes::TIMEOUT); - - let pid_str = std::fs::read_to_string(&pid_file).expect("pid file must be written"); - let pid: u32 = pid_str.trim().parse().expect("pid must be a valid integer"); - - let survived = survivor.exists(); - assert!(!survived, "git's descendant survived the timeout"); - - let handle = unsafe { - ffi::OpenProcess( - ffi::PROCESS_QUERY_LIMITED_INFORMATION | ffi::SYNCHRONIZE, - ffi::FALSE, - pid, - ) - }; - if !handle.is_null() { - let wait_res = unsafe { ffi::WaitForSingleObject(handle, 0) }; - assert_eq!( - wait_res, - ffi::WAIT_OBJECT_0, - "descendant process must be signaled (dead)" - ); - let mut exit_code: u32 = 0; - let ok = unsafe { ffi::GetExitCodeProcess(handle, &mut exit_code) }; - assert_ne!(ok, 0, "GetExitCodeProcess should succeed"); - assert_ne!( - exit_code, - ffi::STILL_ACTIVE, - "descendant process must not be STILL_ACTIVE" - ); - unsafe { ffi::CloseHandle(handle) }; - } - - let _ = std::fs::remove_dir_all(directory); - } - - #[test] - #[cfg(windows)] - fn windows_output_cap_refuses_oversized_output() { - let mut command = Command::new("cmd"); - command - .args([ - "/c", - "for /L %i in (1,1,300000) do @echo 0123456789012345678901234567890123456789", - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let error = run_command(command) - .err() - .expect("command must exceed output cap"); - assert_eq!(error.code, codes::TOO_LARGE); - } - - #[test] - #[cfg(windows)] - fn windows_tags_output_is_bounded_while_reading() { - let mut command = Command::new("cmd"); - command - .args(["/c", "for /L %i in (1,1,4000) do @echo tag-%i"]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let result = run_tags_command(command).expect("tag command must succeed"); - assert!(result.success); - assert_eq!( - String::from_utf8(result.stdout).unwrap().lines().count(), - MAX_TAGS - ); - } - - #[test] - #[cfg(windows)] - fn windows_git_hook_suppression_and_config_isolation() { - if resolve_git().is_err() { - return; - } - - let temp_dir = - std::env::temp_dir().join(format!("basal-git-win-test-repo-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&temp_dir); - std::fs::create_dir_all(&temp_dir).unwrap(); - - let run_setup = |args: &[&str]| { - let status = Command::new(resolve_git().unwrap()) - .args(args) - .current_dir(&temp_dir) - .status() - .unwrap(); - assert!(status.success()); - }; - - run_setup(&["init"]); - run_setup(&["config", "user.name", "TestUser"]); - run_setup(&["config", "user.email", "test@example.com"]); - - let fake_hooks_dir = temp_dir.join("fake_hooks"); - std::fs::create_dir_all(&fake_hooks_dir).unwrap(); - let marker_file = temp_dir.join("hook_was_run.marker"); - let fake_hook = fake_hooks_dir.join("post-checkout.bat"); - std::fs::write( - &fake_hook, - format!("@echo ran > \"{}\"\r\n", marker_file.display()), - ) - .unwrap(); - - run_setup(&[ - "config", - "core.hooksPath", - &fake_hooks_dir.to_string_lossy(), - ]); - run_setup(&["config", "core.fsmonitor", "false"]); - - let file_path = temp_dir.join("file.txt"); - std::fs::write(&file_path, b"hello\n").unwrap(); - run_setup(&["add", "file.txt"]); - run_setup(&["commit", "-m", "init"]); - - let ran = super::super::git(&temp_dir, &["log", "-1"]).expect("git log must succeed"); - assert!(ran.success); - - assert!( - !marker_file.exists(), - "Planted hook must NOT have run due to core.hooksPath override" - ); - - let _ = std::fs::remove_dir_all(temp_dir); - } -} +mod tests; diff --git a/crates/basal-host/src/builtins/git/windows/tests.rs b/crates/basal-host/src/builtins/git/windows/tests.rs new file mode 100644 index 0000000..d670732 --- /dev/null +++ b/crates/basal-host/src/builtins/git/windows/tests.rs @@ -0,0 +1,805 @@ +use super::*; +use std::sync::Arc; +use windows_sys::Win32::{ + Foundation::STILL_ACTIVE, + System::{ + JobObjects::QueryInformationJobObject, + Threading::{ + CREATE_BREAKAWAY_FROM_JOB, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + PROCESS_SYNCHRONIZE, + }, + }, +}; + +struct Tree(PathBuf); +impl Tree { + fn new() -> Self { + let mut random = [0u8; 16]; + assert!( + unsafe { + BCryptGenRandom( + std::ptr::null_mut(), + random.as_mut_ptr(), + random.len() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + } >= 0 + ); + let name: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let path = std::env::temp_dir().join(format!("basal-git-test-{name}")); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + fn p(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} +impl Drop for Tree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn child_image() -> &'static Path { + static IMAGE: OnceLock = OnceLock::new(); + IMAGE.get_or_init(|| { + let tree = Tree::new(); + let source = tree.p("child.rs"); + std::fs::write(&source, include_str!("../fixtures/windows_child.rs")).unwrap(); + let image = tree.p("argv-echo.exe"); + let output = Command::new("rustc") + .args(["--edition=2024", "-Dwarnings"]) + .arg(&source) + .arg("-o") + .arg(&image) + .output() + .expect("native rustc is required"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + // Keep the compiled child executable available to concurrent tests. + std::mem::forget(tree); + image + }) +} +fn child(mode: &str) -> Command { + let mut command = Command::new(child_image()); + command.arg(mode); + command +} +fn succeeded(result: Ran) -> Ran { + assert!(result.success, "{}", result.stderr); + result +} +fn runner(command: Command, lines: Option, timeout: Duration) -> Result { + run_until(command.into(), lines, timeout, Faults::default()) +} + +#[test] +fn windows_crt_argv_round_trip_preserves_quotes_and_utf16() { + let arguments = [ + OsString::from(""), + OsString::from("HEAD:a\"b"), + OsString::from("a\\\"b"), + OsString::from("a\\\\\"b"), + OsString::from("with space\\"), + OsString::from("\t\n"), + OsString::from("C:\\résumé\\日本語"), + OsString::from_wide(&[0xd800, b'"' as u16, b'\\' as u16]), + ]; + let mut command = child("argv"); + command.args(&arguments); + let result = succeeded(run_command(command).unwrap()); + let actual: Vec> = String::from_utf8(result.stdout) + .unwrap() + .lines() + .map(|line| { + line.as_bytes() + .chunks(4) + .map(|unit| u16::from_str_radix(std::str::from_utf8(unit).unwrap(), 16).unwrap()) + .collect() + }) + .collect(); + let expected: Vec> = arguments + .iter() + .map(|arg| arg.encode_wide().collect()) + .collect(); + assert_eq!(actual, expected); +} + +#[test] +fn windows_drive_paths_refuse_unc_and_preserve_utf16() { + for path in [ + r"\\?\UNC\server\share\repo", + r"\\?\Volume{abc}\repo", + r"\\.\C:\repo", + r"\\server\share\repo", + r"C:repo", + r"\repo", + ] { + assert!(drive_path(Path::new(path)).is_err(), "accepted {path}"); + } + let input = OsString::from_wide(&[92, 92, 63, 92, 67, 58, 92, 0xd800]); + assert_eq!( + drive_path(Path::new(&input)) + .unwrap() + .as_os_str() + .encode_wide() + .collect::>(), + [67, 58, 92, 0xd800] + ); +} + +#[test] +fn windows_git_resolution_ignores_relative_path_and_cwd_images() { + let tree = Tree::new(); + std::fs::write(tree.p("git.exe"), b"MZ fixture, never executed").unwrap(); + let paths = std::env::join_paths([ + Path::new(""), + Path::new("relative-bin"), + Path::new(r"C:bin"), + Path::new(r"\bin"), + &tree.0, + ]) + .unwrap(); + let resolved = resolve_git_binary(OsStr::new("git"), Some(&paths)).unwrap(); + assert!(resolved.is_absolute()); + assert_eq!( + resolved, + drive_path(&std::fs::canonicalize(tree.p("git.exe")).unwrap()).unwrap() + ); + let only_relative = OsStr::new(";relative-bin;C:bin;\\bin"); + assert!(resolve_git_binary(OsStr::new("git"), Some(only_relative)).is_err()); + assert!(resolve_git_binary(OsStr::new("relative-bin\\git.exe"), None).is_err()); + for extension in ["bat", "CMD"] { + assert!(resolve_git_binary(tree.p(&format!("git.{extension}")).as_os_str(), None).is_err()); + } + let image = tree.p("git.exe"); + std::fs::remove_file(&image).unwrap(); + std::fs::write(tree.p("git.cmd"), "exit /b 0").unwrap(); + assert!(resolve_git_binary(OsStr::new("git"), Some(&paths)).is_err()); +} + +#[test] +fn windows_explicit_application_is_independent_of_current_directory() { + let tree = Tree::new(); + // A script and a fake executable in the CWD must not shadow the resolved image. + std::fs::write(tree.p("argv-echo.exe"), "not an executable").unwrap(); + std::fs::write(tree.p("git.exe"), "not an executable").unwrap(); + let mut command = child("argv"); + command.current_dir(&tree.0).arg("stable image"); + assert_eq!( + succeeded(run_command(command).unwrap()).stdout, + b"0073007400610062006c006500200069006d006100670065\n" + ); + let marker = tree.p("application-marker"); + let mut command = child("mark"); + command.arg(&marker).current_dir(&tree.0); + let input = command.into(); + // Deliberately misleading argv[0] distinguishes explicit application selection + // from CreateProcess's fallback command-line executable search. + let wrong_image = tree.p("git.exe"); + let (guard, _, _) = + spawn_with_argv0(&input, |_| Ok(()), Some(wrong_image.as_os_str())).unwrap(); + assert_eq!( + unsafe { WaitForSingleObject(guard.process.0, 5000) }, + WAIT_OBJECT_0 + ); + assert!(marker.exists()); +} + +#[test] +fn windows_private_isolation_is_fresh_exclusive_and_pinned() { + let first = Isolation::new().unwrap(); + let second = Isolation::new().unwrap(); + assert_ne!(first.directory, second.directory); + let config = first.directory.join("global.config"); + assert_eq!(std::fs::metadata(&config).unwrap().len(), 0); + assert!(std::fs::write(&config, "[alias]\npwn = !whoami\n").is_err()); + assert!(std::fs::rename(&config, first.directory.join("swapped.config")).is_err()); + assert!(std::fs::rename(&first.directory, first.directory.with_extension("swap")).is_err()); + let old_predictable = + std::env::temp_dir().join(format!("basal-git-isolation-{}", std::process::id())); + // The obsolete name is untrusted; no production helper consults it. + std::fs::create_dir_all(&old_predictable).unwrap(); + std::fs::write( + old_predictable.join("empty.config"), + "[alias]\npwn = !whoami\n", + ) + .unwrap(); + let third = Isolation::new().unwrap(); + assert_ne!(third.directory, old_predictable); + assert_eq!( + std::fs::metadata(third.directory.join("global.config")) + .unwrap() + .len(), + 0 + ); + std::fs::remove_dir_all(old_predictable).unwrap(); + let directory = first.directory.clone(); + drop(first); + assert!(!directory.exists()); + assert_eq!( + succeeded(run_command(child("stdin")).unwrap()).stdout, + b"inert\n" + ); +} + +#[test] +fn windows_attribute_payloads_and_job_membership_before_work() { + let tree = Tree::new(); + let marker = tree.p("work"); + let mut command = child("mark"); + command.arg(&marker); + let input: CommandInput = command.into(); + let (mut guard, _, _) = spawn_job_command(&input, |guard| { + assert!(!marker.exists(), "child ran before membership verification"); + let job = guard.job.as_ref().unwrap().0; + let mut in_job = 0; + assert_ne!( + unsafe { IsProcessInJob(guard.process.0, job, &mut in_job) }, + 0 + ); + assert_ne!(in_job, 0); + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + assert_ne!( + unsafe { + QueryInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (&mut limits as *mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + std::mem::size_of_val(&limits) as u32, + std::ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!( + limits.BasicLimitInformation.LimitFlags, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + ); + // Moving an attribute owner must not move either retained payload. + let inherit = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: std::ptr::null_mut(), + bInheritHandle: 1, + }; + let (_read_a, write_a) = pipe(&inherit).unwrap(); + let (_read_b, write_b) = pipe(&inherit).unwrap(); + let (_read_c, write_c) = pipe(&inherit).unwrap(); + let attributes = AttributeList::new(job, [write_a.0, write_b.0, write_c.0]).unwrap(); + let jobs = attributes.jobs.as_ptr(); + let handles = attributes.handles.as_ptr(); + let moved = Box::new(attributes); + assert_eq!(moved.jobs.as_ptr(), jobs); + assert_eq!(moved.handles.as_ptr(), handles); + Ok(()) + }) + .unwrap(); + assert_eq!( + unsafe { WaitForSingleObject(guard.process.0, 5000) }, + WAIT_OBJECT_0 + ); + assert!(marker.exists()); + guard.stop_tree(); + let mut command = child("breakaway"); + command + .arg(tree.p("escaped")) + .arg(CREATE_BREAKAWAY_FROM_JOB.to_string()); + succeeded(run_command(command).unwrap()); + assert!(!tree.p("escaped").exists()); +} + +fn await_file(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(10); + while !path.exists() { + assert!( + Instant::now() < deadline, + "child did not create {}", + path.display() + ); + thread::sleep(Duration::from_millis(5)); + } +} +fn descendant(pid_path: &Path) -> OwnedHandle { + await_file(pid_path); + let pid: u32 = std::fs::read_to_string(pid_path).unwrap().parse().unwrap(); + let handle = unsafe { + OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, + 0, + pid, + ) + }; + assert!( + !handle.is_null(), + "cannot retain live descendant: {}", + std::io::Error::last_os_error() + ); + let handle = OwnedHandle(handle); + assert_eq!(unsafe { WaitForSingleObject(handle.0, 0) }, WAIT_TIMEOUT); + let mut code = 0; + assert_ne!(unsafe { GetExitCodeProcess(handle.0, &mut code) }, 0); + assert_eq!(code, STILL_ACTIVE as u32); + handle +} +fn require_dead(handle: &OwnedHandle) { + assert_eq!( + unsafe { WaitForSingleObject(handle.0, 5000) }, + WAIT_OBJECT_0, + "descendant survived cleanup" + ); + let mut code = 0; + assert_ne!(unsafe { GetExitCodeProcess(handle.0, &mut code) }, 0); + assert_ne!(code, STILL_ACTIVE as u32); +} + +#[test] +fn windows_timeout_proves_live_descendant_died() { + let tree = Arc::new(Tree::new()); + let worker_tree = tree.clone(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut command = child("spawn"); + command + .arg(worker_tree.p("pid")) + .arg(worker_tree.p("ready")); + tx.send(runner(command, None, Duration::from_secs(3)).map(|_| ())) + .unwrap(); + }); + let handle = descendant(&tree.p("pid")); + let error = rx + .recv_timeout(Duration::from_secs(10)) + .unwrap() + .unwrap_err(); + assert_eq!(error.code, codes::TIMEOUT); + require_dead(&handle); +} + +#[test] +fn windows_post_exit_kills_descendant_holding_both_pipes() { + let tree = Arc::new(Tree::new()); + let worker_tree = tree.clone(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut command = child("post-exit"); + command + .arg(worker_tree.p("pid")) + .arg(worker_tree.p("ready")); + tx.send(run_command(command).map(|_| ())).unwrap(); + }); + let handle = descendant(&tree.p("pid")); + await_file(&tree.p("ready")); + std::fs::write(tree.p("ready.release"), "release").unwrap(); + rx.recv_timeout(Duration::from_secs(10)).unwrap().unwrap(); + require_dead(&handle); +} + +#[test] +fn windows_tags_stop_while_reading_and_enforce_byte_cap() { + let mut command = child("lines"); + command.arg("80"); + let result = succeeded(runner(command, Some(MAX_TAGS), Duration::from_secs(3)).unwrap()); + assert_eq!( + result.stdout.iter().filter(|b| **b == b'\n').count(), + MAX_TAGS + ); + let mut command = child("lines"); + command.arg((MAX_OUTPUT_BYTES / (MAX_TAGS - 1) + 1).to_string()); + assert_eq!( + runner(command, Some(MAX_TAGS), Duration::from_secs(3)) + .err() + .unwrap() + .code, + codes::TOO_LARGE + ); +} + +#[test] +fn windows_failed_termination_is_retryable_and_scope_unwind_is_bounded() { + let tree = Arc::new(Tree::new()); + let worker_tree = tree.clone(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut command = child("hold"); + command.arg(worker_tree.p("pid")); + let input = command.into(); + let (mut guard, _, _) = spawn_job_command(&input, |_| Ok(())).unwrap(); + guard.fail_termination_once = true; + assert!(guard.terminate().is_err()); + assert!(!guard.terminated); + guard.terminate().unwrap(); + assert!(guard.terminated); + assert_eq!( + unsafe { WaitForSingleObject(guard.process.0, 5000) }, + WAIT_OBJECT_0 + ); + let mut command = child("hold"); + command.arg(worker_tree.p("panic-pid")); + let panicked = std::panic::catch_unwind(|| { + run_until( + command.into(), + None, + Duration::from_secs(2), + Faults { + fail_termination_once: true, + panic_after_waiter: true, + }, + ) + }); + assert!(panicked.is_err()); + tx.send(()).unwrap(); + }); + rx.recv_timeout(Duration::from_secs(10)) + .expect("scope cleanup hung after startup failure"); +} + +#[test] +fn windows_only_stdio_handles_are_inherited() { + let inherit = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: std::ptr::null_mut(), + bInheritHandle: 1, + }; + let (read, write) = pipe(&inherit).unwrap(); + let tree = Tree::new(); + let mut command = child("hold"); + command.arg(tree.p("pid")); + let input = command.into(); + let (mut guard, _, _) = spawn_job_command(&input, |_| Ok(())).unwrap(); + await_file(&tree.p("pid")); + drop(write); + let mut available = 0; + assert_eq!( + unsafe { + PeekNamedPipe( + read.0, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut available, + std::ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(ERROR_BROKEN_PIPE as i32), + "unrelated inheritable writer leaked to child" + ); + guard.stop_tree(); +} + +fn repository(tree: &Tree) -> PathBuf { + let repo = tree.p("repo"); + std::fs::create_dir(&repo).unwrap(); + setup_git(&repo, &["init", "-q"]); + std::fs::write(repo.join("file.txt"), "one\n").unwrap(); + setup_git(&repo, &["add", "."]); + setup_git( + &repo, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "commit", + "-qm", + "first", + ], + ); + std::fs::write(repo.join("file.txt"), "two\n").unwrap(); + setup_git(&repo, &["add", "."]); + setup_git( + &repo, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "commit", + "-qm", + "second", + ], + ); + repo +} +fn plain_git(repo: &Path) -> Command { + let mut command = Command::new( + resolve_git().expect("native git.exe is required for Windows confinement tests"), + ); + command + .arg("-C") + .arg(repo) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "NUL"); + command +} +fn setup_git(repo: &Path, args: &[&str]) { + let output = plain_git(repo).args(args).output().unwrap(); + assert!( + output.status.success(), + "setup git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} +fn junction(link: &Path, target: &Path) { + let output = Command::new("cmd.exe") + .args(["/d", "/c", "mklink", "/J"]) + .arg(link) + .arg(target) + .output() + .unwrap(); + assert!( + output.status.success(), + "junction creation failed: {}", + String::from_utf8_lossy(&output.stdout) + ); +} + +#[test] +fn windows_repository_walk_refuses_junctions_and_pins_all_ancestors() { + let tree = Tree::new(); + let parent = tree.p("parent"); + std::fs::create_dir(&parent).unwrap(); + let repo = parent.join("repo"); + std::fs::create_dir(&repo).unwrap(); + let approved = [repo.to_str().unwrap().to_owned()]; + // Positive control: the same names really can be replaced without a pin. + let moved = tree.p("moved"); + std::fs::rename(&parent, &moved).unwrap(); + std::fs::rename(&moved, &parent).unwrap(); + let pin = super::super::repo(repo.to_str().unwrap(), &approved).unwrap(); + assert!( + std::fs::rename(&parent, &moved).is_err(), + "ancestor replaced while git is authorized" + ); + assert!( + std::fs::rename(&repo, parent.join("old-repo")).is_err(), + "repo replaced while git is authorized" + ); + let mut command = child("argv"); + command.current_dir(pin.path()).arg("pinned"); + succeeded(run_command(command).unwrap()); + drop(pin); + std::fs::rename(&parent, &moved).unwrap(); + junction(&parent, &moved); + assert!(super::super::repo(repo.to_str().unwrap(), &approved).is_err()); + std::fs::remove_dir(&parent).unwrap(); + junction(&tree.p("root-link"), &moved.join("repo")); + let link = tree.p("root-link").to_str().unwrap().to_owned(); + assert!(super::super::repo(&link, std::slice::from_ref(&link)).is_err()); + std::fs::remove_dir(tree.p("root-link")).unwrap(); + let sub = moved.join("repo").join("sub"); + std::fs::create_dir(&sub).unwrap(); + assert!( + super::super::repo( + sub.to_str().unwrap(), + &[moved.join("repo").to_str().unwrap().to_owned()] + ) + .is_err() + ); +} + +fn marker_script(tree: &Tree, name: &str) -> (PathBuf, PathBuf) { + let marker = tree.p(&format!("{name}.marker")); + let script = tree.p(&format!("{name}.sh")); + let marker_shell = marker + .to_str() + .unwrap() + .replace('\\', "/") + .replace('\'', "'\\''"); + std::fs::write( + &script, + format!("#!/bin/sh\nprintf ran >> '{marker_shell}'\nprintf 'converted\\n'\n"), + ) + .unwrap(); + (script, marker) +} +fn helper_value(script: &Path) -> String { + format!( + "sh '{}'", + script + .to_str() + .unwrap() + .replace('\\', "/") + .replace('\'', "'\\''") + ) +} +fn hard_git(repo: &Path, args: &[&str]) -> Ran { + let mut command = hardened_command(repo).unwrap(); + command.args(args); + run_command(command).unwrap() +} +fn positive_marker(repo: &Path, args: &[&str], marker: &Path) { + let mut command = plain_git(repo); + command.args(args); + succeeded(run_command(command).unwrap()); + assert!( + marker.exists(), + "positive control did not execute the planted helper: {args:?}" + ); + std::fs::remove_file(marker).unwrap(); +} + +#[test] +fn windows_hook_positive_control_and_hardened_suppression() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "hook"); + // Hooks have no .bat suffix: Git for Windows executes the shebang file. + std::fs::copy( + script, + repo.join(".git").join("hooks").join("post-checkout"), + ) + .unwrap(); + positive_marker(&repo, &["checkout", "--detach", "HEAD~"], &marker); + succeeded(hard_git(&repo, &["checkout", "--detach", "HEAD"])); + assert!(!marker.exists(), "hook ran through hardened hooksPath"); +} + +#[test] +fn windows_fsmonitor_positive_control_and_hardened_suppression() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "fsmonitor"); + setup_git(&repo, &["config", "core.fsmonitor", &helper_value(&script)]); + positive_marker(&repo, &["status", "--porcelain"], &marker); + succeeded(hard_git(&repo, &["status", "--porcelain"])); + assert!(!marker.exists(), "fsmonitor ran through hardened command"); +} + +#[test] +fn windows_external_diff_positive_control_and_builtin_suppression() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "external-diff"); + setup_git(&repo, &["config", "diff.external", &helper_value(&script)]); + positive_marker(&repo, &["diff", "HEAD~", "HEAD", "--", "file.txt"], &marker); + let path = repo.to_str().unwrap().to_owned(); + let op = super::super::Op::Diff { + from: "HEAD~".into(), + to: "HEAD".into(), + path: Some("file.txt".into()), + }; + super::super::run(&path, std::slice::from_ref(&path), &op).unwrap(); + assert!(!marker.exists(), "external diff ran through builtin diff"); +} + +#[test] +fn windows_textconv_positive_control_and_builtin_suppression() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "textconv"); + std::fs::write(repo.join(".gitattributes"), "file.txt diff=marker\n").unwrap(); + setup_git( + &repo, + &["config", "diff.marker.textconv", &helper_value(&script)], + ); + positive_marker(&repo, &["diff", "HEAD~", "HEAD", "--", "file.txt"], &marker); + let path = repo.to_str().unwrap().to_owned(); + let op = super::super::Op::Diff { + from: "HEAD~".into(), + to: "HEAD".into(), + path: Some("file.txt".into()), + }; + super::super::run(&path, std::slice::from_ref(&path), &op).unwrap(); + assert!(!marker.exists(), "textconv ran through builtin diff"); +} + +#[test] +fn windows_contaminated_global_config_positive_control_and_isolation() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "global"); + let config = tree.p("hostile-global.config"); + let alias = format!("!{}", helper_value(&script)); + let output = Command::new(resolve_git().unwrap()) + .args(["config", "--file"]) + .arg(&config) + .args(["alias.basal-poison", &alias]) + .output() + .unwrap(); + assert!(output.status.success()); + let mut command = plain_git(&repo); + command + .env("GIT_CONFIG_GLOBAL", &config) + .arg("basal-poison"); + succeeded(run_command(command).unwrap()); + assert!( + marker.exists(), + "contaminated global alias positive control did not run" + ); + std::fs::remove_file(&marker).unwrap(); + // Supply the poison as inherited host environment to the spawn recipe without + // altering this test process's environment (other tests run concurrently). + let mut command = hardened_command(&repo).unwrap(); + command.arg("basal-poison"); + let block = environment(&command.command, true).unwrap(); + assert!(!String::from_utf16_lossy(&block).contains(config.to_str().unwrap())); + let result = run_command(command).unwrap(); + assert!( + !result.success, + "global alias survived isolated global config" + ); + assert!(!marker.exists()); +} + +#[test] +fn windows_hostile_environment_child() { + let Some(repo) = std::env::var_os("BASAL_GIT_ENV_REPO") else { + return; + }; + let config = + std::env::var_os("GIT_CONFIG_GLOBAL").expect("parent supplied contaminated global config"); + let mut positive = plain_git(Path::new(&repo)); + positive + .env_remove("GIT_DIR") + .env_remove("GIT_CONFIG_COUNT") + .env("GIT_CONFIG_GLOBAL", &config) + .arg("basal-poison"); + succeeded(run_command(positive).unwrap()); + let marker = PathBuf::from(std::env::var_os("BASAL_GIT_ENV_MARKER").unwrap()); + assert!(marker.exists()); + std::fs::remove_file(&marker).unwrap(); + let result = hard_git(Path::new(&repo), &["basal-poison"]); + assert!(!result.success); + assert!( + !marker.exists(), + "inherited global config executed a helper" + ); + let mut command = hardened_command(Path::new(&repo)).unwrap(); + command.args(["rev-parse", "--show-toplevel"]); + let result = succeeded(run_command(command).unwrap()); + assert!( + String::from_utf8(result.stdout).unwrap().contains("repo"), + "inherited GIT_DIR redirected the repository" + ); +} + +#[test] +fn windows_inherited_git_environment_is_not_imported() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "inherited-global"); + let config = tree.p("global.config"); + let alias = format!("!{}", helper_value(&script)); + let output = Command::new(resolve_git().unwrap()) + .args(["config", "--file"]) + .arg(&config) + .args(["alias.basal-poison", &alias]) + .output() + .unwrap(); + assert!(output.status.success()); + // A subprocess is the real host with hostile inherited settings. No unsafe + // process-wide environment mutation races this suite's concurrent tests. + let output = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "builtins::git::windows::tests::windows_hostile_environment_child", + "--nocapture", + ]) + .env("BASAL_GIT_ENV_REPO", &repo) + .env("BASAL_GIT_ENV_MARKER", &marker) + .env("GIT_CONFIG_GLOBAL", &config) + .env("GIT_DIR", tree.p("not-a-repository")) + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "alias.basal-poison") + .env("GIT_CONFIG_VALUE_0", &alias) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("1 passed"), + "child control never executed" + ); + assert!(!marker.exists()); +} From 80c43148d2e1bd0489e06c32b1dda24a126d68a5 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:56:04 +0200 Subject: [PATCH 14/15] mason: accept the host Windows temp directory separator Native run 38065124338 exercised all new git tests: seven failed only because GetTempPathW's trailing backslash was passed unchanged to the fs raw-spelling validator. Normalize just that host-selected base before the unchanged reparse-refusing fs walk; repository path policy remains strict. Add a regression test for that native temp spelling, inspect the actual protected DACL of directory/hooks/config (only SYSTEM and owner rights), refuse a temp junction, and attempt config/hook replacements concurrently while their guards are live. MSVC scratch lib/tests clippy -D warnings passes. Mac/Linux gates are unchanged by these Windows-only edits. All 44 fs tests passed in the first native run; rerun Windows host tests to close the seven git failures. --- crates/basal-host/src/builtins/git/windows.rs | 14 ++- .../src/builtins/git/windows/tests.rs | 94 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/crates/basal-host/src/builtins/git/windows.rs b/crates/basal-host/src/builtins/git/windows.rs index 57446d1..c08319e 100644 --- a/crates/basal-host/src/builtins/git/windows.rs +++ b/crates/basal-host/src/builtins/git/windows.rs @@ -202,7 +202,19 @@ struct Isolation { } impl Isolation { fn new() -> Result { - let base = drive_path(&std::env::temp_dir())?; + Self::in_base(&std::env::temp_dir()) + } + + fn in_base(base: &Path) -> Result { + let base = drive_path(base)?; + // GetTempPathW normally includes a trailing separator. Remove only that + // separator from this host-selected base, not from repository inputs; + // the fs walk still rejects ambiguous components and reparse points. + let mut units: Vec = base.as_os_str().encode_wide().collect(); + while units.len() > 3 && units.last() == Some(&(b'\\' as u16)) { + units.pop(); + } + let base = PathBuf::from(OsString::from_wide(&units)); let base = base .to_str() .ok_or_else(|| Denial::invalid("temp directory is not Unicode"))?; diff --git a/crates/basal-host/src/builtins/git/windows/tests.rs b/crates/basal-host/src/builtins/git/windows/tests.rs index d670732..171ef93 100644 --- a/crates/basal-host/src/builtins/git/windows/tests.rs +++ b/crates/basal-host/src/builtins/git/windows/tests.rs @@ -200,6 +200,17 @@ fn windows_private_isolation_is_fresh_exclusive_and_pinned() { assert!(std::fs::write(&config, "[alias]\npwn = !whoami\n").is_err()); assert!(std::fs::rename(&config, first.directory.join("swapped.config")).is_err()); assert!(std::fs::rename(&first.directory, first.directory.with_extension("swap")).is_err()); + let hooks = first.directory.join("hooks"); + let config_path = config.clone(); + let hooks_path = hooks.clone(); + thread::spawn(move || { + for _ in 0..20 { + assert!(std::fs::write(&config_path, "[alias]\npwn = !whoami\n").is_err()); + assert!(std::fs::rename(&hooks_path, hooks_path.with_extension("swap")).is_err()); + } + }) + .join() + .unwrap(); let old_predictable = std::env::temp_dir().join(format!("basal-git-isolation-{}", std::process::id())); // The obsolete name is untrusted; no production helper consults it. @@ -227,6 +238,89 @@ fn windows_private_isolation_is_fresh_exclusive_and_pinned() { ); } +fn private_dacl(path: &Path) -> String { + use windows_sys::Win32::Security::{ + Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, GetNamedSecurityInfoW, + SE_FILE_OBJECT, + }, + DACL_SECURITY_INFORMATION, + }; + let mut descriptor = std::ptr::null_mut(); + assert_eq!( + unsafe { + GetNamedSecurityInfoW( + wide(path).unwrap().as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut descriptor, + ) + }, + 0 + ); + let descriptor = SecurityDescriptor(descriptor); + let mut sddl = std::ptr::null_mut(); + let mut length = 0; + assert_ne!( + unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor.0, + 1, + DACL_SECURITY_INFORMATION, + &mut sddl, + &mut length, + ) + }, + 0 + ); + let text = unsafe { + String::from_utf16(std::slice::from_raw_parts(sddl, length as usize - 1)).unwrap() + }; + unsafe { + LocalFree(sddl.cast()); + } + text +} + +#[test] +fn windows_isolation_trims_host_temp_separator_and_has_private_dacl() { + let tree = Tree::new(); + let mut base = tree.0.as_os_str().to_owned(); + base.push("\\"); + let isolation = Isolation::in_base(Path::new(&base)).unwrap(); + assert_eq!(isolation.directory.parent(), Some(tree.0.as_path())); + for path in [ + &isolation.directory, + &isolation.directory.join("hooks"), + &isolation.directory.join("global.config"), + ] { + let sddl = private_dacl(path); + // Both explicit and inherited ACLs must grant only SYSTEM and the owner. + assert!(sddl.starts_with("D:P"), "DACL is not protected: {sddl}"); + let trustees: Vec<&str> = sddl + .split(";;;") + .skip(1) + .map(|ace| ace.split(')').next().unwrap()) + .collect(); + assert_eq!(trustees.len(), 2, "unexpected ACL entries: {sddl}"); + assert!( + trustees.contains(&"SY") && trustees.contains(&"OW"), + "not private: {sddl}" + ); + } + let link = tree.p("temp-junction"); + junction(&link, &tree.0); + assert!( + Isolation::in_base(&link).is_err(), + "temp junction was accepted" + ); + std::fs::remove_dir(link).unwrap(); +} + #[test] fn windows_attribute_payloads_and_job_membership_before_work() { let tree = Tree::new(); From 4bd60901388d3f2d4b93bd5d0dd86f905e2a8a94 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:11:31 +0200 Subject: [PATCH 15/15] mason: observe handle inheritance in the exact Windows git child Native run 38065638982 had 44 fs and 21 git passes, with one handle test failure: PeekNamedPipe returned TRUE after parent writer closure. That establishes that a writer exists somewhere, not that the tested process owns it; concurrent ordinary setup/rustc spawns can inherit the same sentinel. Microsoft CreateProcessW Remarks explicitly document this multithreaded inheritance problem, and its bInheritHandles documentation says inherited handles have the same numeric value and access rights. Keep the parent's writer alive and DuplicateHandle from the exact tested child's handle table before resume and after it starts; CompareObjectHandles distinguishes the sentinel from a reused numeric slot or OS-created object. Add a deliberately unrestricted suspended CreateProcess(TRUE) positive control requiring that same-object observation to succeed. This strengthens the child-specific no-leak claim instead of relaxing it or retrying global EOF. No product handle allowlist is weakened; a real target-child leak will fail these assertions. References: learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw (Parameters/Remarks), learn.microsoft.com/en-us/windows/win32/api/handleapi/nf-handleapi-duplicatehandle (Parameters/Remarks), learn.microsoft.com/en-us/windows/win32/api/handleapi/nf-handleapi-compareobjecthandles (Return value). Also preserve the original uniform git repository-denial contract when the fs pin walk fails (including missing unapproved paths), with an existence-privacy regression test. Strengthen global isolation with a real default HOME/.gitconfig positive control as well as the explicit GIT_CONFIG_GLOBAL control, so clearing inherited environment alone cannot satisfy the test. MSVC mounted lib/tests clippy with -D warnings passes; edits are Windows-only and do not impact earlier Mac/Linux gates. Native rerun pending. --- crates/basal-host/src/builtins/git.rs | 7 +- .../src/builtins/git/windows/tests.rs | 159 +++++++++++++++--- 2 files changed, 135 insertions(+), 31 deletions(-) diff --git a/crates/basal-host/src/builtins/git.rs b/crates/basal-host/src/builtins/git.rs index e472a9d..d17af8e 100644 --- a/crates/basal-host/src/builtins/git.rs +++ b/crates/basal-host/src/builtins/git.rs @@ -249,7 +249,8 @@ pub(crate) fn repo( let path = path .to_str() .ok_or_else(|| Denial::invalid("repository is not Unicode"))?; - let pinned = pin_directory(path)?; + let refused = || Denial::denied("repository is outside the manifest's repositories or missing"); + let pinned = pin_directory(path).map_err(|_| refused())?; for approved in repos.iter().filter_map(|r| expand_home(r)) { if let Some(approved) = approved.to_str() && let Ok(root) = pin_directory(approved) @@ -258,9 +259,7 @@ pub(crate) fn repo( return Ok(pinned); } } - Err(Denial::denied( - "repository is outside the manifest's repositories or missing", - )) + Err(refused()) } /// The only way the built-ins run git: a `git -C ` command that can diff --git a/crates/basal-host/src/builtins/git/windows/tests.rs b/crates/basal-host/src/builtins/git/windows/tests.rs index 171ef93..0f6d6b3 100644 --- a/crates/basal-host/src/builtins/git/windows/tests.rs +++ b/crates/basal-host/src/builtins/git/windows/tests.rs @@ -1,12 +1,15 @@ use super::*; use std::sync::Arc; use windows_sys::Win32::{ - Foundation::STILL_ACTIVE, + Foundation::{ + CompareObjectHandles, DUPLICATE_SAME_ACCESS, DuplicateHandle, ERROR_INVALID_HANDLE, + STILL_ACTIVE, + }, System::{ JobObjects::QueryInformationJobObject, Threading::{ - CREATE_BREAKAWAY_FROM_JOB, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, - PROCESS_SYNCHRONIZE, + CREATE_BREAKAWAY_FROM_JOB, GetCurrentProcess, OpenProcess, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, }, }, }; @@ -532,41 +535,107 @@ fn windows_failed_termination_is_retryable_and_scope_unwind_is_bounded() { #[test] fn windows_only_stdio_handles_are_inherited() { + // Compile before publishing any inheritable test handle to other processes. + let image = wide(child_image()).unwrap(); let inherit = SECURITY_ATTRIBUTES { nLength: std::mem::size_of::() as u32, lpSecurityDescriptor: std::ptr::null_mut(), bInheritHandle: 1, }; - let (read, write) = pipe(&inherit).unwrap(); - let tree = Tree::new(); - let mut command = child("hold"); - command.arg(tree.p("pid")); - let input = command.into(); - let (mut guard, _, _) = spawn_job_command(&input, |_| Ok(())).unwrap(); - await_file(&tree.p("pid")); - drop(write); - let mut available = 0; - assert_eq!( + let (_read, write) = pipe(&inherit).unwrap(); + // Positive control: CreateProcess(TRUE) without a handle list inherits this + // exact pipe writer. The child remains suspended and is killed by the guard. + let mut startup: windows_sys::Win32::System::Threading::STARTUPINFOW = + unsafe { std::mem::zeroed() }; + startup.cb = std::mem::size_of_val(&startup) as u32; + let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + let mut argv = Vec::new(); + append_windows_arg(&mut argv, child_image().as_os_str()).unwrap(); + argv.push(0); + assert_ne!( unsafe { - PeekNamedPipe( - read.0, - std::ptr::null_mut(), - 0, - std::ptr::null_mut(), - &mut available, - std::ptr::null_mut(), + CreateProcessW( + image.as_ptr(), + argv.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 1, + CREATE_SUSPENDED | CREATE_NO_WINDOW, + std::ptr::null(), + std::ptr::null(), + &startup, + &mut process, ) }, 0 ); - assert_eq!( - std::io::Error::last_os_error().raw_os_error(), - Some(ERROR_BROKEN_PIPE as i32), - "unrelated inheritable writer leaked to child" + let control = SuspendedControl(OwnedHandle(process.hProcess)); + let _primary_thread = OwnedHandle(process.hThread); + assert!( + process_has_handle(control.0.0, write.0), + "positive control did not inherit the writer" + ); + drop(control); + let tree = Tree::new(); + let mut command = child("hold"); + command.arg(tree.p("pid")); + let input = command.into(); + let (mut guard, _, _) = spawn_job_command(&input, |guard| { + assert!( + !process_has_handle(guard.process.0, write.0), + "contained child inherited an unrelated writer before resume" + ); + Ok(()) + }) + .unwrap(); + await_file(&tree.p("pid")); + // Pipe EOF is not process-specific: another concurrent, unrestricted spawn + // can inherit the writer. Duplicate from the tested child's handle table + // instead; object comparison also distinguishes reuse of the numeric slot. + assert!( + !process_has_handle(guard.process.0, write.0), + "contained child owns an unrelated writer after resume" ); guard.stop_tree(); } +struct SuspendedControl(OwnedHandle); +impl Drop for SuspendedControl { + fn drop(&mut self) { + unsafe { + TerminateProcess(self.0.0, 1); + WaitForSingleObject(self.0.0, 5000); + } + } +} + +fn process_has_handle(process: HANDLE, original: HANDLE) -> bool { + let mut duplicate = std::ptr::null_mut(); + // Inherited handles have the same numeric value in both processes. Keep + // the parent's original live and compare objects, not just handle numbers. + if unsafe { + DuplicateHandle( + process, + original, + GetCurrentProcess(), + &mut duplicate, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) + } == 0 + { + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(ERROR_INVALID_HANDLE as i32), + "could not inspect the child's handle table" + ); + return false; + } + let duplicate = OwnedHandle(duplicate); + unsafe { CompareObjectHandles(original, duplicate.0) != 0 } +} + fn repository(tree: &Tree) -> PathBuf { let repo = tree.p("repo"); std::fs::create_dir(&repo).unwrap(); @@ -678,6 +747,26 @@ fn windows_repository_walk_refuses_junctions_and_pins_all_ancestors() { ); } +#[test] +fn windows_repository_refusals_do_not_reveal_unapproved_existence() { + let tree = Tree::new(); + let approved = tree.p("approved"); + std::fs::create_dir(&approved).unwrap(); + let roots = [approved.to_str().unwrap().to_owned()]; + let outside = tree.p("outside"); + let missing = tree.p("missing"); + std::fs::create_dir(&outside).unwrap(); + for path in [&outside, &missing] { + assert_eq!( + super::super::repo(path.to_str().unwrap(), &roots) + .err() + .unwrap() + .code, + codes::DENIED + ); + } +} + fn marker_script(tree: &Tree, name: &str) -> (PathBuf, PathBuf) { let marker = tree.p(&format!("{name}.marker")); let script = tree.p(&format!("{name}.sh")); @@ -808,10 +897,26 @@ fn windows_contaminated_global_config_positive_control_and_isolation() { "contaminated global alias positive control did not run" ); std::fs::remove_file(&marker).unwrap(); - // Supply the poison as inherited host environment to the spawn recipe without - // altering this test process's environment (other tests run concurrently). + let profile = tree.p("profile"); + std::fs::create_dir(&profile).unwrap(); + std::fs::copy(&config, profile.join(".gitconfig")).unwrap(); + let mut default_global = plain_git(&repo); + default_global + .env_remove("GIT_CONFIG_GLOBAL") + .env("HOME", &profile) + .env("USERPROFILE", &profile) + .arg("basal-poison"); + succeeded(run_command(default_global).unwrap()); + assert!( + marker.exists(), + "default user-global config positive control did not run" + ); + std::fs::remove_file(&marker).unwrap(); let mut command = hardened_command(&repo).unwrap(); - command.arg("basal-poison"); + command + .env("HOME", &profile) + .env("USERPROFILE", &profile) + .arg("basal-poison"); let block = environment(&command.command, true).unwrap(); assert!(!String::from_utf16_lossy(&block).contains(config.to_str().unwrap())); let result = run_command(command).unwrap();