diff --git a/crates/tinybus/src/module/cache.rs b/crates/tinybus/src/module/cache.rs index ac94835..f91ee58 100644 --- a/crates/tinybus/src/module/cache.rs +++ b/crates/tinybus/src/module/cache.rs @@ -403,7 +403,9 @@ fn create_private_dir_all(path: &Path) -> std::io::Result<()> { } #[cfg(not(unix))] { - std::fs::create_dir_all(path) + // Windows: an owner-only, inheritance-protected DACL; elsewhere a + // plain `create_dir_all`. + super::windows_acl::create_private_dir_all(path) } } @@ -433,7 +435,7 @@ pub(crate) fn secure_release_cache(install_root: &Path, dir: &Path) { } #[cfg(not(unix))] { - let _ = (install_root, dir); + super::windows_acl::secure_release_cache(install_root, dir); } } diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs index 8cb107e..c9af7ca 100644 --- a/crates/tinybus/src/module/host.rs +++ b/crates/tinybus/src/module/host.rs @@ -2348,7 +2348,7 @@ fn trusted_installer_sid() -> Vec { } #[cfg(windows)] -fn windows_path_grants_untrusted_write(path: &Path) -> Result { +pub(super) fn windows_path_grants_untrusted_write(path: &Path) -> Result { use std::ffi::c_void; use std::os::windows::ffi::OsStrExt; @@ -2417,15 +2417,11 @@ fn windows_path_grants_untrusted_write(path: &Path) -> Result { const SE_FILE_OBJECT: u32 = 1; const OWNER_SECURITY_INFORMATION: u32 = 0x1; const DACL_SECURITY_INFORMATION: u32 = 0x4; - const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; - const INHERIT_ONLY_ACE: u8 = 0x08; const WIN_CREATOR_OWNER_SID: u32 = 3; const WIN_LOCAL_SYSTEM_SID: u32 = 22; const WIN_BUILTIN_ADMINISTRATORS_SID: u32 = 26; const TOKEN_QUERY: u32 = 0x8; const TOKEN_USER: u32 = 1; - const WRITE_MASK: u32 = - 0x2 | 0x4 | 0x10 | 0x100 | 0x1_0000 | 0x4_0000 | 0x8_0000 | 0x1000_0000 | 0x4000_0000; let mut wide = path .as_os_str() @@ -2546,17 +2542,15 @@ fn windows_path_grants_untrusted_write(path: &Path) -> Result { return true; } let ace = ace.cast::(); - // An inherit-only ACE grants nothing on this object; it only - // seeds the ACL of children created later, and every module file - // is checked against its own ACL before it is loaded. - if unsafe { (*ace).header.ace_type } != ACCESS_ALLOWED_ACE_TYPE - || unsafe { (*ace).header.ace_flags } & INHERIT_ONLY_ACE != 0 - || unsafe { (*ace).mask } & WRITE_MASK == 0 - { - continue; - } + let ace_type = unsafe { (*ace).header.ace_type }; + let ace_flags = unsafe { (*ace).header.ace_flags }; + let mask = unsafe { (*ace).mask }; + // Whether an ACE counts is `windows_acl::ace_grants_untrusted_write`: + // inherit-only entries seed children (each module file is checked + // against its own ACL), and entries without a write right or that + // are not allow-ACEs grant nothing here. let sid = unsafe { std::ptr::addr_of!((*ace).sid_start) }.cast(); - let trusted = unsafe { EqualSid(sid, user_sid) } != 0 + let principal_trusted = unsafe { EqualSid(sid, user_sid) } != 0 || unsafe { EqualSid(sid, admin_sid.as_ptr().cast()) } != 0 || unsafe { EqualSid(sid, system_sid.as_ptr().cast()) } != 0 || unsafe { EqualSid(sid, trusted_installer) } != 0 @@ -2564,7 +2558,12 @@ fn windows_path_grants_untrusted_write(path: &Path) -> Result { // of each child. Check each module file's actual owner and // ACL too, before accepting this ACE on its parent directory. || unsafe { EqualSid(sid, creator_owner_sid.as_ptr().cast()) } != 0; - if !trusted { + if super::windows_acl::ace_grants_untrusted_write( + ace_type, + ace_flags, + mask, + principal_trusted, + ) { return true; } } diff --git a/crates/tinybus/src/module/mod.rs b/crates/tinybus/src/module/mod.rs index 3bc5527..34eb131 100644 --- a/crates/tinybus/src/module/mod.rs +++ b/crates/tinybus/src/module/mod.rs @@ -27,6 +27,8 @@ mod remembered_hash; mod resolve; #[cfg(feature = "modules")] mod transport; +#[cfg(feature = "modules")] +mod windows_acl; #[cfg(feature = "modules")] pub use cache::{artifact_dir, is_safe_path_component, prune_stale_versions}; diff --git a/crates/tinybus/src/module/windows_acl.rs b/crates/tinybus/src/module/windows_acl.rs new file mode 100644 index 0000000..14ba030 --- /dev/null +++ b/crates/tinybus/src/module/windows_acl.rs @@ -0,0 +1,505 @@ +//! Windows ACL policy for the module release cache. Feature `modules`. +//! +//! The directory gate in `host.rs` refuses a module directory when any principal +//! other than the current user, Administrators, SYSTEM, TrustedInstaller or +//! CREATOR OWNER holds a write ACE. On Unix the cache is created `0700` and an +//! older permissive one is repaired; this module does the same on Windows by +//! giving the cache a protected DACL that grants only the current user and +//! SYSTEM. A cache that inherits a group write ACE (a managed or redirected +//! `LOCALAPPDATA`, for example) is repaired instead of refused for good. +//! +//! The decisions (is this ACE acceptable, may this directory be repaired, what +//! DACL is written) are pure functions that run on every platform. Only the +//! Win32 calls are gated behind `cfg(windows)`. The gate itself is not loosened. + +use std::path::Path; + +/// Access-mask bits that let a principal change a directory or file: write +/// data, append, write EA and attributes, delete child, delete, WRITE_DAC, +/// WRITE_OWNER, GENERIC_WRITE and GENERIC_ALL. +pub(super) const WRITE_MASK: u32 = + 0x2 | 0x4 | 0x10 | 0x40 | 0x100 | 0x1_0000 | 0x4_0000 | 0x8_0000 | 0x1000_0000 | 0x4000_0000; +/// `ACCESS_ALLOWED_ACE_TYPE`. +pub(super) const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; +/// `INHERIT_ONLY_ACE`. +pub(super) const INHERIT_ONLY_ACE: u8 = 0x08; + +/// Whether one DACL entry lets an untrusted principal write the object itself. +/// +/// `principal_trusted` says whether the ACE's SID is the current user, +/// Administrators, SYSTEM, TrustedInstaller or CREATOR OWNER. Entries that are +/// not allow-ACEs, that only seed children (inherit-only), or that grant no +/// write access never count. +#[cfg_attr(not(windows), allow(dead_code))] +pub(super) fn ace_grants_untrusted_write( + ace_type: u8, + ace_flags: u8, + mask: u32, + principal_trusted: bool, +) -> bool { + ace_type == ACCESS_ALLOWED_ACE_TYPE + && ace_flags & INHERIT_ONLY_ACE == 0 + && mask & WRITE_MASK != 0 + && !principal_trusted +} + +/// Whether a directory may be rewritten by the repair pass: it is a real +/// directory (not a link), this user (or the Administrators group, the owner of +/// what an elevated administrator creates) owns it, and the gate would refuse it. +/// Anything else is left for the gate to judge. +#[cfg_attr(not(windows), allow(dead_code))] +pub(super) fn should_repair(is_real_dir: bool, owned_by_current_user: bool, refused: bool) -> bool { + is_real_dir && owned_by_current_user && refused +} + +/// Whether `directory` is inside the part of the tree the repair pass may +/// rewrite: at or below `install_root`, or strictly below `base` (the user's +/// local application data directory, never `base` itself or anything above it). +/// A path or root with a `..` component is never in scope. +/// Windows paths are case-insensitive, so components compare case-folded. +#[cfg_attr(not(windows), allow(dead_code))] +pub(super) fn in_repair_scope(directory: &Path, install_root: &Path, base: Option<&Path>) -> bool { + // A `..` in the directory or in either root could climb out of the prefix + // the comparison below accepts; such a path is never repaired. + let has_parent = |path: &Path| { + path.components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + }; + if has_parent(directory) || has_parent(install_root) || base.is_some_and(has_parent) { + return false; + } + let fold = |path: &Path| -> Vec { + path.components() + .map(|component| component.as_os_str().to_string_lossy().to_lowercase()) + .collect() + }; + let directory = fold(directory); + let inside = |root: Vec, strictly: bool| { + directory.starts_with(&root) && (!strictly || directory.len() > root.len()) + }; + inside(fold(install_root), false) || base.is_some_and(|base| inside(fold(base), true)) +} + +/// The SDDL for an owner-only, inheritance-protected directory DACL: +/// full control for `owner_sid` and SYSTEM, inherited by children, nothing +/// inherited from the parent. `None` when `owner_sid` is not a plain SID string, +/// so a malformed value can never smuggle extra ACEs into the descriptor. +#[cfg_attr(not(windows), allow(dead_code))] +pub(super) fn owner_only_sddl(owner_sid: &str) -> Option { + let valid = owner_sid.starts_with("S-1-") + && owner_sid.len() <= 184 + && owner_sid[2..] + .split('-') + .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())); + valid.then(|| format!("D:P(A;OICI;FA;;;{owner_sid})(A;OICI;FA;;;SY)")) +} + +/// `create_dir_all`, with every directory it creates owner-only. +#[cfg_attr(not(windows), allow(dead_code))] +pub(super) fn create_private_dir_all(path: &Path) -> std::io::Result<()> { + #[cfg(windows)] + { + win32::create_private_dir_all(path) + } + #[cfg(not(windows))] + { + std::fs::create_dir_all(path) + } +} + +/// Whether `path` is itself a symlink or junction (never followed). +#[cfg_attr(not(windows), allow(dead_code))] +fn is_link(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) +} + +/// Replace the DACL of release-cache directories this user owns when the gate +/// would refuse them. Best effort: a failure leaves the gate's verdict as is. +#[cfg_attr(not(windows), allow(dead_code))] +pub(super) fn secure_release_cache(install_root: &Path, dir: &Path) { + #[cfg(windows)] + { + let base = std::env::var_os("LOCALAPPDATA").map(std::path::PathBuf::from); + let scoped: Vec<&Path> = dir + .ancestors() + .take_while(|directory| in_repair_scope(directory, install_root, base.as_deref())) + .collect(); + // A junction or symlink anywhere in the chain could carry a repair + // outside the cache tree, so repair nothing when one is present. + if scoped.iter().any(|directory| is_link(directory)) { + tracing::debug!("[modules] release cache path crosses a link; not repaired"); + return; + } + for directory in scoped { + win32::repair_owned_directory(directory); + } + } + #[cfg(not(windows))] + { + let _ = (install_root, dir); + } +} + +#[cfg(windows)] +mod win32 { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + use std::path::Path; + + use super::{owner_only_sddl, should_repair}; + + #[repr(C)] + struct SecurityAttributes { + length: u32, + descriptor: *mut c_void, + inherit_handle: i32, + } + #[repr(C)] + struct ByHandleFileInformation { + attributes: u32, + times: [u64; 3], + volume_serial: u32, + size_high: u32, + size_low: u32, + links: u32, + index_high: u32, + index_low: u32, + } + #[repr(C)] + struct SidAndAttributes { + sid: *mut c_void, + attributes: u32, + } + + #[link(name = "advapi32")] + unsafe extern "system" { + fn ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl: *const u16, + revision: u32, + descriptor: *mut *mut c_void, + size: *mut u32, + ) -> i32; + fn ConvertSidToStringSidW(sid: *const c_void, string: *mut *mut u16) -> i32; + fn GetSecurityDescriptorDacl( + descriptor: *const c_void, + present: *mut i32, + dacl: *mut *mut c_void, + defaulted: *mut i32, + ) -> i32; + fn SetSecurityInfo( + handle: *mut c_void, + object_type: u32, + security_info: u32, + owner: *mut c_void, + group: *mut c_void, + dacl: *mut c_void, + sacl: *mut c_void, + ) -> u32; + fn GetSecurityInfo( + handle: *mut c_void, + object_type: u32, + security_info: u32, + owner: *mut *mut c_void, + group: *mut *mut c_void, + dacl: *mut *mut c_void, + sacl: *mut *mut c_void, + descriptor: *mut *mut c_void, + ) -> u32; + fn EqualSid(first: *const c_void, second: *const c_void) -> i32; + fn OpenProcessToken(process: *mut c_void, access: u32, token: *mut *mut c_void) -> i32; + fn GetTokenInformation( + token: *mut c_void, + class: u32, + information: *mut c_void, + length: u32, + returned_length: *mut u32, + ) -> i32; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn CreateDirectoryW(path: *const u16, attributes: *const SecurityAttributes) -> i32; + fn CreateFileW( + path: *const u16, + access: u32, + share: u32, + attributes: *const SecurityAttributes, + disposition: u32, + flags: u32, + template: *mut c_void, + ) -> *mut c_void; + fn GetFileInformationByHandle( + handle: *mut c_void, + information: *mut ByHandleFileInformation, + ) -> i32; + fn GetLastError() -> u32; + fn LocalFree(memory: *mut c_void) -> *mut c_void; + fn GetCurrentProcess() -> *mut c_void; + fn CloseHandle(handle: *mut c_void) -> i32; + } + + const SDDL_REVISION_1: u32 = 1; + const SE_FILE_OBJECT: u32 = 1; + const OWNER_SECURITY_INFORMATION: u32 = 0x1; + const DACL_SECURITY_INFORMATION: u32 = 0x4; + const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; + const TOKEN_QUERY: u32 = 0x8; + const TOKEN_USER: u32 = 1; + const ERROR_ALREADY_EXISTS: u32 = 183; + const READ_CONTROL: u32 = 0x2_0000; + const WRITE_DAC: u32 = 0x4_0000; + const FILE_READ_ATTRIBUTES: u32 = 0x80; + const FILE_SHARE_ALL: u32 = 0x7; + const OPEN_EXISTING: u32 = 3; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + + fn wide(path: &Path) -> Vec { + path.as_os_str().encode_wide().chain([0]).collect() + } + + /// The process token's user SID: a `usize`-aligned buffer holding the + /// token information, and a pointer into it that borrows from it. + fn current_user() -> Option<(Vec, *mut c_void)> { + let mut token = std::ptr::null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return None; + } + let mut len = 0; + unsafe { GetTokenInformation(token, TOKEN_USER, std::ptr::null_mut(), 0, &mut len) }; + if (len as usize) < size_of::() { + unsafe { CloseHandle(token) }; + return None; + } + let mut buffer = vec![0usize; (len as usize).div_ceil(size_of::())]; + let read = unsafe { + GetTokenInformation(token, TOKEN_USER, buffer.as_mut_ptr().cast(), len, &mut len) + }; + unsafe { CloseHandle(token) }; + if read == 0 { + return None; + } + let sid = unsafe { (*buffer.as_ptr().cast::()).sid }; + (!sid.is_null()).then_some((buffer, sid)) + } + + fn sid_string(sid: *const c_void) -> Option { + let mut text = std::ptr::null_mut(); + if unsafe { ConvertSidToStringSidW(sid, &mut text) } == 0 || text.is_null() { + return None; + } + let mut length = 0; + while unsafe { *text.add(length) } != 0 { + length += 1; + } + let value = String::from_utf16(unsafe { std::slice::from_raw_parts(text, length) }).ok(); + unsafe { LocalFree(text.cast()) }; + value + } + + /// A security descriptor built from the owner-only SDDL; freed on drop. + struct Descriptor(*mut c_void); + + impl Descriptor { + fn owner_only() -> Option { + let (_buffer, sid) = current_user()?; + let sddl = owner_only_sddl(&sid_string(sid)?)?; + let sddl = sddl.encode_utf16().chain([0]).collect::>(); + let mut descriptor = std::ptr::null_mut(); + let converted = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + }; + (converted != 0 && !descriptor.is_null()).then_some(Self(descriptor)) + } + } + + impl Drop for Descriptor { + fn drop(&mut self) { + unsafe { LocalFree(self.0) }; + } + } + + pub(super) fn create_private_dir_all(path: &Path) -> std::io::Result<()> { + create_private(path, true) + } + + /// `leaf` is the directory the caller asked for: an existing link there is + /// refused (a write through it would land elsewhere). Its parents are only + /// required to resolve to directories, since a redirected profile folder + /// high in the path is legitimate. + fn create_private(path: &Path, leaf: bool) -> std::io::Result<()> { + if let Ok(metadata) = std::fs::symlink_metadata(path) { + let plain_dir = metadata.file_type().is_dir(); + if plain_dir || (!leaf && path.is_dir()) { + return Ok(()); + } + return Err(std::io::Error::other( + "a release cache path is not a plain directory", + )); + } + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + create_private(parent, false)?; + } + let descriptor = Descriptor::owner_only().ok_or_else(|| { + std::io::Error::other("an owner-only directory ACL could not be built") + })?; + let attributes = SecurityAttributes { + length: size_of::() as u32, + descriptor: descriptor.0, + inherit_handle: 0, + }; + let path_wide = wide(path); + if unsafe { CreateDirectoryW(path_wide.as_ptr(), &attributes) } != 0 { + return Ok(()); + } + let code = unsafe { GetLastError() }; + if code == ERROR_ALREADY_EXISTS + && std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir()) + { + return Ok(()); + } + Err(std::io::Error::from_raw_os_error(code as i32)) + } + + /// An open directory handle that is closed on drop. + struct Directory(*mut c_void); + + impl Directory { + /// Opens `path` without following a final reparse point, and accepts + /// only a real directory. Every later check and the DACL write go + /// through this handle, so a name swapped after the open cannot + /// redirect them to another object. + fn open_plain(path: &Path) -> Option { + let name = wide(path); + let handle = unsafe { + CreateFileW( + name.as_ptr(), + READ_CONTROL | WRITE_DAC | FILE_READ_ATTRIBUTES, + FILE_SHARE_ALL, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + if handle.is_null() || handle as isize == -1 { + return None; + } + let directory = Self(handle); + let mut info = ByHandleFileInformation { + attributes: 0, + times: [0; 3], + volume_serial: 0, + size_high: 0, + size_low: 0, + links: 0, + index_high: 0, + index_low: 0, + }; + if unsafe { GetFileInformationByHandle(handle, &mut info) } == 0 { + return None; + } + let plain = info.attributes & FILE_ATTRIBUTE_DIRECTORY != 0 + && info.attributes & FILE_ATTRIBUTE_REPARSE_POINT == 0; + plain.then_some(directory) + } + } + + impl Drop for Directory { + fn drop(&mut self) { + unsafe { CloseHandle(self.0) }; + } + } + + pub(super) fn repair_owned_directory(directory: &Path) { + let Some(handle) = Directory::open_plain(directory) else { + return; + }; + let owned = owned_by_current_user_or_admins(&handle); + let refused = owned + && super::super::host::windows_path_grants_untrusted_write(directory).unwrap_or(false); + if !should_repair(true, owned, refused) { + return; + } + let Some(descriptor) = Descriptor::owner_only() else { + tracing::debug!("[modules] could not build an owner-only ACL for a release cache"); + return; + }; + let mut dacl = std::ptr::null_mut(); + let (mut present, mut defaulted) = (0, 0); + let have_dacl = unsafe { + GetSecurityDescriptorDacl(descriptor.0, &mut present, &mut dacl, &mut defaulted) + }; + if have_dacl == 0 || present == 0 || dacl.is_null() { + return; + } + let status = unsafe { + SetSecurityInfo( + handle.0, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + dacl, + std::ptr::null_mut(), + ) + }; + if status == 0 { + tracing::info!( + "[modules] replaced the ACL of a release cache directory with an owner-only one" + ); + } else { + tracing::debug!("[modules] could not repair a release cache ACL (error {status})"); + } + } + + /// BUILTIN\Administrators. An elevated administrator's token makes the + /// group, not the user, the owner of everything it creates, so a cache that + /// user made is owned by this SID. + const ADMINISTRATORS_SID: &str = "S-1-5-32-544"; + + /// Whether the directory's owner is the current user, or the Administrators + /// group (the default owner of what an elevated administrator creates). + /// Rewriting the DACL still needs WRITE_DAC, so another account's directory + /// stays untouched: the call fails and the gate's verdict stands. + fn owned_by_current_user_or_admins(directory: &Directory) -> bool { + let Some((_buffer, user)) = current_user() else { + return false; + }; + let mut owner = std::ptr::null_mut(); + let mut descriptor = std::ptr::null_mut(); + let status = unsafe { + GetSecurityInfo( + directory.0, + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + &mut owner, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut descriptor, + ) + }; + if status != 0 || descriptor.is_null() { + return false; + } + let same = !owner.is_null() + && (unsafe { EqualSid(owner, user) } != 0 + || sid_string(owner).as_deref() == Some(ADMINISTRATORS_SID)); + unsafe { LocalFree(descriptor) }; + same + } +} + +#[cfg(test)] +#[path = "windows_acl_tests.rs"] +mod tests; diff --git a/crates/tinybus/src/module/windows_acl_tests.rs b/crates/tinybus/src/module/windows_acl_tests.rs new file mode 100644 index 0000000..7720f97 --- /dev/null +++ b/crates/tinybus/src/module/windows_acl_tests.rs @@ -0,0 +1,273 @@ +use super::*; +use std::path::PathBuf; + +const FILE_ALL_ACCESS: u32 = 0x1F_01FF; +const READ_ONLY: u32 = 0x12_00A9; +const MODIFY: u32 = 0x13_01BF; +const ACCESS_DENIED_ACE_TYPE: u8 = 1; +const OBJECT_INHERIT_AND_CONTAINER_INHERIT: u8 = 0x03; + +#[test] +fn an_untrusted_modify_grant_on_the_directory_is_refused() { + // The report: Authenticated Users holding Modify on the cache directory. + assert!(ace_grants_untrusted_write(0, 0, MODIFY, false)); + assert!(ace_grants_untrusted_write( + 0, + OBJECT_INHERIT_AND_CONTAINER_INHERIT, + FILE_ALL_ACCESS, + false + )); +} + +#[test] +fn every_single_write_right_counts() { + for bit in [ + 0x2u32, + 0x4, + 0x10, + 0x40, + 0x100, + 0x1_0000, + 0x4_0000, + 0x8_0000, + 0x1000_0000, + 0x4000_0000, + ] { + assert!(ace_grants_untrusted_write(0, 0, bit, false), "{bit:#x}"); + } +} + +#[test] +fn a_trusted_principal_may_write() { + assert!(!ace_grants_untrusted_write(0, 0, FILE_ALL_ACCESS, true)); +} + +#[test] +fn read_only_grants_to_anyone_are_accepted() { + assert!(!ace_grants_untrusted_write(0, 0, READ_ONLY, false)); +} + +#[test] +fn an_inherit_only_grant_does_not_apply_to_the_directory() { + assert!(!ace_grants_untrusted_write(0, 0x08 | 0x03, MODIFY, false)); +} + +#[test] +fn deny_entries_never_count_as_a_grant() { + assert!(!ace_grants_untrusted_write( + ACCESS_DENIED_ACE_TYPE, + 0, + MODIFY, + false + )); +} + +#[test] +fn only_an_owned_real_directory_the_gate_refuses_is_repaired() { + assert!(should_repair(true, true, true)); + assert!(!should_repair(false, true, true), "a link or file"); + assert!(!should_repair(true, false, true), "another account's"); + assert!(!should_repair(true, true, false), "already acceptable"); +} + +#[test] +fn the_sddl_is_protected_and_names_only_the_owner_and_system() { + let sid = "S-1-5-21-1004336348-1177238915-682003330-1000"; + let sddl = owner_only_sddl(sid).unwrap(); + assert_eq!(sddl, format!("D:P(A;OICI;FA;;;{sid})(A;OICI;FA;;;SY)")); + assert!( + sddl.starts_with("D:P("), + "inheritance from the parent is cut" + ); + assert_eq!(sddl.matches("(A;").count(), 2); +} + +#[test] +fn a_malformed_sid_never_reaches_the_sddl() { + for bad in [ + "", + "S-1-", + "S-1-5-21-", + "S-1-5-x", + "S-1-5-1)(A;OICI;FA;;;WD", + "S-1-5-1 ", + "X-1-5-1", + "S-1--5", + ] { + assert_eq!(owner_only_sddl(bad), None, "{bad:?}"); + } + assert_eq!( + owner_only_sddl(&format!("S-1-5-{}", "1-".repeat(100))), + None + ); +} + +#[test] +fn the_repair_covers_the_cache_tree_but_not_its_container() { + let base = PathBuf::from("users/ana/appdata/local"); + let root = base.join("openhuman").join("modules"); + let cache = root.join("tinydocs").join("0.1.15").join("windows"); + assert!(in_repair_scope(&cache, &root, Some(&base))); + assert!(in_repair_scope(&root, &root, Some(&base))); + // `%LOCALAPPDATA%\openhuman`, strictly inside the base, is ours to fix. + assert!(in_repair_scope(&base.join("openhuman"), &root, Some(&base))); + // The base itself and everything above it are never rewritten. + assert!(!in_repair_scope(&base, &root, Some(&base))); + assert!(!in_repair_scope(base.parent().unwrap(), &root, Some(&base))); +} + +#[test] +fn a_parent_component_never_counts_as_in_scope() { + let base = PathBuf::from("users/ana/appdata/local"); + let root = base.join("openhuman"); + let escaping = root + .join("cache") + .join("..") + .join("..") + .join("..") + .join("outside"); + assert!(!in_repair_scope(&escaping, &root, Some(&base))); + assert!(!in_repair_scope(&root.join("..").join("x"), &root, None)); + // A root that itself contains `..` is not trusted either. + let odd_root = base.join("x").join("..").join("openhuman"); + assert!(!in_repair_scope( + &root.join("modules"), + &odd_root, + Some(&base) + )); + let odd_base = base.join("..").join("local"); + assert!(!in_repair_scope( + &root.join("modules"), + &root, + Some(&odd_base) + )); +} + +#[test] +fn scope_comparison_ignores_case_and_requires_a_real_prefix() { + let base = PathBuf::from("/users/ana/appdata/local"); + let root = base.join("openhuman"); + assert!(in_repair_scope( + &PathBuf::from("/Users/ANA/AppData/Local/OpenHuman/modules"), + &root, + Some(&base) + )); + assert!(!in_repair_scope( + &PathBuf::from("/users/ana/appdata/local2/openhuman"), + &root, + Some(&base) + )); + // Without a base, only the install root's own tree qualifies. + assert!(!in_repair_scope(&base.join("other"), &root, None)); + assert!(in_repair_scope(&root.join("modules"), &root, None)); +} + +#[cfg(windows)] +fn icacls_grant(path: &Path, grant: &str) { + let output = std::process::Command::new("icacls") + .arg(path) + .args(["/grant", grant]) + .output() + .expect("icacls is installed on Windows"); + assert!(output.status.success(), "icacls failed: {output:?}"); +} + +#[cfg(windows)] +fn icacls_show(path: &Path) -> String { + let output = std::process::Command::new("icacls") + .arg(path) + .output() + .expect("icacls is installed on Windows"); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +#[cfg(windows)] +use super::super::host::windows_path_grants_untrusted_write as refused; + +#[cfg(windows)] +#[test] +fn a_directory_created_private_is_accepted_and_not_open_to_others() { + let root = tempfile::tempdir().unwrap(); + let nested = root.path().join("a").join("b").join("c"); + create_private_dir_all(&nested).unwrap(); + assert!(nested.is_dir()); + assert!(!refused(&nested).unwrap()); + // Idempotent on an existing tree. + create_private_dir_all(&nested).unwrap(); +} + +#[cfg(windows)] +#[test] +fn a_cache_inheriting_a_group_write_grant_is_repaired() { + let install = tempfile::tempdir().unwrap(); + icacls_grant(install.path(), "*S-1-5-11:(OI)(CI)(M)"); // Authenticated Users + let cache = install + .path() + .join("tinydocs") + .join("0.1.15") + .join("windows"); + std::fs::create_dir_all(&cache).unwrap(); + assert!(refused(&cache).unwrap(), "the inherited grant is refused"); + + secure_release_cache(install.path(), &cache); + + assert!( + !refused(&cache).unwrap(), + "the repaired cache is accepted; its ACL is now: {}", + icacls_show(&cache) + ); +} + +#[cfg(windows)] +#[test] +fn a_junction_in_the_cache_path_stops_the_repair() { + let install = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + icacls_grant(outside.path(), "*S-1-5-11:(OI)(CI)(M)"); + let junction = install.path().join("linked"); + let made = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&junction) + .arg(outside.path()) + .output() + .expect("cmd is available on Windows"); + assert!(made.status.success(), "mklink failed: {made:?}"); + let through = junction.join("cache"); + std::fs::create_dir_all(&through).unwrap(); + assert!(refused(&through).unwrap(), "the target inherits the grant"); + + secure_release_cache(install.path(), &through); + + assert!( + refused(&through).unwrap(), + "nothing behind a junction is rewritten" + ); + assert!(refused(outside.path()).unwrap()); + assert!( + create_private_dir_all(&junction).is_err(), + "a link is not a cache directory" + ); +} + +#[cfg(windows)] +#[test] +fn a_directory_outside_the_install_root_is_left_alone() { + let install = tempfile::tempdir().unwrap(); + let other = tempfile::tempdir().unwrap(); + icacls_grant(other.path(), "*S-1-1-0:(OI)(CI)(M)"); + secure_release_cache(install.path(), other.path()); + assert!(refused(other.path()).unwrap(), "the gate still decides it"); +} + +#[cfg(not(windows))] +#[test] +fn off_windows_private_creation_is_plain_create_dir_all_and_repair_is_a_no_op() { + let root = tempfile::tempdir().unwrap(); + let nested = root.path().join("a").join("b"); + create_private_dir_all(&nested).unwrap(); + assert!(nested.is_dir()); + // Idempotent on an existing tree. + create_private_dir_all(&nested).unwrap(); + secure_release_cache(root.path(), &nested); + assert!(nested.is_dir()); +}