From 3ec188963c98d85b92e32a745d33507db4559481 Mon Sep 17 00:00:00 2001 From: Nikola Lukovic Date: Wed, 19 Aug 2026 17:10:31 +0200 Subject: [PATCH] test: implement -ef, -t, -h/-L, -r, -w and -x on Windows --- src/uu/test/Cargo.toml | 7 +- src/uu/test/src/platform/mod.rs | 4 +- src/uu/test/src/platform/windows.rs | 403 ++++++++++++++++++++++------ src/uu/test/src/test.rs | 48 ++-- tests/by-util/test_test.rs | 119 +++++++- 5 files changed, 462 insertions(+), 119 deletions(-) diff --git a/src/uu/test/Cargo.toml b/src/uu/test/Cargo.toml index 946c599baf4..0981835bbe2 100644 --- a/src/uu/test/Cargo.toml +++ b/src/uu/test/Cargo.toml @@ -18,15 +18,18 @@ doctest = false [dependencies] clap = { workspace = true } fluent = { workspace = true } -libc = { workspace = true } thiserror = { workspace = true } -uucore = { workspace = true, features = ["process", "wide"] } +uucore = { workspace = true, features = ["fs", "process", "wide"] } + +[target.'cfg(not(windows))'.dependencies] +libc = { workspace = true } [target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = [ "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", + "Win32_Storage_FileSystem", "Win32_System_Threading", ] } diff --git a/src/uu/test/src/platform/mod.rs b/src/uu/test/src/platform/mod.rs index 363d10d6977..c69b72b3f67 100644 --- a/src/uu/test/src/platform/mod.rs +++ b/src/uu/test/src/platform/mod.rs @@ -6,7 +6,9 @@ #[cfg(target_os = "wasi")] pub use self::wasi::{path, same_file}; #[cfg(windows)] -pub use self::windows::owned_by_current_token; +pub use self::windows::{ + fd_is_terminal, is_executable, is_readable, is_writable, owned_by_current_token, same_file, +}; #[cfg(target_os = "wasi")] mod wasi; diff --git a/src/uu/test/src/platform/windows.rs b/src/uu/test/src/platform/windows.rs index a720b82544a..142e38c8370 100644 --- a/src/uu/test/src/platform/windows.rs +++ b/src/uu/test/src/platform/windows.rs @@ -3,37 +3,230 @@ // 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 +// spell-checker:ignore (vars) DACL 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 std::fs::{Metadata, OpenOptions}; +use std::io::{self, IsTerminal}; +use std::os::windows::fs::OpenOptionsExt; +use std::os::windows::io::{AsHandle, OwnedHandle}; +use std::path::Path; +use uucore::fs::{FileInformation, infos_refer_to_same_file}; +use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED; use windows_sys::Win32::Security::{ - EqualSid, GROUP_SECURITY_INFORMATION, GetTokenInformation, OWNER_SECURITY_INFORMATION, - PSECURITY_DESCRIPTOR, PSID, TOKEN_QUERY, TokenOwner, TokenPrimaryGroup, + DACL_SECURITY_INFORMATION, GENERIC_MAPPING, GROUP_SECURITY_INFORMATION, + OWNER_SECURITY_INFORMATION, SecurityImpersonation, TOKEN_DUPLICATE, TOKEN_QUERY, }; -use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; +use windows_sys::Win32::Storage::FileSystem::{ + FILE_ALL_ACCESS, FILE_FLAG_BACKUP_SEMANTICS, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, + FILE_GENERIC_WRITE, +}; + +/// Confines the unsafe security calls: results come back as [`io::Error`] and +/// SIDs only as [`Sid`]s borrowed from the buffer that owns them. +mod sys { + use std::ffi::OsStr; + use std::io; + use std::marker::PhantomData; + use std::os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle}; + use std::ptr; + use uucore::wide::ToWide; + use windows_sys::Win32::Foundation::{ERROR_SUCCESS, HANDLE, LocalFree}; + use windows_sys::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{ + AccessCheck, DuplicateToken, EqualSid, GENERIC_MAPPING, GROUP_SECURITY_INFORMATION, + GetTokenInformation, MapGenericMask, OBJECT_SECURITY_INFORMATION, + OWNER_SECURITY_INFORMATION, PRIVILEGE_SET, PSECURITY_DESCRIPTOR, PSID, + SECURITY_IMPERSONATION_LEVEL, TOKEN_ACCESS_MASK, 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); + /// A SID borrowed from the structure that owns its memory. + #[derive(Clone, Copy)] + pub struct Sid<'a>(PSID, PhantomData<&'a ()>); -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) }; + impl PartialEq for Sid<'_> { + fn eq(&self, other: &Self) -> bool { + // SAFETY: each SID is valid for as long as its owner lives, which + // the borrows guarantee. + unsafe { EqualSid(self.0, other.0) != 0 } + } + } + + /// A security descriptor allocated by the security API, freed on drop, + /// along with the owner and group SIDs that point into it. + pub struct SecurityDescriptor { + ptr: PSECURITY_DESCRIPTOR, + owner: PSID, + group: PSID, + } + + impl Drop for SecurityDescriptor { + fn drop(&mut self) { + // SAFETY: the pointer comes from GetNamedSecurityInfoW; freeing a + // null pointer is a no-op. + unsafe { LocalFree(self.ptr) }; + } } -} -/// A handle on the token of the current process, closed on drop. -struct Token(HANDLE); + impl SecurityDescriptor { + pub fn owner(&self) -> Option> { + (!self.owner.is_null()).then_some(Sid(self.owner, PhantomData)) + } + + pub fn group(&self) -> Option> { + (!self.group.is_null()).then_some(Sid(self.group, PhantomData)) + } + } + + pub fn named_security_info( + path: &OsStr, + info: OBJECT_SECURITY_INFORMATION, + ) -> io::Result { + let wide_path = path.to_wide_null(); + let mut descriptor = SecurityDescriptor { + ptr: ptr::null_mut(), + owner: ptr::null_mut(), + group: ptr::null_mut(), + }; + let owner = if info & OWNER_SECURITY_INFORMATION == 0 { + ptr::null_mut() + } else { + &raw mut descriptor.owner + }; + let group = if info & GROUP_SECURITY_INFORMATION == 0 { + ptr::null_mut() + } else { + &raw mut descriptor.group + }; + // SAFETY: `wide_path` is NUL-terminated and outlives the call, and each + // out pointer is either valid or null. + let status = unsafe { + GetNamedSecurityInfoW( + wide_path.as_ptr(), + SE_FILE_OBJECT, + info, + owner, + group, + ptr::null_mut(), + ptr::null_mut(), + &raw mut descriptor.ptr, + ) + }; + if status == ERROR_SUCCESS { + Ok(descriptor) + } else { + Err(io::Error::from_raw_os_error(status as i32)) + } + } + + pub fn open_process_token(access: TOKEN_ACCESS_MASK) -> io::Result { + 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(), access, &raw mut handle) } == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: OpenProcessToken succeeded, so `handle` is a fresh owned handle. + Ok(unsafe { OwnedHandle::from_raw_handle(handle) }) + } + + pub fn duplicate_token( + token: BorrowedHandle, + level: SECURITY_IMPERSONATION_LEVEL, + ) -> io::Result { + let mut handle: HANDLE = ptr::null_mut(); + // SAFETY: `token` is a valid handle and `handle` a valid out pointer. + if unsafe { DuplicateToken(token.as_raw_handle(), level, &raw mut handle) } == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: DuplicateToken succeeded, so `handle` is a fresh owned handle. + Ok(unsafe { OwnedHandle::from_raw_handle(handle) }) + } -impl Drop for Token { - fn drop(&mut self) { - // SAFETY: the handle comes from a successful OpenProcessToken. - unsafe { CloseHandle(self.0) }; + #[derive(Clone, Copy)] + pub enum TokenSidClass { + Owner, + PrimaryGroup, + } + + /// A SID of a token, kept in the buffer it points into. + pub struct TokenSid(Vec); + + impl TokenSid { + pub fn sid(&self) -> Sid<'_> { + // SAFETY: GetTokenInformation filled the buffer with a structure + // whose first field is the SID pointer. + Sid(unsafe { *self.0.as_ptr().cast::() }, PhantomData) + } + } + + pub fn token_sid(token: BorrowedHandle, class: TokenSidClass) -> io::Result { + let class = match class { + TokenSidClass::Owner => TokenOwner, + TokenSidClass::PrimaryGroup => TokenPrimaryGroup, + }; + let token = token.as_raw_handle(); + + let mut size = 0; + // SAFETY: a null buffer of length zero only asks for the size to + // allocate. + unsafe { GetTokenInformation(token, class, ptr::null_mut(), 0, &raw mut size) }; + if size == 0 { + return Err(io::Error::last_os_error()); + } + + // TOKEN_OWNER and TOKEN_PRIMARY_GROUP are 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, + class, + buffer.as_mut_ptr().cast(), + size, + &raw mut size, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(TokenSid(buffer)) + } + + pub fn access_check( + descriptor: &SecurityDescriptor, + token: BorrowedHandle, + mut desired: u32, + mapping: &GENERIC_MAPPING, + ) -> io::Result { + // SAFETY: both pointers refer to live locals. + unsafe { MapGenericMask(&raw mut desired, mapping) }; + + // One entry is enough: no file right is ever granted by a privilege. + let mut privileges = PRIVILEGE_SET::default(); + let mut privileges_len = size_of::() as u32; + let mut granted = 0; + let mut status = 0; + // SAFETY: the descriptor and token are valid, every out pointer refers + // to a live local, and `privileges_len` is the size of `privileges`. + let ok = unsafe { + AccessCheck( + descriptor.ptr, + token.as_raw_handle(), + desired, + mapping, + &raw mut privileges, + &raw mut privileges_len, + &raw mut granted, + &raw mut status, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(status != 0) } } @@ -47,78 +240,120 @@ impl Drop for Token { /// 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, - ) +fn matches_token_sid(sid: sys::Sid<'_>, group: bool) -> bool { + let class = if group { + sys::TokenSidClass::PrimaryGroup + } else { + sys::TokenSidClass::Owner }; - 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 + sys::open_process_token(TOKEN_QUERY) + .and_then(|token| sys::token_sid(token.as_handle(), class)) + .is_ok_and(|token_sid| token_sid.sid() == sid) } /// 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) + let info = if group { + GROUP_SECURITY_INFORMATION } else { - (OWNER_SECURITY_INFORMATION, &raw mut sid, ptr::null_mut()) + OWNER_SECURITY_INFORMATION + }; + let Ok(descriptor) = sys::named_security_info(path, info) else { + return false; }; + let sid = if group { + descriptor.group() + } else { + descriptor.owner() + }; + sid.is_some_and(|sid| matches_token_sid(sid, group)) +} - // 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, - ) +/// AccessCheck only evaluates impersonation tokens. +fn impersonation_token() -> io::Result { + let primary = sys::open_process_token(TOKEN_QUERY | TOKEN_DUPLICATE)?; + sys::duplicate_token(primary.as_handle(), SecurityImpersonation) +} + +/// `None` on volumes without ACLs (FAT, exFAT, many network shares), where the +/// question cannot be answered and the caller keeps the permissive answer. +fn access_check(path: &OsStr, access: u32) -> Option { + // AccessCheck needs the owner and group SIDs next to the DACL. + let descriptor = match sys::named_security_info( + path, + OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + ) { + Ok(descriptor) => descriptor, + // Every file right includes READ_CONTROL, so a denied descriptor means + // the file cannot be opened. + Err(e) if e.raw_os_error() == Some(ERROR_ACCESS_DENIED as i32) => return Some(false), + Err(_) => return None, + }; + let token = impersonation_token().ok()?; + let mapping = GENERIC_MAPPING { + GenericRead: FILE_GENERIC_READ, + GenericWrite: FILE_GENERIC_WRITE, + GenericExecute: FILE_GENERIC_EXECUTE, + GenericAll: FILE_ALL_ACCESS, }; + sys::access_check(&descriptor, token.as_handle(), access, &mapping).ok() +} + +fn has_access(path: &OsStr, access: u32) -> bool { + access_check(path, access).unwrap_or(true) +} + +pub fn is_readable(path: &OsStr) -> bool { + has_access(path, FILE_GENERIC_READ) +} + +/// The read-only attribute blocks writes whatever the DACL says, but NTFS +/// ignores it on directories. +pub fn is_writable(path: &OsStr, metadata: &Metadata) -> bool { + (metadata.is_dir() || !metadata.permissions().readonly()) + && has_access(path, FILE_GENERIC_WRITE) +} + +fn has_executable_extension(path: &OsStr) -> bool { + Path::new(path) + .extension() + .and_then(OsStr::to_str) + .is_some_and(|extension| { + ["exe", "bat", "cmd", "com"] + .iter() + .any(|known| extension.eq_ignore_ascii_case(known)) + }) +} + +/// For a directory this is permission to enter it: FILE_EXECUTE doubles as +/// FILE_TRAVERSE. +pub fn is_executable(path: &OsStr, metadata: &Metadata) -> bool { + (metadata.is_dir() || has_executable_extension(path)) && has_access(path, FILE_GENERIC_EXECUTE) +} + +/// Only the standard streams can be answered for: asking the CRT about a +/// descriptor it never handed out aborts the process. +pub fn fd_is_terminal(fd: i32) -> bool { + match fd { + 0 => io::stdin().is_terminal(), + 1 => io::stdout().is_terminal(), + 2 => io::stderr().is_terminal(), + _ => false, + } +} + +pub fn same_file(a: &OsStr, b: &OsStr) -> bool { + // Asking for no access right leaves nothing a share mode or DACL could + // refuse; BACKUP_SEMANTICS lets a directory be opened. + fn information(path: &OsStr) -> io::Result { + let file = OpenOptions::new() + .access_mode(0) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(path)?; + FileInformation::from_file(&file) + } - status == ERROR_SUCCESS && !sid.is_null() && matches_token_sid(sid, group) + infos_refer_to_same_file(information(a), information(b)) } diff --git a/src/uu/test/src/test.rs b/src/uu/test/src/test.rs index 965ce713c16..f1963e69bfa 100644 --- a/src/uu/test/src/test.rs +++ b/src/uu/test/src/test.rs @@ -14,6 +14,8 @@ mod platform; use clap::Command; use error::{ParseError, ParseErrorKind, ParseResult}; use parser::{Operator, Symbol, UnaryOperator, parse}; +#[cfg(windows)] +use platform::fd_is_terminal; #[cfg(target_os = "wasi")] use platform::path; use std::cmp::Ordering; @@ -306,10 +308,8 @@ fn files(a: &OsStr, b: &OsStr, op: &OsStr) -> ParseResult { let result = match (op.to_str(), f_a, f_b) { #[cfg(unix)] (Some("-ef"), Ok(f_a), Ok(f_b)) => f_a.ino() == f_b.ino() && f_a.dev() == f_b.dev(), - #[cfg(target_os = "wasi")] + #[cfg(any(windows, target_os = "wasi"))] (Some("-ef"), Ok(_), Ok(_)) => platform::same_file(a, b), - #[cfg(not(any(unix, target_os = "wasi")))] - (Some("-ef"), Ok(_), Ok(_)) => unimplemented!(), (Some("-nt"), Ok(f_a), Ok(f_b)) => f_a.modified().unwrap() > f_b.modified().unwrap(), (Some("-nt"), Ok(_), _) => true, (Some("-ot"), Ok(f_a), Ok(f_b)) => f_a.modified().unwrap() < f_b.modified().unwrap(), @@ -329,14 +329,20 @@ fn files(a: &OsStr, b: &OsStr, op: &OsStr) -> ParseResult { fn isatty(fd: &OsStr) -> ParseResult { fd.to_str() .map(str::trim) - .and_then(|s| s.parse().ok()) + .and_then(|s| s.parse::().ok()) .ok_or_else(|| { ParseError::at_value( ParseErrorKind::InvalidFileDescriptor(fd.quote().to_string()), fd, ) }) - .map(|i| unsafe { libc::isatty(i) == 1 }) + .map(fd_is_terminal) +} + +#[cfg(not(windows))] +fn fd_is_terminal(fd: i32) -> bool { + // SAFETY: isatty only inspects the descriptor number it is given. + unsafe { libc::isatty(fd) == 1 } } #[derive(Eq, PartialEq)] @@ -432,33 +438,36 @@ 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; + use crate::platform::{is_executable, is_readable, is_writable, owned_by_current_token}; - let Ok(stat) = metadata(path) else { + let metadata = if condition == &PathCondition::SymLink { + fs::symlink_metadata(path) + } else { + fs::metadata(path) + }; + + let Ok(metadata) = metadata else { return false; }; match condition { - PathCondition::Directory => stat.is_dir(), - PathCondition::Exists | PathCondition::Readable => true, - PathCondition::ExistsModifiedLastRead => modified_since_read(&stat), + PathCondition::Directory => metadata.is_dir(), + PathCondition::Exists => true, + PathCondition::ExistsModifiedLastRead => modified_since_read(&metadata), 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(), - PathCondition::Executable => std::path::Path::new(path) - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| matches!(e, "exe" | "bat" | "cmd" | "com")), + PathCondition::Regular => metadata.is_file(), + PathCondition::SymLink => metadata.file_type().is_symlink(), + PathCondition::NonEmpty => metadata.len() > 0, + PathCondition::Readable => is_readable(path), + PathCondition::Writable => is_writable(path, &metadata), + PathCondition::Executable => is_executable(path, &metadata), PathCondition::BlockSpecial | PathCondition::CharacterSpecial | PathCondition::Fifo | PathCondition::GroupIdFlag | PathCondition::Socket | PathCondition::Sticky - | PathCondition::SymLink | PathCondition::UserIdFlag => false, } } @@ -483,7 +492,6 @@ mod tests { } #[test] - #[cfg(unix)] fn test_files_with_ef_op() { let a = NamedTempFile::new().unwrap(); let b = NamedTempFile::new().unwrap(); diff --git a/tests/by-util/test_test.rs b/tests/by-util/test_test.rs index 9af9dd1f3db..aca5a5a07e2 100644 --- a/tests/by-util/test_test.rs +++ b/tests/by-util/test_test.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (words) egid euid pseudofloat +// spell-checker:ignore (words) egid euid icacls pseudofloat use uutests::util::TestScenario; use uutests::{at_and_ucmd, new_ucmd, util_name}; @@ -442,13 +442,34 @@ fn test_isatty_whitespace_stripping() { } #[test] -#[cfg(unix)] +fn test_isatty_invalid_fd_is_false() { + // Asking the CRT about an unopened descriptor aborted the process. + new_ucmd!().args(&["-t", "99"]).fails_with_code(1); + new_ucmd!().args(&["-t", "-1"]).fails_with_code(1); +} + +#[test] +#[cfg(windows)] +fn test_isatty_unknown_fd_windows() { + // Only the standard streams are known on Windows. + new_ucmd!().args(&["-t", "3"]).fails_with_code(1); +} + +#[test] fn test_file_is_itself() { new_ucmd!() .args(&["regular_file", "-ef", "regular_file"]) .succeeds(); } +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] +fn test_hard_link_is_same_file() { + let (at, mut ucmd) = at_and_ucmd!(); + at.hard_link("regular_file", "hard_link"); + ucmd.args(&["regular_file", "-ef", "hard_link"]).succeeds(); +} + #[test] #[cfg(not(target_os = "android"))] fn test_file_is_newer_than_and_older_than_itself() { @@ -485,15 +506,12 @@ fn test_file_is_newer_than_non_existing_file() { } #[test] -#[cfg(unix)] #[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_same_device_inode() { let scenario = TestScenario::new(util_name!()); let at = &scenario.fixtures; - scenario.cmd("touch").arg("regular_file").succeeds(); - scenario.cmd("touch").arg("regular_file_second").succeeds(); - + at.touch("regular_file_second"); at.symlink_file("regular_file", "symlink"); scenario @@ -567,7 +585,7 @@ fn test_file_is_readable() { } #[test] -#[cfg(not(windows))] // FIXME: implement on Windows +#[cfg(not(windows))] #[cfg_attr(wasi_runner, ignore = "WASI: no permission bits")] fn test_file_is_not_readable() { let scenario = TestScenario::new(util_name!()); @@ -587,7 +605,7 @@ fn test_file_is_writable() { } #[test] -#[cfg(not(windows))] // FIXME: implement on Windows +#[cfg(not(windows))] fn test_file_is_not_writable() { let scenario = TestScenario::new(util_name!()); let mut ucmd = scenario.ucmd(); @@ -649,12 +667,91 @@ fn test_file_is_not_writable_windows() { ucmd.args(&["!", "-w", "readonly_file"]).succeeds(); } +#[test] +#[cfg(windows)] +fn test_readonly_directory_is_writable_windows() { + // NTFS ignores the read-only attribute on directories. + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("readonly_dir"); + at.set_readonly("readonly_dir"); + ucmd.args(&["-w", "readonly_dir"]).succeeds(); +} + #[test] #[cfg(windows)] fn test_file_is_executable_windows() { let (at, mut ucmd) = at_and_ucmd!(); - at.touch("program.exe"); - ucmd.args(&["-x", "program.exe"]).succeeds(); + at.touch("PROGRAM.EXE"); + ucmd.args(&["-x", "PROGRAM.EXE"]).succeeds(); +} + +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI: no permission bits")] +fn test_directory_is_executable() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("dir"); + ucmd.args(&["-x", "dir"]).succeeds(); +} + +// Denies a single right so cleanup still works: it needs DELETE and directory +// listing. +#[cfg(windows)] +fn deny_everyone(scenario: &TestScenario, name: &str, rights: &str) { + let ace = format!("*S-1-1-0:({rights})"); + scenario + .cmd("icacls") + .args(&[name, "/deny", ace.as_str()]) + .succeeds(); +} + +#[test] +#[cfg(windows)] +fn test_file_is_not_readable_windows() { + let scenario = TestScenario::new(util_name!()); + scenario.fixtures.touch("crypto_file"); + deny_everyone(&scenario, "crypto_file", "RD"); + + scenario.ucmd().args(&["!", "-r", "crypto_file"]).succeeds(); +} + +#[test] +#[cfg(windows)] +fn test_file_is_not_writable_by_acl_windows() { + let scenario = TestScenario::new(util_name!()); + let at = &scenario.fixtures; + at.touch("immutable_file"); + at.mkdir("immutable_dir"); + deny_everyone(&scenario, "immutable_file", "WD"); + deny_everyone(&scenario, "immutable_dir", "WD"); + + scenario + .ucmd() + .args(&["!", "-w", "immutable_file"]) + .succeeds(); + scenario + .ucmd() + .args(&["!", "-w", "immutable_dir"]) + .succeeds(); +} + +#[test] +#[cfg(windows)] +fn test_file_is_not_executable_by_acl_windows() { + let scenario = TestScenario::new(util_name!()); + scenario.fixtures.touch("program.exe"); + deny_everyone(&scenario, "program.exe", "X"); + + scenario.ucmd().args(&["!", "-x", "program.exe"]).succeeds(); +} + +#[test] +#[cfg(windows)] +fn test_directory_is_not_executable_by_acl_windows() { + let scenario = TestScenario::new(util_name!()); + scenario.fixtures.mkdir("locked_dir"); + deny_everyone(&scenario, "locked_dir", "X"); + + scenario.ucmd().args(&["!", "-x", "locked_dir"]).succeeds(); } #[test] @@ -675,14 +772,12 @@ fn test_not_is_not_empty() { } #[test] -#[cfg(not(windows))] fn test_symlink_is_symlink() { let scenario = TestScenario::new(util_name!()); let at = &scenario.fixtures; at.symlink_file("regular_file", "symlink"); - // FIXME: implement on Windows scenario.ucmd().args(&["-h", "symlink"]).succeeds(); scenario.ucmd().args(&["-L", "symlink"]).succeeds(); }