diff --git a/Cargo.lock b/Cargo.lock index 6b7171ecbf2..45b88919d99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4418,6 +4418,7 @@ dependencies = [ "blake2b_simd", "blake3", "bstr", + "cfg_aliases", "clap", "crc-fast", "data-encoding", diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 8ec03377f8d..5c40433753c 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1833,6 +1833,7 @@ dependencies = [ "bigdecimal", "blake2b_simd", "blake3", + "cfg_aliases", "clap", "crc-fast", "data-encoding", diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index e5a178879ac..eb7ce9d5fa0 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -84,6 +84,9 @@ fluent-syntax = { workspace = true } unic-langid = { workspace = true } thiserror = { workspace = true } +[build-dependencies] +cfg_aliases = { workspace = true } + [dev-dependencies] tempfile = { workspace = true } diff --git a/src/uucore/build.rs b/src/uucore/build.rs index a9a7d09e6b6..c9f37dc8378 100644 --- a/src/uucore/build.rs +++ b/src/uucore/build.rs @@ -3,12 +3,32 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore tvos watchos visionos + +use cfg_aliases::cfg_aliases; use std::env; use std::fs::File; use std::io::Write; use std::path::{Path, PathBuf}; pub fn main() -> Result<(), Box> { + cfg_aliases! { + // Directory open flags that grant search but not read access. + // tests/by-util/test_install.rs repeats the union of these targets. + has_o_path: { any(target_os = "linux", target_os = "android") }, + has_o_search: { any( + target_os = "macos", + target_os = "ios", + target_os = "tvos", + target_os = "watchos", + target_os = "visionos", + target_os = "freebsd", + target_os = "netbsd", + target_os = "illumos", + target_os = "solaris" + ) }, + } + let out_dir = env::var("OUT_DIR")?; let mut embedded_file = File::create(Path::new(&out_dir).join("embedded_locales.rs"))?; diff --git a/src/uucore/src/lib/features/safe_traversal.rs b/src/uucore/src/lib/features/safe_traversal.rs index b8bd8183fb4..2ebc460f1d6 100644 --- a/src/uucore/src/lib/features/safe_traversal.rs +++ b/src/uucore/src/lib/features/safe_traversal.rs @@ -231,6 +231,23 @@ const LARGEFILE: OFlag = OFlag::O_LARGEFILE; #[cfg(not(any(target_os = "linux", target_os = "android")))] const LARGEFILE: OFlag = OFlag::empty(); +/// Flag that opens a directory as an anchor for `*at` calls without read access. +/// +/// `mkdirat` and `openat` need write and execute on the anchor directory, but +/// opening it `O_RDONLY` also demands read, which fails on write-only +/// directories where GNU succeeds. `O_PATH` (Linux) and `O_SEARCH` (POSIX +/// 2008) both yield a descriptor that anchors `*at` calls without reading. +/// Such a descriptor cannot list directory entries. +#[cfg(has_o_path)] +const SEARCH_ONLY: Option = Some(OFlag::O_PATH); +#[cfg(has_o_search)] +const SEARCH_ONLY: Option = Some(OFlag::O_SEARCH); +/// Neither flag exists here (OpenBSD, for example), so creating an entry +/// inside a write-only directory fails with `EACCES` instead of succeeding the +/// way `mkdir` does. +#[cfg(not(any(has_o_path, has_o_search)))] +const SEARCH_ONLY: Option = None; + impl DirFd { /// Open a directory and return a file descriptor /// @@ -251,6 +268,44 @@ impl DirFd { Ok(Self { fd }) } + /// Open a directory to anchor `*at` calls, following symlinks. + /// + /// Falls back to a search-only descriptor when the directory denies read + /// access, so that creating entries in a write-only directory works the + /// way it does with `mkdir`. The returned descriptor is only guaranteed to + /// support `*at` calls; it may not be able to list directory entries. + pub fn open_anchor(path: &Path) -> io::Result { + match Self::open(path, SymlinkBehavior::Follow) { + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => { + Self::open_search_only(path, e) + } + result => result, + } + } + + /// Retry a readable open of `path` that failed with `denied` using a + /// search-only descriptor. + /// + /// If this open fails as well, its own error is returned. The readable + /// open only adds a read-permission check, so a different error here + /// (`ENOENT`, `ELOOP`, `ENOTDIR`) means `path` changed between the two + /// calls and describes what is there now. Platforms without a search-only + /// flag return `denied` unchanged. + fn open_search_only(path: &Path, denied: io::Error) -> io::Result { + let Some(search_only) = SEARCH_ONLY else { + return Err(denied); + }; + + let flags = search_only | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC | LARGEFILE; + let fd = nix::fcntl::open(path, flags, Mode::empty()).map_err(|e| { + SafeTraversalError::OpenFailed { + path: path.into(), + source: io::Error::from_raw_os_error(e as i32), + } + })?; + Ok(Self { fd }) + } + /// Open a subdirectory relative to this directory /// /// # Arguments @@ -730,7 +785,7 @@ fn open_or_create_subdir(parent_fd: &DirFd, name: &OsStr, mode: u32) -> io::Resu #[cfg(unix)] pub fn create_dir_all_safe(path: &Path, mode: u32) -> io::Result { let (existing_ancestor, components_to_create) = find_existing_ancestor(path)?; - let mut dir_fd = DirFd::open(&existing_ancestor, SymlinkBehavior::Follow)?; + let mut dir_fd = DirFd::open_anchor(&existing_ancestor)?; for component in &components_to_create { dir_fd = open_or_create_subdir(&dir_fd, component.as_os_str(), mode)?; @@ -1517,6 +1572,53 @@ mod tests { assert!(nested_path.is_dir()); } + #[test] + #[cfg(any(has_o_path, has_o_search))] + fn test_open_anchor_in_write_only_dir() { + use std::os::unix::fs::PermissionsExt; + + if Uid::effective().is_root() { + // root ignores the permission bits this test depends on + return; + } + let temp_dir = TempDir::new().unwrap(); + let write_only = temp_dir.path().join("wx"); + fs::create_dir(&write_only).unwrap(); + fs::set_permissions(&write_only, fs::Permissions::from_mode(0o300)).unwrap(); + + // Creating entries needs write and execute, not read. + let dir_fd = DirFd::open_anchor(&write_only).unwrap(); + dir_fd.mkdir_at(OsStr::new("sub"), 0o755).unwrap(); + dir_fd.open_file_at(OsStr::new("file")).unwrap(); + + fs::set_permissions(&write_only, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(write_only.join("sub").is_dir()); + assert!(write_only.join("file").is_file()); + } + + #[test] + #[cfg(any(has_o_path, has_o_search))] + fn test_open_search_only_keeps_its_own_error() { + // If the path changes after the readable open was denied, report what + // the search-only open found, not the earlier EACCES. + let temp_dir = TempDir::new().unwrap(); + let missing = temp_dir.path().join("missing"); + let looping = temp_dir.path().join("loop"); + symlink(&looping, &looping).unwrap(); + let file = temp_dir.path().join("file"); + fs::write(&file, "").unwrap(); + + for (path, errno) in [ + (&missing, libc::ENOENT), + (&looping, libc::ELOOP), + (&file, libc::ENOTDIR), + ] { + let denied = io::Error::from_raw_os_error(libc::EACCES); + let err = DirFd::open_search_only(path, denied).err().unwrap(); + assert_eq!(err.raw_os_error(), Some(errno), "{}", path.display()); + } + } + #[test] fn test_create_dir_all_safe_existing_path() { let temp_dir = TempDir::new().unwrap(); diff --git a/tests/by-util/test_install.rs b/tests/by-util/test_install.rs index 3a5189d57cf..63fd335ae24 100644 --- a/tests/by-util/test_install.rs +++ b/tests/by-util/test_install.rs @@ -4,6 +4,7 @@ // file that was distributed with this source code. // spell-checker:ignore (words) helloworld nodir n'source nconfined testdir ETXTBSY Cryptfs +// spell-checker:ignore tvos watchos visionos use rustix::process::{getegid, geteuid}; use std::env::current_exe; @@ -2945,6 +2946,52 @@ fn test_install_d_dangling_symlink_in_path_errors() { ); } +#[test] +// The targets with O_PATH or O_SEARCH, as named by the `has_o_path` and +// `has_o_search` cfg aliases in src/uucore/build.rs. Elsewhere install cannot +// anchor on an unreadable directory and fails with EACCES. +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "tvos", + target_os = "watchos", + target_os = "visionos", + target_os = "freebsd", + target_os = "netbsd", + target_os = "illumos", + target_os = "solaris" +))] +fn test_install_d_leading_dirs_in_write_only_directory() { + // mkdir needs write and execute on the parent, not read, so -D must be + // able to create leading directories inside a directory it cannot read. + use std::os::unix::fs::PermissionsExt; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + if geteuid().is_root() { + println!("Test skipped; root ignores directory permissions"); + return; + } + + at.write("file.txt", "hello"); + at.mkdir("wx"); + fs::set_permissions(at.plus("wx"), fs::Permissions::from_mode(0o300)).unwrap(); + + scene + .ucmd() + .args(&["-D", "file.txt", "wx/a/b/file.txt"]) + .succeeds(); + + fs::set_permissions(at.plus("wx"), fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + fs::read_to_string(at.plus("wx/a/b/file.txt")).unwrap(), + "hello" + ); +} + #[test] #[cfg(target_os = "linux")] fn test_install_set_owner_nonexistent_uid_and_gid() {