diff --git a/Cargo.lock b/Cargo.lock index 92c5c07efa..0d47ef4ba5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4338,6 +4338,7 @@ dependencies = [ "tempfile", "thiserror 2.0.19", "uucore", + "windows-sys 0.61.2", ] [[package]] diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 3ad2a7b57f..9ee3c25da6 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -2073,6 +2073,7 @@ dependencies = [ "libc", "thiserror", "uucore", + "windows-sys", ] [[package]] diff --git a/src/uu/test/Cargo.toml b/src/uu/test/Cargo.toml index 4d9e58ba54..1e65c1ee08 100644 --- a/src/uu/test/Cargo.toml +++ b/src/uu/test/Cargo.toml @@ -23,7 +23,15 @@ clap = { workspace = true } fluent = { workspace = true } libc = { workspace = true } thiserror = { workspace = true } -uucore = { workspace = true, features = ["process"] } +uucore = { workspace = true, features = ["process", "wide"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_System_Threading", +] } [dev-dependencies] tempfile = { workspace = true } diff --git a/src/uu/test/src/platform/mod.rs b/src/uu/test/src/platform/mod.rs new file mode 100644 index 0000000000..c7d61e44ea --- /dev/null +++ b/src/uu/test/src/platform/mod.rs @@ -0,0 +1,10 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +#[cfg(windows)] +pub use self::windows::owned_by_current_token; + +#[cfg(windows)] +mod windows; diff --git a/src/uu/test/src/platform/windows.rs b/src/uu/test/src/platform/windows.rs new file mode 100644 index 0000000000..a720b82544 --- /dev/null +++ b/src/uu/test/src/platform/windows.rs @@ -0,0 +1,124 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (vars) PSECURITY PSID + +use std::ffi::OsStr; +use std::ptr; +use uucore::wide::ToWide; +use windows_sys::Win32::Foundation::{CloseHandle, ERROR_SUCCESS, HANDLE, LocalFree}; +use windows_sys::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}; +use windows_sys::Win32::Security::{ + EqualSid, GROUP_SECURITY_INFORMATION, GetTokenInformation, OWNER_SECURITY_INFORMATION, + PSECURITY_DESCRIPTOR, PSID, TOKEN_QUERY, TokenOwner, TokenPrimaryGroup, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + +/// A security descriptor allocated by the security API, freed on drop. +struct Descriptor(PSECURITY_DESCRIPTOR); + +impl Drop for Descriptor { + fn drop(&mut self) { + // SAFETY: the pointer comes from GetNamedSecurityInfoW; freeing a null + // pointer is a no-op. + unsafe { LocalFree(self.0) }; + } +} + +/// A handle on the token of the current process, closed on drop. +struct Token(HANDLE); + +impl Drop for Token { + fn drop(&mut self) { + // SAFETY: the handle comes from a successful OpenProcessToken. + unsafe { CloseHandle(self.0) }; + } +} + +/// Whether `sid` is the SID the current process token stamps onto the objects it +/// creates: its owner or, with `group`, its primary group. +/// +/// Those two are the counterparts of the effective UID and GID. They are not +/// always the token user: a process running elevated has the Administrators +/// group as its token owner, and that is what lands in the descriptor of a file +/// it creates, so comparing against the token user would report that you do not +/// own a file you just created. Group membership is not a substitute — it would +/// also match every group you happen to belong to, such as `Everyone`, and +/// report ownership of files that are not yours. +fn matches_token_sid(sid: PSID, group: bool) -> bool { + let mut handle: HANDLE = ptr::null_mut(); + // SAFETY: GetCurrentProcess returns a pseudo handle that needs no closing, + // and `handle` is a valid out pointer. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut handle) } == 0 { + return false; + } + let token = Token(handle); + let class = if group { TokenPrimaryGroup } else { TokenOwner }; + + let mut size = 0; + // SAFETY: a null buffer of length zero only asks for the size to allocate. + unsafe { GetTokenInformation(token.0, class, ptr::null_mut(), 0, &raw mut size) }; + if size == 0 { + return false; + } + + // TOKEN_OWNER and TOKEN_PRIMARY_GROUP are both a lone SID pointer followed + // by the SID it points at, so the buffer has to be pointer aligned. + let mut buffer = vec![0usize; (size as usize).div_ceil(size_of::())]; + // SAFETY: `buffer` is at least `size` bytes long, as reported above. + let ok = unsafe { + GetTokenInformation( + token.0, + class, + buffer.as_mut_ptr().cast(), + size, + &raw mut size, + ) + }; + if ok == 0 { + return false; + } + + // SAFETY: the call above wrote one of those two structures into `buffer`, + // and the SID pointer is its first field. + let token_sid = unsafe { *buffer.as_ptr().cast::() }; + + // SAFETY: `sid` is valid while its descriptor lives, `token_sid` while + // `buffer` does. + !token_sid.is_null() && unsafe { EqualSid(sid, token_sid) } != 0 +} + +/// Whether `path` is owned by the current process token, comparing its owner +/// (or, with `group`, its primary group) SID — the Windows analogue of matching +/// `st_uid`/`st_gid` against the effective UID/GID for `-O` and `-G`. +pub fn owned_by_current_token(path: &OsStr, group: bool) -> bool { + let wide_path = path.to_wide_null(); + let mut sid: PSID = ptr::null_mut(); + // Owns the memory `sid` points into, so it has to live until the comparison + // below is done. + let mut descriptor = Descriptor(ptr::null_mut()); + let (info, owner_out, group_out) = if group { + (GROUP_SECURITY_INFORMATION, ptr::null_mut(), &raw mut sid) + } else { + (OWNER_SECURITY_INFORMATION, &raw mut sid, ptr::null_mut()) + }; + + // SAFETY: `wide_path` is NUL-terminated and outlives the call, and the out + // pointers are valid. On success `sid` points into the descriptor. + let status = unsafe { + GetNamedSecurityInfoW( + wide_path.as_ptr(), + SE_FILE_OBJECT, + info, + owner_out, + group_out, + ptr::null_mut(), + ptr::null_mut(), + &raw mut descriptor.0, + ) + }; + + status == ERROR_SUCCESS && !sid.is_null() && matches_token_sid(sid, group) +} diff --git a/src/uu/test/src/test.rs b/src/uu/test/src/test.rs index 38959ec5bd..18c9109778 100644 --- a/src/uu/test/src/test.rs +++ b/src/uu/test/src/test.rs @@ -7,6 +7,8 @@ pub(crate) mod error; mod parser; +#[cfg(windows)] +mod platform; use clap::Command; use error::{ParseError, ParseResult}; @@ -253,6 +255,16 @@ enum PathCondition { Executable, } +/// Whether the file was modified more recently than it was last read, the +/// condition behind `-N`. A timestamp the platform cannot report counts as +/// "not modified since read" rather than aborting. +fn modified_since_read(metadata: &fs::Metadata) -> bool { + matches!( + (metadata.accessed(), metadata.modified()), + (Ok(read), Ok(modified)) if read < modified + ) +} + #[cfg(not(windows))] fn path(path: &OsStr, condition: &PathCondition) -> bool { use std::fs::Metadata; @@ -295,9 +307,7 @@ fn path(path: &OsStr, condition: &PathCondition) -> bool { PathCondition::CharacterSpecial => file_type.is_char_device(), PathCondition::Directory => file_type.is_dir(), PathCondition::Exists => true, - PathCondition::ExistsModifiedLastRead => { - metadata.accessed().unwrap() < metadata.modified().unwrap() - } + PathCondition::ExistsModifiedLastRead => modified_since_read(&metadata), PathCondition::Regular => file_type.is_file(), PathCondition::GroupIdFlag => metadata.mode() & S_ISGID != 0, PathCondition::GroupOwns => metadata.gid() == getegid(), @@ -316,6 +326,7 @@ fn path(path: &OsStr, condition: &PathCondition) -> bool { #[cfg(windows)] fn path(path: &OsStr, condition: &PathCondition) -> bool { + use crate::platform::owned_by_current_token; use std::fs::metadata; let Ok(stat) = metadata(path) else { @@ -325,9 +336,9 @@ fn path(path: &OsStr, condition: &PathCondition) -> bool { match condition { PathCondition::Directory => stat.is_dir(), PathCondition::Exists | PathCondition::Readable => true, - PathCondition::ExistsModifiedLastRead - | PathCondition::GroupOwns - | PathCondition::UserOwns => unimplemented!(), + PathCondition::ExistsModifiedLastRead => modified_since_read(&stat), + PathCondition::GroupOwns => owned_by_current_token(path, true), + PathCondition::UserOwns => owned_by_current_token(path, false), PathCondition::Regular => stat.is_file(), PathCondition::NonEmpty => stat.len() > 0, PathCondition::Writable => !stat.permissions().readonly(), diff --git a/tests/by-util/test_test.rs b/tests/by-util/test_test.rs index 7ed296328c..a162fab887 100644 --- a/tests/by-util/test_test.rs +++ b/tests/by-util/test_test.rs @@ -703,13 +703,11 @@ fn test_parenthesized_right_parenthesis_as_literal() { } #[test] -#[cfg(not(windows))] fn test_file_owned_by_euid() { new_ucmd!().args(&["-O", "regular_file"]).succeeds(); } #[test] -#[cfg(not(windows))] fn test_nonexistent_file_not_owned_by_euid() { new_ucmd!() .args(&["-O", "nonexistent_file"]) @@ -749,7 +747,6 @@ fn test_file_owned_by_egid() { } #[test] -#[cfg(not(windows))] fn test_nonexistent_file_not_owned_by_egid() { new_ucmd!() .args(&["-G", "nonexistent_file"]) @@ -773,6 +770,24 @@ fn test_file_not_owned_by_egid() { .succeeds(); } +#[test] +#[cfg(windows)] +fn test_file_owned_by_current_group_windows() { + new_ucmd!().args(&["-G", "regular_file"]).succeeds(); +} + +#[test] +#[cfg(windows)] +fn test_file_not_owned_by_current_token_windows() { + // The system directory belongs to TrustedInstaller, so it is owned neither + // by the user nor by the Administrators group an elevated shell runs as. + let system_root = std::env::var("SystemRoot").expect("SystemRoot is not set"); + + new_ucmd!() + .args(&["-d", &system_root, "-a", "!", "-O", &system_root]) + .succeeds(); +} + #[test] fn test_op_precedence_and_or_1() { new_ucmd!().args(&[" ", "-o", "", "-a", ""]).succeeds(); @@ -964,7 +979,6 @@ fn test_bracket_syntax_version() { #[test] #[allow(non_snake_case)] -#[cfg(unix)] fn test_file_N() { use std::{fs::FileTimes, time::Duration}; @@ -980,6 +994,7 @@ fn test_file_N() { .set_modified(std::time::UNIX_EPOCH); f.set_times(times).unwrap(); // TODO: stat call for debugging #7570, remove? + #[cfg(unix)] println!("{}", scene.cmd_shell("stat file").succeeds().stdout_str()); scene.ucmd().args(&["-N", "file"]).fails(); @@ -990,6 +1005,7 @@ fn test_file_N() { .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(123)); f.set_times(times).unwrap(); // TODO: stat call for debugging #7570, remove? + #[cfg(unix)] println!("{}", scene.cmd_shell("stat file").succeeds().stdout_str()); scene.ucmd().args(&["-N", "file"]).succeeds(); }