Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ uucore = { workspace = true, features = [
"entries",
"pipes",
"process",
"safe-traversal",
"signals",
"utmpx",
] }
Expand Down
86 changes: 83 additions & 3 deletions src/uucore/src/lib/features/safe_traversal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use std::time::SystemTime;
use nix::dir::Dir;
use nix::fcntl::{OFlag, openat};
use nix::libc;
use nix::sys::stat::{FchmodatFlags, FileStat, Mode, fchmodat, fstatat, mkdirat};
use nix::sys::stat::{FchmodatFlags, FileStat, Mode, fchmodat, fstat, fstatat, mkdirat};
use nix::unistd::{Gid, Uid, UnlinkatFlags, fchown, fchownat, unlinkat};
use os_display::Quotable;

Expand Down Expand Up @@ -146,6 +146,43 @@ 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(any(target_os = "linux", target_os = "android"))]
const SEARCH_ONLY: Option<OFlag> = Some(OFlag::O_PATH);
#[cfg(any(
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "netbsd",
target_os = "illumos",
target_os = "solaris"
))]
const SEARCH_ONLY: Option<OFlag> = Some(OFlag::O_SEARCH);
Comment thread
abendrothj marked this conversation as resolved.
#[cfg(not(any(
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "netbsd",
target_os = "illumos",
target_os = "solaris"
)))]
const SEARCH_ONLY: Option<OFlag> = None;

/// Whether this platform can anchor `*at` calls on a directory it may not read.
///
/// Where it cannot, creating an entry inside a write-only directory fails with
/// `EACCES` instead of succeeding the way `mkdir` does (OpenBSD, for example,
/// has neither `O_PATH` nor `O_SEARCH`).
pub const SEARCH_ONLY_SUPPORTED: bool = SEARCH_ONLY.is_some();

impl DirFd {
/// Open a directory and return a file descriptor
///
Expand All @@ -166,6 +203,27 @@ 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<Self> {
let denied = match Self::open(path, SymlinkBehavior::Follow) {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => e,
result => return result,
};
let Some(search_only) = SEARCH_ONLY else {
return Err(denied);
};

let flags = search_only | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC | LARGEFILE;
nix::fcntl::open(path, flags, Mode::empty())
.map(|fd| Self { fd })
.map_err(|_| denied)
}

/// Open a subdirectory relative to this directory
///
/// # Arguments
Expand Down Expand Up @@ -225,7 +283,7 @@ impl DirFd {

/// Get raw stat data for this directory
pub fn fstat(&self) -> io::Result<FileStat> {
let stat = nix::sys::stat::fstat(&self.fd).map_err(|e| SafeTraversalError::StatFailed {
let stat = fstat(&self.fd).map_err(|e| SafeTraversalError::StatFailed {
path: translate!("safe-traversal-current-directory").into(),
source: io::Error::from_raw_os_error(e as i32),
})?;
Expand Down Expand Up @@ -651,7 +709,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<DirFd> {
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)?;
Expand Down Expand Up @@ -988,6 +1046,7 @@ mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::MetadataExt;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::fs::symlink;
use std::os::unix::io::IntoRawFd;
use tempfile::TempDir;
Expand Down Expand Up @@ -1413,6 +1472,27 @@ mod tests {
assert!(nested_path.is_dir());
}

#[test]
fn test_create_dir_all_safe_in_write_only_dir() {
// root ignores the permission bits this test depends on, and without
// O_PATH or O_SEARCH the walk cannot anchor on an unreadable directory
if Uid::effective().is_root() || !SEARCH_ONLY_SUPPORTED {
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();

// mkdir needs write and execute, not read: an unreadable parent must
// not stop us, the way it does not stop GNU.
let nested = write_only.join("a/b");
create_dir_all_safe(&nested, 0o755).unwrap();

fs::set_permissions(&write_only, fs::Permissions::from_mode(0o755)).unwrap();
assert!(nested.is_dir());
}

#[test]
fn test_create_dir_all_safe_existing_path() {
let temp_dir = TempDir::new().unwrap();
Expand Down
38 changes: 38 additions & 0 deletions tests/by-util/test_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2906,6 +2906,44 @@ fn test_install_d_dangling_symlink_in_path_errors() {
);
}

#[test]
#[cfg(all(
unix,
not(any(target_os = "aix", target_os = "hurd", target_os = "redox"))
))]
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;
}
if !uucore::safe_traversal::SEARCH_ONLY_SUPPORTED {
println!("Test skipped; platform cannot anchor on an unreadable directory");
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() {
Expand Down
Loading