From c1ce76d0e0d6201ee195f23352abb1bb30260330 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 18:53:28 +0000 Subject: [PATCH 01/71] mv: refuse a link/ operand across filesystems instead of emptying its target `mv link/ /otherfs/x`, where link points to a directory D: rename(2) fails with EXDEV, so mv fell back to copy and remove. The copy followed the link and duplicated D, and the removal walk then unlinked everything it had copied -- D was emptied and the link left in place. Within one filesystem the same command fails with ENOTDIR. - mv: in the EXDEV fallback, an operand with a trailing slash whose bare name is a symbolic link fails with the rename's own diagnostic ("cannot move 'link/' to 'x': Not a directory") before anything is copied. GNU coreutils 9.4 empties D here; we do not follow it. - ftw: traverse_directory_at handled a trailing-slash name by opening it directly, so a symbolic link was walked under its own name and Entry::reached_through_symlink() never fired. It now dispatches the way traverse_directory does: such a link is walked as `.` in the directory it names. - mv's removal walk refuses a starting point reached through a symbolic link (ENOTDIR), as rm does, so a directory swapped for a link after the check above still cannot be emptied. Co-Authored-By: Claude Opus 5.5 --- ftw/src/lib.rs | 22 +++++++++++++++++-- tree/mv.rs | 27 +++++++++++++++++++++++ tree/remove_moved.rs | 39 +++++++++++++++++++++++++++++++++ tree/tests/mv/mod.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) diff --git a/ftw/src/lib.rs b/ftw/src/lib.rs index 7778d9572..6af5ee6be 100644 --- a/ftw/src/lib.rs +++ b/ftw/src/lib.rs @@ -1072,8 +1072,9 @@ where /// the path to it is resolved again, so renaming or replacing one of its ancestors after `dir` /// was opened cannot redirect the walk. `name` is a single component (it may carry trailing /// slashes), looked up in `dir` exactly as `traverse_directory` looks up an operand's last -/// component; and `postprocess_dir` for the starting point itself receives `dir` as the -/// containing directory. +/// component: a symbolic link named with a trailing slash is walked as `.` in the directory it +/// names (`Entry::reached_through_symlink`). `postprocess_dir` for the starting point itself +/// receives `dir` as the containing directory (or, for such a link, the directory it names). /// /// `display_parent` is only shown: each entry's `path()` is `display_parent` joined with the /// entry's path from `name`. It is never resolved. @@ -1112,6 +1113,23 @@ where return false; } }; + + // A trailing slash after a name that may be a symbolic link: as in `traverse_directory`. + let name_bytes = name.to_bytes(); + let (bare, suffix) = name_bytes.split_at(name_bytes.len() - directory_suffix_len(name_bytes)); + if !suffix.is_empty() && !matches!(bare, b"" | b"." | b"..") { + let bare = CString::new(bare).expect("taken from a C string"); + return walk_slash_operand( + starting_dir, + path_stack, + cstring_to_rc(&bare), + suffix, + file_handler, + postprocess_dir, + err_reporter, + opts, + ); + } walk_from( starting_dir, path_stack, diff --git a/tree/mv.rs b/tree/mv.rs index 6857b1c9b..fc10bef51 100644 --- a/tree/mv.rs +++ b/tree/mv.rs @@ -23,6 +23,7 @@ use gettextrs::gettext; use remove_moved::remove_moved_source; use std::{ collections::{HashMap, HashSet}, + ffi::CString, fs, io::{self, IsTerminal}, os::unix::{ffi::OsStrExt, fs::MetadataExt}, @@ -397,6 +398,17 @@ fn move_file_deciding( // Fall through: source and target are on different filesystems; must copy. + // `link/` is not a directory the rename could move (ENOTDIR), and copying it would follow + // the link: fail as the rename does within one filesystem, rather than copy the directory + // the link points to and then empty it. + if is_symlink_with_trailing_slash(&source_entry) { + return Err(cannot_move( + source, + target, + &io::Error::from_raw_os_error(libc::ENOTDIR), + )); + } + // The copy must start from the file examined above, and is what step 7 removes. let identity = match &source_lstat { Ok(md) => (md.dev(), md.ino()), @@ -455,6 +467,21 @@ fn move_file_deciding( Ok(Moved::Copied(copied)) } +/// Whether `entry` is named with a trailing slash and, without it, names a symbolic link. +fn is_symlink_with_trailing_slash(entry: &PinnedEntry) -> bool { + let name = entry.name().to_bytes(); + let Some(last) = name.iter().rposition(|&b| b != b'/') else { + return false; + }; + if last + 1 == name.len() { + return false; + } + let Ok(link) = CString::new(&name[..=last]) else { + return false; + }; + ftw::Metadata::new(entry.dir_fd(), &link, false).is_ok_and(|md| md.is_symlink()) +} + /// Whether a rename may replace a file at the target. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Replace { diff --git a/tree/remove_moved.rs b/tree/remove_moved.rs index 09aca07cc..7879018a1 100644 --- a/tree/remove_moved.rs +++ b/tree/remove_moved.rs @@ -53,6 +53,13 @@ pub fn remove_moved_source( let file_handler = |entry: ftw::Entry<'_>| -> Result { let mut removal = removal.borrow_mut(); + // `link/` is walked as `.` in the directory the link points to, which no removal takes + // away by that name: refuse it rather than empty that directory. + if entry.reached_through_symlink() { + let enotdir = io::Error::from_raw_os_error(libc::ENOTDIR); + removal.leave(cannot_remove(&entry, &enotdir)); + return Ok(false); + } let Some(md) = entry.metadata().filter(|md| copied.unchanged(md)) else { removal.leave(gettext!( "not removing '{}': it changed during the move", @@ -148,3 +155,35 @@ fn remove_emptied_dir(entry: &ftw::Entry<'_>, holds_reported: bool, removal: &mu fn cannot_remove(entry: &ftw::Entry<'_>, e: &io::Error) -> String { gettext!("cannot remove '{}': {}", entry.path(), error_string(e)) } + +#[cfg(test)] +mod tests { + use super::remove_moved_source; + use crate::common::{CopiedSources, InodeMap, PinnedDirs, SourceState}; + use std::fs; + + /// `link/` is walked as `.` in the directory the link points to. Even with everything there + /// recorded as copied, the removal refuses it instead of emptying that directory. + #[test] + fn a_symlink_with_a_trailing_slash_is_not_removed_through() { + let tmp = plib::tmp::tempdir().unwrap(); + let dir = tmp.path(); + fs::create_dir(dir.join("D")).unwrap(); + fs::write(dir.join("D/f"), b"f").unwrap(); + std::os::unix::fs::symlink("D", dir.join("link")).unwrap(); + + let mut copied = CopiedSources::default(); + for copy in ["D", "D/f"] { + copied.record(SourceState::of( + &fs::symlink_metadata(dir.join(copy)).unwrap(), + )); + } + let source = PinnedDirs::default().pin(&dir.join("link/")).unwrap(); + + let removed = remove_moved_source(&source, &copied, &mut InodeMap::new(), false); + + assert!(!removed); + assert_eq!(fs::read(dir.join("D/f")).unwrap(), b"f"); + assert!(fs::symlink_metadata(dir.join("link")).unwrap().is_symlink()); + } +} diff --git a/tree/tests/mv/mod.rs b/tree/tests/mv/mod.rs index 315b3622c..c6b4360b3 100644 --- a/tree/tests/mv/mod.rs +++ b/tree/tests/mv/mod.rs @@ -1589,6 +1589,58 @@ fn test_mv_xdev_symlink_operand() { fs::remove_dir_all(test_dir).unwrap(); } +// `link/`, a symbolic link to a directory named with a trailing slash, is not a directory that +// can be renamed (rename(2) fails with ENOTDIR). Across filesystems the move fell back to a copy, +// which followed the link, and then removed what it had copied: the directory the link points +// to was emptied and the link left. It fails as the rename does, before anything is copied. +#[test] +#[cfg_attr(not(target_os = "linux"), ignore)] +fn test_mv_xdev_symlink_with_trailing_slash() { + let other_root = Path::new(option_env!("OTHER_PARTITION_TMPDIR").unwrap_or("/dev/shm")); + let src_tmp = plib::tmp::tempdir().unwrap(); + let here = fs::metadata(src_tmp.path()).unwrap().dev(); + if fs::metadata(other_root).map_or(true, |md| md.dev() == here) { + eprintln!("skipping: no second filesystem at {}", other_root.display()); + return; + } + let other_tmp = plib::tmp::Builder::new().tempdir_in(other_root).unwrap(); + + let src = src_tmp.path(); + fs::create_dir(src.join("D")).unwrap(); + fs::write(src.join("D/f"), b"f").unwrap(); + unix::fs::symlink("D", src.join("link")).unwrap(); + let link_slash = &format!("{}/link/", src.display()); + + // Within one filesystem: the rename's own error. + let same_fs = &format!("{}/x", src.display()); + mv_test( + &[link_slash, same_fs], + "", + &format!("mv: cannot move '{link_slash}' to '{same_fs}': Not a directory\n"), + 1, + ); + + // Across filesystems: the same error, and nothing copied or removed. + let other = other_tmp.path().display(); + for (target, shown) in [ + (format!("{other}/x"), format!("{other}/x")), + (format!("{other}"), format!("{other}/link")), + ] { + mv_test( + &[link_slash, &target], + "", + &format!("mv: cannot move '{link_slash}' to '{shown}': Not a directory\n"), + 1, + ); + assert_eq!(fs::read(src.join("D/f")).unwrap(), b"f", "D was emptied"); + assert!(fs::symlink_metadata(src.join("link")).unwrap().is_symlink()); + assert!( + fs::symlink_metadata(&shown).is_err(), + "{shown} was copied to" + ); + } +} + // rename(2) needs write and search permission on the directories involved, not read // permission: a move within one filesystem out of, and into, directories of mode 0300 works, // in both synopsis forms, wherever mv cannot hold such a directory open. From 49c4bcf219a40f633e7772e59f1241d918e8b42a Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 18:57:00 +0000 Subject: [PATCH 02/71] rm -r: report a directory left not empty unless what keeps it was already reported remove_left_directory ignored ENOTEMPTY from rmdir unconditionally, to avoid repeating "Directory not empty" up the tree after a child failed. But it also hid a directory that gained an entry while rm emptied it, and one holding a file the user declined to remove under -i: the directory stayed, with no diagnostic and exit status 0. rm now tracks, per directory walked, whether anything under it was left behind, the way GNU coreutils 9.4 does (measured): - left behind: an entry already reported (a removal or walk error), or a directory kept at its first prompt (a declined descent, or a declined removal of an empty directory). A directory holding one is neither prompted for nor reported again. - not left behind: a declined file, or a declined removal of a directory already emptied. The directory is still prompted for, and rmdir's ENOTEMPTY is reported as "cannot remove 'd': Directory not empty" with exit status 1. A directory holding a reported entry is therefore no longer prompted for under -i, as with GNU rm. The new test makes the directory gain an entry deterministically: rm -ri reads its answers from a pipe, and the entry is created while rm waits for the answer to the directory's own prompt. Co-Authored-By: Claude Opus 5.5 --- tree/rm.rs | 140 +++++++++++++++++++++++++++++++++---------- tree/tests/rm/mod.rs | 127 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 33 deletions(-) diff --git a/tree/rm.rs b/tree/rm.rs index 7e7b74646..06501fdc0 100644 --- a/tree/rm.rs +++ b/tree/rm.rs @@ -14,7 +14,7 @@ use clap::Parser; use ftw::{self, traverse_directory}; use gettextrs::gettext; use std::{ - cell::Cell, + cell::{Cell, RefCell}, ffi::{CStr, CString}, fs, io::{self, IsTerminal}, @@ -272,7 +272,10 @@ where enum DirAction { Removed, Entered, - Skipped, + /// Kept: its prompt was declined. + Declined, + /// Removed meanwhile by someone else, which with `-f` is no error. + Gone, } /// Directly remove a directory or enter it. @@ -284,7 +287,7 @@ fn process_directory(cfg: &RmConfig, entry: &ftw::Entry) -> io::Result io::Result io::Result { // Set by every diagnostic. The walk's own result cannot stand for the exit status: it also // counts the errors `is_already_gone` excuses. let had_error = Cell::new(false); + let left = LeftBehind::default(); + let failed = || { + had_error.set(true); + left.leave(); + }; traverse_directory( filepath, - |entry| { - let result = remove_walked(cfg, &entry); - if result.is_err() { - had_error.set(true); + |entry| match remove_walked(cfg, &entry) { + Ok(Walked::Entered) => { + left.enter(); + Ok(true) + } + Ok(Walked::Done) => Ok(false), + Ok(Walked::Declined) => { + left.leave(); + Ok(false) + } + Err(()) => { + failed(); + Err(()) } - result }, |entry, exit| { - let result = remove_left_directory(cfg, &entry, exit); + let holds_left = left.exit(); + let result = remove_left_directory(cfg, &entry, exit, holds_left); if result.is_err() { - had_error.set(true); + failed(); } result }, @@ -368,7 +385,7 @@ fn rm_directory(cfg: &RmConfig, filepath: &Path) -> io::Result { if is_already_gone(cfg, &error) { return; } - had_error.set(true); + failed(); report_walk_error(&entry, kind, &error); }, ftw::TraverseDirectoryOpts::default(), @@ -377,9 +394,50 @@ fn rm_directory(cfg: &RmConfig, filepath: &Path) -> io::Result { Ok(!had_error.get()) } +/// What a recursive removal has left behind, which keeps the directories holding it. +/// +/// As GNU rm does, an entry counts once it has been reported, or kept at a directory's first +/// prompt (a declined descent, or a declined removal of an empty directory): a directory holding +/// one is neither prompted for nor reported again. A declined file, or a declined removal of a +/// directory already emptied, does not count: its directory is still prompted for, and rmdir's +/// "Directory not empty" is reported. +#[derive(Default)] +struct LeftBehind { + /// Entries left behind so far. + count: Cell, + /// `count` when each directory being walked was entered. + on_entry: RefCell>, +} + +impl LeftBehind { + fn leave(&self) { + self.count.set(self.count.get() + 1); + } + + fn enter(&self) { + self.on_entry.borrow_mut().push(self.count.get()); + } + + /// Leave the directory being walked: whether anything was left behind under it. + fn exit(&self) -> bool { + let on_entry = self.on_entry.borrow_mut().pop().unwrap_or(0); + self.count.get() > on_entry + } +} + +/// What `remove_walked` did with an entry. +enum Walked { + /// A directory with contents, entered to remove them. + Entered, + /// Removed, kept at a file's prompt, or gone meanwhile (with `-f`). + Done, + /// A directory kept at its first prompt. + Declined, +} + /// The walk's `file_handler` for `rm_directory`: remove `entry`, or enter it if it is a /// directory with contents. `Err` means a diagnostic was written. -fn remove_walked(cfg: &RmConfig, entry: &ftw::Entry) -> Result { +fn remove_walked(cfg: &RmConfig, entry: &ftw::Entry) -> Result { let md = entry.metadata().unwrap(); if md.file_type() == ftw::FileType::Directory { @@ -405,8 +463,9 @@ fn remove_walked(cfg: &RmConfig, entry: &ftw::Entry) -> Result { } match process_directory(cfg, entry) { Ok(dir_action) => match dir_action { - DirAction::Entered => Ok(true), - DirAction::Removed | DirAction::Skipped => Ok(false), + DirAction::Entered => Ok(Walked::Entered), + DirAction::Declined => Ok(Walked::Declined), + DirAction::Removed | DirAction::Gone => Ok(Walked::Done), }, Err(e) => { eprintln!("rm: {}", error_string(&e)); @@ -420,36 +479,51 @@ fn remove_walked(cfg: &RmConfig, entry: &ftw::Entry) -> Result { eprintln!("rm: {}", error_string(&e)); return Err(()); } - Ok(true) + Ok(Walked::Done) } } /// The walk's `postprocess_dir` for `rm_directory`: remove the directory `entry` once its -/// contents are gone. `Err` means a diagnostic was written. -fn remove_left_directory(cfg: &RmConfig, entry: &ftw::Entry, exit: ftw::DirExit) -> Result<(), ()> { - // A directory the traversal could not descend into still has its contents, so - // prompting for it and attempting the removal would only produce a second diagnostic - // on top of the one already reported. - if exit == ftw::DirExit::NotDescended { +/// contents are gone, unless it holds something left behind (`holds_left`, see `LeftBehind`). +/// `Err` means a diagnostic was written. +fn remove_left_directory( + cfg: &RmConfig, + entry: &ftw::Entry, + exit: ftw::DirExit, + holds_left: bool, +) -> Result<(), ()> { + // A directory the traversal could not descend into still has its contents, and one holding + // an entry already reported or kept stays for it: prompting for either and attempting the + // removal would only add a `Directory not empty` on top of what was already said. + if exit == ftw::DirExit::NotDescended || holds_left { return Ok(()); } if should_remove_directory(cfg, entry) { // Remove the directory if let Err(e) = entry.unlink(libc::AT_REMOVEDIR) { - // `ENOTEMPTY` means one or more subdirectories were not - // removed. Do not flood the output by recursively - // printing `Directory not empty` errors. With -f a directory - // someone else removed meanwhile is no error either. - if e.raw_os_error() != Some(libc::ENOTEMPTY) && !is_already_gone(cfg, &e) { - let err_str = gettext!( + // With -f a directory someone else removed meanwhile is no error. + if is_already_gone(cfg, &e) { + return Ok(()); + } + // Not empty: an entry the removal did not know of appeared in it meanwhile, or + // one was kept at its prompt. + let not_empty = matches!(e.raw_os_error(), Some(libc::ENOTEMPTY) | Some(libc::EEXIST)); + let err_str = if not_empty { + gettext!( + "cannot remove '{}': {}", + entry.path().clean_trailing_slashes(), + error_string(&e) + ) + } else { + gettext!( "cannot remove directory '{}': {}", entry.path().clean_trailing_slashes(), error_string(&e) - ); - eprintln!("rm: {}", err_str); - return Err(()); - } + ) + }; + eprintln!("rm: {}", err_str); + return Err(()); } else { report_removed(cfg, true, &entry.path().clean_trailing_slashes()); } diff --git a/tree/tests/rm/mod.rs b/tree/tests/rm/mod.rs index 10141ded3..71b4ff4af 100644 --- a/tree/tests/rm/mod.rs +++ b/tree/tests/rm/mod.rs @@ -1369,3 +1369,130 @@ fn test_rm_r_trailing_slash_symlink_to_directory() { fs::remove_dir_all(test_dir).unwrap(); } + +/// Read `stderr` until what it has written ends with `prompt`. +fn await_prompt(stderr: &mut impl io::Read, seen: &mut Vec, prompt: &str) { + let mut byte = [0u8]; + while !seen.ends_with(prompt.as_bytes()) { + let n = stderr.read(&mut byte).unwrap(); + assert_eq!(n, 1, "rm exited before asking {prompt:?}: {seen:?}"); + seen.push(byte[0]); + } +} + +/// A directory that gains an entry while `rm -r` empties it is not removed, and rm says so: +/// rmdir's ENOTEMPTY is no error only for a directory that holds something rm already reported +/// or was told to keep. The entry is added while rm waits for the answer to the directory's +/// own prompt, so the order is fixed. +#[test] +fn test_rm_r_dir_that_gained_an_entry_is_reported() { + let tmp = plib::tmp::tempdir().unwrap(); + let d = &format!("{}/d", tmp.path().display()); + fs::create_dir(d).unwrap(); + fs::write(format!("{d}/f"), b"f").unwrap(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_rm")) + .args(["-ri", d]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + let mut stderr = child.stderr.take().unwrap(); + let mut seen = Vec::new(); + + await_prompt( + &mut stderr, + &mut seen, + &format!("descend into directory '{d}'? "), + ); + stdin.write_all(b"y\n").unwrap(); + await_prompt( + &mut stderr, + &mut seen, + &format!("remove regular file '{d}/f'? "), + ); + stdin.write_all(b"y\n").unwrap(); + await_prompt(&mut stderr, &mut seen, &format!("remove directory '{d}'? ")); + fs::write(format!("{d}/new"), b"new").unwrap(); + stdin.write_all(b"y\n").unwrap(); + drop(stdin); + + io::Read::read_to_end(&mut stderr, &mut seen).unwrap(); + let status = child.wait().unwrap(); + let seen = String::from_utf8(seen).unwrap(); + assert!( + seen.ends_with(&format!("? rm: cannot remove '{d}': Directory not empty\n")), + "{seen:?}" + ); + assert_eq!(status.code(), Some(1)); + assert!(Path::new(&format!("{d}/new")).exists()); +} + +/// What `-i` was told to keep, as GNU coreutils 9.4 treats it: a declined file, or a declined +/// removal of an emptied directory, leaves its parent not empty, which is reported; a declined +/// descent, or a declined removal of an empty directory, keeps its ancestors without asking. +#[test] +fn test_rm_ri_declined_entries_and_their_parent() { + let tmp = plib::tmp::tempdir().unwrap(); + let d = &format!("{}/d", tmp.path().display()); + let s = &format!("{d}/s"); + + // A declined file. + fs::create_dir(d).unwrap(); + fs::write(format!("{d}/f"), b"f").unwrap(); + rm_test_with_stdin( + &["-ri", d], + "y\nn\ny\n", + "", + &format!( + "rm: descend into directory '{d}'? rm: remove regular file '{d}/f'? \ + rm: remove directory '{d}'? rm: cannot remove '{d}': Directory not empty\n" + ), + 1, + ); + assert!(Path::new(&format!("{d}/f")).exists()); + fs::remove_dir_all(d).unwrap(); + + // A declined removal of a directory, once emptied. + fs::create_dir_all(s).unwrap(); + fs::write(format!("{s}/f"), b"f").unwrap(); + rm_test_with_stdin( + &["-ri", d], + "y\ny\ny\nn\ny\n", + "", + &format!( + "rm: descend into directory '{d}'? rm: descend into directory '{s}'? \ + rm: remove regular file '{s}/f'? rm: remove directory '{s}'? \ + rm: remove directory '{d}'? rm: cannot remove '{d}': Directory not empty\n" + ), + 1, + ); + assert!(Path::new(s).exists()); + fs::remove_dir_all(d).unwrap(); + + // A declined descent. + fs::create_dir_all(s).unwrap(); + fs::write(format!("{s}/f"), b"f").unwrap(); + rm_test_with_stdin( + &["-ri", d], + "y\nn\n", + "", + &format!("rm: descend into directory '{d}'? rm: descend into directory '{s}'? "), + 0, + ); + assert!(Path::new(&format!("{s}/f")).exists()); + fs::remove_dir_all(d).unwrap(); + + // A declined removal of an empty directory. + fs::create_dir_all(s).unwrap(); + rm_test_with_stdin( + &["-ri", d], + "y\nn\n", + "", + &format!("rm: descend into directory '{d}'? rm: remove directory '{s}'? "), + 0, + ); + assert!(Path::new(s).exists()); +} From 0f928b9f96a0feb2a35e38a3a3c461bc5ebf348e Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 18:59:13 +0000 Subject: [PATCH 03/71] cp -Rl: replace a symlink at a destination name below the operand under -f Below the operand, cp refuses to write through a symbolic link at a destination name ("not writing through symlink"), since the tree's owner chose where it points. That refusal also caught -l, which writes nothing: it makes a hard link and, under -f, renames it over the existing name, never following it. So `cp -Rlf src dst` failed where dst held a symlink, and `cp -Rl` gave the wrong diagnostic. -l is now exempt from the refusal, as GNU coreutils 9.4 is (measured): with -f the link is replaced by a hard link to the source; without it, "cannot create hard link 'dst/...' to 'src/...': File exists" and exit status 1. The link's target is untouched either way. Co-Authored-By: Claude Opus 5.5 --- tree/common/copy.rs | 4 +++- tree/tests/cp/link.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/tree/common/copy.rs b/tree/common/copy.rs index dfd638ace..5d675f331 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -1093,7 +1093,8 @@ where // user named; below it the link is refused in GNU's words. A link that resolves is // refused below the operand for the same reason (GNU writes through it): the tree's // owner chose where it points, not the user who ran cp. (A link to be reproduced as a - // link, or a special file, replaces the link instead.) + // link, a special file, or a hard link under -l, which writes nothing, replaces the link + // instead, or is refused like any existing destination.) let target_is_symlink = target_symlink_md .as_ref() .is_some_and(|md| md.file_type() == ftw::FileType::SymbolicLink); @@ -1101,6 +1102,7 @@ where && !state.at_top_level && !act_on_link_itself && !(source_is_special_file && cfg.recursive) + && !cfg.link { return Err(io::Error::other(if target_is_dangling_symlink { gettext!( diff --git a/tree/tests/cp/link.rs b/tree/tests/cp/link.rs index f13a6a8b3..81ab8deee 100644 --- a/tree/tests/cp/link.rs +++ b/tree/tests/cp/link.rs @@ -180,3 +180,38 @@ fn cp_lp_does_not_take_a_symlink_to_the_same_file_for_the_link() { assert_eq!((err.as_str(), code), ("", 0)); assert_eq!(ident(&d.join("s2")), ident(&d.join("s1"))); } + +/// A symbolic link at a destination name below the operand is not written through by -l, which +/// writes nothing: the link is replaced by the hard link under -f, and refused as any existing +/// destination is without it. Either way the file it points to is untouched (GNU coreutils 9.4). +#[test] +fn cp_rl_replaces_a_symlink_below_the_operand_only_under_f() { + let tmp = plib::tmp::tempdir().unwrap(); + let d = tmp.path(); + fs::create_dir_all(d.join("src/sub")).unwrap(); + fs::write(d.join("src/sub/f"), "new\n").unwrap(); + fs::write(d.join("victim"), "victim\n").unwrap(); + fs::create_dir_all(d.join("dst/src/sub")).unwrap(); + symlink("../../../victim", d.join("dst/src/sub/f")).unwrap(); + let victim = ident(&d.join("victim")); + + let (err, code) = cp_in(d, &["-Rl", "src", "dst"]); + assert_eq!( + (err.as_str(), code), + ( + "cp: cannot create hard link 'dst/src/sub/f' to 'src/sub/f': File exists\n", + 1 + ) + ); + assert!(fs::symlink_metadata(d.join("dst/src/sub/f")) + .unwrap() + .is_symlink()); + + let (err, code) = cp_in(d, &["-Rlf", "src", "dst"]); + assert_eq!((err.as_str(), code), ("", 0)); + assert_eq!(ident(&d.join("dst/src/sub/f")), ident(&d.join("src/sub/f"))); + + assert_eq!(ident(&d.join("victim")), victim); + assert_eq!(fs::read(d.join("victim")).unwrap(), b"victim\n"); + assert_eq!(fs::metadata(d.join("victim")).unwrap().nlink(), 1); +} From 19b2522365cd4ff0b782e8655e79b32d60fe6cc6 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 19:00:47 +0000 Subject: [PATCH 04/71] cp -v, mv -v: report a made directory only once it is opened and verified The verbose line for a directory the copy made ("'d' -> 'dst'", or mv's "created directory 'dst'") was written as soon as mkdirat returned, before open_made_dir opened it and checked it was the directory made. When that failed, -v had already reported a directory the copy then refused to copy into, followed by "cannot open directory 'dst'". The report is now made after open_made_dir succeeds. The test makes the open fail deterministically with RLIMIT_NOFILE, trying each limit from 3 up until the copy succeeds: whichever open a limit falls on, no directory named in a "cannot open directory" error may appear in the verbose output, and at least one limit must make a made directory fail to open. Co-Authored-By: Claude Opus 5.5 --- tree/common/copy.rs | 15 ++++------- tree/tests/cp/verbose.rs | 58 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/tree/common/copy.rs b/tree/common/copy.rs index 5d675f331..120528f9e 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -2006,16 +2006,10 @@ where prompt_fn, ) { Ok(copy_result) => { - match ©_result { - CopyResult::CopyingDirectory(DirOrigin::Made) => { - report_copied(cfg, source.path().as_inner(), &target, true) - } - CopyResult::CopiedFile(_) => { - report_copied(cfg, source.path().as_inner(), &target, false) - } - // A directory copied into was not made by this copy. - CopyResult::CopyingDirectory(DirOrigin::Found { .. }) | CopyResult::Skipped => { - } + // A directory this copy made is reported once it is opened and checked to be + // the one made, below; one copied into was not made by this copy. + if let CopyResult::CopiedFile(_) = ©_result { + report_copied(cfg, source.path().as_inner(), &target, false); } // Record where this inode landed only if a file was actually created there. // Recording a skipped copy pointed a later hard link at a target that does @@ -2097,6 +2091,7 @@ where let fd = ftw::FileDescriptor::from(fd); let md = fd_metadata(fd.as_raw_fd())?; made_dirs.borrow_mut().insert((md.dev(), md.ino()), trust); + report_copied(cfg, source.path().as_inner(), &target, true); Ok((fd, md)) }), }; diff --git a/tree/tests/cp/verbose.rs b/tree/tests/cp/verbose.rs index de5222ae1..0548a765c 100644 --- a/tree/tests/cp/verbose.rs +++ b/tree/tests/cp/verbose.rs @@ -189,3 +189,61 @@ fn verbose_write_error_finishes_the_copy() { assert_eq!(fs::read_to_string(copied).unwrap(), "b\n"); fs::remove_dir_all(&dir).unwrap(); } + +/// A directory made for the copy is reported only once it has been opened and checked to be the +/// one made: one that cannot be opened is reported as an error, not as copied. The open is made +/// to fail by a limit on open files, tried at each limit in turn, so that whichever opens the +/// limit falls on, every directory reported as made is one the copy could open. +#[test] +#[cfg(target_os = "linux")] +fn test_cp_verbose_reports_a_made_directory_only_once_opened() { + use std::os::unix::process::CommandExt; + + let tmp = plib::tmp::tempdir().unwrap(); + let dir = tmp.path(); + fs::create_dir_all(dir.join("d/s")).unwrap(); + fs::write(dir.join("d/s/f"), "f").unwrap(); + + let mut made_dir_failures = 0; + for limit in 3..32 { + let _ = fs::remove_dir_all(dir.join("dst")); + let mut command = Command::new(get_binary_path("cp")); + command + .args(["-Rv", "d", "dst"]) + .current_dir(dir) + .stdin(Stdio::null()); + // SAFETY: setrlimit is async-signal-safe, and nothing else runs in the child. + unsafe { + command.pre_exec(move || { + let rlim = libc::rlimit { + rlim_cur: limit, + rlim_max: limit, + }; + if libc::setrlimit(libc::RLIMIT_NOFILE, &rlim) == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + }); + } + let output = command.output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + for made in ["dst", "dst/s"] { + if stderr.contains(&format!("cannot open directory '{made}'")) { + made_dir_failures += 1; + assert!( + !stdout.contains(&format!("-> '{made}'\n")), + "limit {limit}: '{made}' reported as made: {stdout:?} {stderr:?}" + ); + } + } + if output.status.success() { + break; + } + } + assert!( + made_dir_failures > 0, + "no limit made a directory fail to open" + ); +} From a7a1bb924558e2cede34356c464338c79ea4be02 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 19:02:37 +0000 Subject: [PATCH 05/71] mv: refuse a D/. or D/.. source across filesystems instead of emptying D or its parent A source whose last component is `.` or `..` (`D/.`, `D/..`, `link/.`, `link/./`, `.`, `..`) is pinned as the name `.` or `..` in its parent directory. Within one filesystem rename(2) refuses it with EBUSY. Across filesystems Linux returns EXDEV first, so mv fell back to copy and remove: it copied the directory and then removed everything it had copied, emptying D, and for `D/..` or `..` the parent with all of D's siblings. - mv: in the EXDEV fallback such a source fails with the rename's own diagnostic, "cannot move 'D/.' to 'x': Device or resource busy", before anything is copied. (GNU coreutils 9.4 copies it and then refuses the removal; we do not copy at all.) - remove_moved_source refuses such a starting name outright, so no caller can remove through it. - PinnedEntry::names_dot_or_dotdot is the one test of the name, used by both. The target side is not affected: step 5 removes an existing target with a single unlinkat, which fails on `.` or `..`. cp never removes what it pins, and rm already refuses `.` and `..` as POSIX requires. Co-Authored-By: Claude Opus 5.5 --- tree/common/pinned.rs | 13 +++++++++++++ tree/mv.rs | 10 ++++++++++ tree/remove_moved.rs | 40 ++++++++++++++++++++++++++++++++++++++++ tree/tests/mv/mod.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+) diff --git a/tree/common/pinned.rs b/tree/common/pinned.rs index 7e21f1c38..839d04f54 100644 --- a/tree/common/pinned.rs +++ b/tree/common/pinned.rs @@ -91,6 +91,19 @@ impl PinnedEntry { &self.display_parent } + /// Whether the last component, trailing slashes aside, is `.` or `..` (as for `D/.`, `D/..`, + /// `link/./` or `/`): a name for a directory that is not its entry in a parent, so that + /// nothing can be renamed or removed by it. + pub fn names_dot_or_dotdot(&self) -> bool { + let name = self.name.to_bytes(); + let end = name + .iter() + .rposition(|&b| b != b'/') + .map_or(0, |last| last + 1); + let last = name[..end].rsplit(|&b| b == b'/').next().unwrap_or(b""); + matches!(last, b"." | b"..") + } + /// `fstatat` of the entry in its pinned directory. pub fn metadata(&self, follow_symlinks: bool) -> io::Result { ftw::Metadata::new(self.dir_fd(), &self.name, follow_symlinks) diff --git a/tree/mv.rs b/tree/mv.rs index fc10bef51..f786eafe2 100644 --- a/tree/mv.rs +++ b/tree/mv.rs @@ -398,6 +398,16 @@ fn move_file_deciding( // Fall through: source and target are on different filesystems; must copy. + // `D/.` and `D/..` are no entries the rename could move (EBUSY), and copying then removing + // them would empty D, or D's parent: fail as the rename does within one filesystem. + if source_entry.names_dot_or_dotdot() { + return Err(cannot_move( + source, + target, + &io::Error::from_raw_os_error(libc::EBUSY), + )); + } + // `link/` is not a directory the rename could move (ENOTDIR), and copying it would follow // the link: fail as the rename does within one filesystem, rather than copy the directory // the link points to and then empty it. diff --git a/tree/remove_moved.rs b/tree/remove_moved.rs index 7879018a1..efc4377ba 100644 --- a/tree/remove_moved.rs +++ b/tree/remove_moved.rs @@ -49,6 +49,19 @@ pub fn remove_moved_source( inode_map: &mut InodeMap, verbose: bool, ) -> bool { + // `D/.` and `D/..` are no entries of their own to remove: removing through them would empty + // D, or D's parent. (mv refuses to copy them; this holds whatever the caller did.) + if source.names_dot_or_dotdot() { + eprintln!( + "mv: {}", + gettext!( + "refusing to remove '.' or '..' directory: skipping '{}'", + source.path().display() + ) + ); + return false; + } + let removal = RefCell::new(Removal::default()); let file_handler = |entry: ftw::Entry<'_>| -> Result { @@ -186,4 +199,31 @@ mod tests { assert_eq!(fs::read(dir.join("D/f")).unwrap(), b"f"); assert!(fs::symlink_metadata(dir.join("link")).unwrap().is_symlink()); } + + /// `D/.` and `D/..` name D and its parent, which are not removed by those names. Even with + /// everything there recorded as copied, the removal refuses them instead of emptying them. + #[test] + fn a_dot_or_dotdot_source_is_not_removed_through() { + let tmp = plib::tmp::tempdir().unwrap(); + let p = tmp.path().join("P"); + fs::create_dir_all(p.join("D")).unwrap(); + fs::write(p.join("D/f"), b"f").unwrap(); + fs::write(p.join("g"), b"g").unwrap(); + + let mut copied = CopiedSources::default(); + for copy in ["", "D", "D/f", "g"] { + copied.record(SourceState::of( + &fs::symlink_metadata(p.join(copy)).unwrap(), + )); + } + for source in ["D/.", "D/..", "D/./", "D/../"] { + let source = PinnedDirs::default().pin(&p.join(source)).unwrap(); + + let removed = remove_moved_source(&source, &copied, &mut InodeMap::new(), false); + + assert!(!removed); + assert_eq!(fs::read(p.join("D/f")).unwrap(), b"f"); + assert_eq!(fs::read(p.join("g")).unwrap(), b"g"); + } + } } diff --git a/tree/tests/mv/mod.rs b/tree/tests/mv/mod.rs index c6b4360b3..932b4b6eb 100644 --- a/tree/tests/mv/mod.rs +++ b/tree/tests/mv/mod.rs @@ -1641,6 +1641,49 @@ fn test_mv_xdev_symlink_with_trailing_slash() { } } +// A source whose last component is `.` or `..` (`D/.`, `D/..`, `link/.`, `link/./`) names a +// directory that cannot be renamed by that name: rename(2) fails with EBUSY within one +// filesystem. Across filesystems it failed with EXDEV first, and mv fell back to a copy and then +// removed what it had copied: D was emptied, and for `D/..` its parent with all of D's +// siblings. It fails as the rename does, before anything is copied. +#[test] +#[cfg_attr(not(target_os = "linux"), ignore)] +fn test_mv_xdev_dot_and_dotdot_sources() { + let other_root = Path::new(option_env!("OTHER_PARTITION_TMPDIR").unwrap_or("/dev/shm")); + let src_tmp = plib::tmp::tempdir().unwrap(); + let here = fs::metadata(src_tmp.path()).unwrap().dev(); + if fs::metadata(other_root).map_or(true, |md| md.dev() == here) { + eprintln!("skipping: no second filesystem at {}", other_root.display()); + return; + } + let other_tmp = plib::tmp::Builder::new().tempdir_in(other_root).unwrap(); + + let p = src_tmp.path().join("P"); + fs::create_dir_all(p.join("D")).unwrap(); + fs::write(p.join("D/f"), b"f").unwrap(); + fs::write(p.join("g"), b"g").unwrap(); + unix::fs::symlink("D", p.join("link")).unwrap(); + let p = p.display(); + + for source in ["D/.", "D/..", "link/.", "link/./"] { + let source = &format!("{p}/{source}"); + for target in [ + format!("{}/x", src_tmp.path().display()), + format!("{}/x", other_tmp.path().display()), + ] { + mv_test( + &[source, &target], + "", + &format!("mv: cannot move '{source}' to '{target}': Device or resource busy\n"), + 1, + ); + assert_eq!(fs::read(format!("{p}/D/f")).unwrap(), b"f", "{source}"); + assert_eq!(fs::read(format!("{p}/g")).unwrap(), b"g", "{source}"); + assert!(fs::symlink_metadata(&target).is_err(), "{target} was made"); + } + } +} + // rename(2) needs write and search permission on the directories involved, not read // permission: a move within one filesystem out of, and into, directories of mode 0300 works, // in both synopsis forms, wherever mv cannot hold such a directory open. From 9a510b6d19902d768e2aa2065f774c137f77966b Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 19:04:44 +0000 Subject: [PATCH 06/71] rm -v: quote removed names with the shared quote_bytes, as cp and mv do rm -v wrote each name between hand-written single quotes, from a lossy UTF-8 rendering of the path: a name with a newline split the line, one with a quote came out ambiguous, and a byte that is not a character was replaced by U+FFFD. GNU coreutils 9.4 prints, for example, removed 'd/a'$'\n''b' removed "d/it's" removed 'd/x'$'\377''y' report_removed now takes the path and quotes its bytes with quote_bytes, which cp -v and mv -v already use, reducing trailing slashes to one as before. remove_nondir_at takes the path instead of a display closure. Co-Authored-By: Claude Opus 5.5 --- tree/rm.rs | 47 ++++++++++++++++++++------------------- tree/tests/rm/mod.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 22 deletions(-) diff --git a/tree/rm.rs b/tree/rm.rs index 06501fdc0..4e1f0b9d0 100644 --- a/tree/rm.rs +++ b/tree/rm.rs @@ -9,7 +9,7 @@ mod common; -use self::common::{error_string, exit_after_verbose, report_verbose}; +use self::common::{error_string, exit_after_verbose, quote_bytes, report_verbose}; use clap::Parser; use ftw::{self, traverse_directory}; use gettextrs::gettext; @@ -93,13 +93,19 @@ fn ask_for_prompt(cfg: &RmConfig, writable: bool) -> bool { } // With `-v`, write the name of each removed entry to standard output (format unspecified by POSIX; -// matches the `removed '…'` / `removed directory '…'` wording of common implementations). -fn report_removed(cfg: &RmConfig, is_dir: bool, name: &str) { +// matches the `removed '…'` / `removed directory '…'` wording of common implementations), quoted +// as cp and mv quote names, trailing slashes reduced to one as in diagnostics. +fn report_removed(cfg: &RmConfig, is_dir: bool, path: &Path) { if cfg.args.verbose { + let mut name = path.as_os_str().as_bytes(); + while name.ends_with(b"//") { + name = &name[..name.len() - 1]; + } + let name = quote_bytes(name); let msg = if is_dir { - gettext!("removed directory '{}'", name) + gettext!("removed directory {}", name) } else { - gettext!("removed '{}'", name) + gettext!("removed {}", name) }; report_verbose(&msg); } @@ -304,7 +310,7 @@ fn process_directory(cfg: &RmConfig, entry: &ftw::Entry) -> io::Result Result { } } } else { - if let Err(e) = remove_nondir_at(cfg, entry.dir_fd(), entry.file_name(), md, || { - entry.path().clean_trailing_slashes() - }) { + if let Err(e) = remove_nondir_at(cfg, entry.dir_fd(), entry.file_name(), md, &entry.path()) + { eprintln!("rm: {}", error_string(&e)); return Err(()); } @@ -525,7 +530,7 @@ fn remove_left_directory( eprintln!("rm: {}", err_str); return Err(()); } else { - report_removed(cfg, true, &entry.path().clean_trailing_slashes()); + report_removed(cfg, true, entry.path().as_inner()); } } @@ -636,26 +641,24 @@ fn rm_file(cfg: &RmConfig, filepath: &Path) -> io::Result { parent_fd.as_raw_fd(), &basename_cstr, &metadata, - || display_cleaned(filepath), + filepath, )?; Ok(true) } /// Removes the non-directory `file_name` in the directory open on `dirfd`, which `metadata` -/// describes, after prompting as the options require; `shown` names it in messages. +/// describes, after prompting as the options require; `path` names it in messages. /// /// Declining the prompt is not an error. The returned error carries the full diagnostic. -fn remove_nondir_at( +fn remove_nondir_at( cfg: &RmConfig, dirfd: libc::c_int, file_name: &CStr, metadata: &ftw::Metadata, - shown: F, -) -> io::Result<()> -where - F: Fn() -> String, -{ - if !should_remove_file(cfg, dirfd, file_name, metadata, &shown) { + path: &Path, +) -> io::Result<()> { + let shown = || display_cleaned(path); + if !should_remove_file(cfg, dirfd, file_name, metadata, shown) { return Ok(()); } let ret = unsafe { libc::unlinkat(dirfd, file_name.as_ptr(), 0) }; @@ -667,7 +670,7 @@ where let err_str = gettext!("cannot remove '{}': {}", shown(), error_string(&e)); return Err(io::Error::other(err_str)); } - report_removed(cfg, false, &shown()); + report_removed(cfg, false, path); Ok(()) } @@ -697,7 +700,7 @@ fn rm_dir_empty(cfg: &RmConfig, filepath: &Path) -> io::Result { } match fs::remove_dir(filepath) { - Ok(()) => report_removed(cfg, true, &display_cleaned(filepath)), + Ok(()) => report_removed(cfg, true, filepath), Err(e) if is_already_gone(cfg, &e) => (), Err(e) => { let err_str = gettext!( @@ -892,7 +895,7 @@ mod tests { let md = ftw::Metadata::new(dir_fd.as_raw_fd(), c"x.o", false).unwrap(); fs::remove_file(tmp.path().join("x.o")).unwrap(); - let shown = || String::from("x.o"); + let shown = Path::new("x.o"); assert!(remove_nondir_at(&config("-f"), dir_fd.as_raw_fd(), c"x.o", &md, shown).is_ok()); let err = remove_nondir_at(&config("-v"), dir_fd.as_raw_fd(), c"x.o", &md, shown) .unwrap_err() diff --git a/tree/tests/rm/mod.rs b/tree/tests/rm/mod.rs index 71b4ff4af..761dc347e 100644 --- a/tree/tests/rm/mod.rs +++ b/tree/tests/rm/mod.rs @@ -1496,3 +1496,55 @@ fn test_rm_ri_declined_entries_and_their_parent() { ); assert!(Path::new(s).exists()); } + +/// `-v` quotes each name as GNU coreutils 9.4 does (and as cp and mv do): a newline, a quote and +/// a byte that is not a character all come out unambiguous, from names in a directory and from +/// operands alike. +#[test] +fn test_rm_v_quotes_names() { + let tmp = plib::tmp::tempdir().unwrap(); + let dir = tmp.path(); + let rm_in = |args: &[&str]| { + let output = Command::new(env!("CARGO_BIN_EXE_rm")) + .args(args) + .current_dir(dir) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert_eq!(String::from_utf8_lossy(&output.stderr), ""); + assert_eq!(output.status.code(), Some(0)); + let mut lines: Vec = String::from_utf8(output.stdout) + .unwrap() + .lines() + .map(String::from) + .collect(); + lines.sort(); + lines + }; + + fs::create_dir(dir.join("d")).unwrap(); + fs::File::create(dir.join("d/a\nb")).unwrap(); + fs::File::create(dir.join("d/it's")).unwrap(); + let non_utf8 = plib::testing::create_non_utf8(&dir.join("d"), b"x\xffy", |p| { + fs::File::create(p).map(drop) + }); + let mut expected = vec![ + "removed 'd/a'$'\\n''b'", + "removed directory 'd'", + "removed \"d/it's\"", + ]; + if non_utf8.is_some() { + expected.push("removed 'd/x'$'\\377''y'"); + } + expected.sort(); + assert_eq!(rm_in(&["-rv", "d"]), expected); + + fs::File::create(dir.join("a\nb")).unwrap(); + fs::File::create(dir.join("it's")).unwrap(); + fs::create_dir(dir.join("e\nf")).unwrap(); + assert_eq!( + rm_in(&["-v", "a\nb", "it's"]), + ["removed \"it's\"", "removed 'a'$'\\n''b'"] + ); + assert_eq!(rm_in(&["-dv", "e\nf"]), ["removed directory 'e'$'\\n''f'"]); +} From 5e6f9786eeefddfe390d71e7ab8f1a0f0cc50fa9 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 19:09:02 +0000 Subject: [PATCH 07/71] ftw: gather a deferred reopen's component identities only when it goes component by component DeferredDir::open_file_descriptor built the full list of ancestor identities on every reopen of a deferred directory, though open_long_filename reads it only when it falls back to opening the path one component at a time. Most reopens resolve in a single fstatat, so the list was walked and allocated for nothing. open_long_filename now takes a closure that yields the identities and calls it once, at the first component it opens. No check is dropped: the fallback is taken for ENAMETOOLONG and also for ELOOP, which a short path can hit, so gating the list on the path's length instead would have left a short ELOOP fallback unchecked. The new test, deferred_long_path_reopen_refuses_a_replaced_component, replaces an intermediate component of a path longer than PATH_MAX with a new directory holding the rest of the path, so only the per-component check can refuse it. It fails when the identities are not passed. Co-Authored-By: Claude Opus 5.5 --- ftw/src/dir.rs | 9 +++--- ftw/src/lib.rs | 10 +++--- ftw/tests/race.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/ftw/src/dir.rs b/ftw/src/dir.rs index b96d69af8..482bb4f26 100644 --- a/ftw/src/dir.rs +++ b/ftw/src/dir.rs @@ -244,10 +244,11 @@ impl DeferredDir { // remainder - bar/baz let remainder = self.path.strip_prefix(&self.parent.1).unwrap(); - // `remainder` is not guaranteed to be shorter than `libc::PATH_MAX`. When it is not, the - // prefix is opened one component at a time, each with this walk's descent flags and - // checked against the identity the walk recorded for it. - let identities = self.component_identities(); + // `remainder` is not guaranteed to be shorter than `libc::PATH_MAX`. When it is not (or + // resolving it at once meets ELOOP), the prefix is opened one component at a time, each + // with this walk's descent flags and checked against the identity the walk recorded for + // it. The identities are gathered only then: most reopens resolve in one call. + let identities = || self.component_identities(); let (starting_dir, components) = open_long_filename( self.parent.0.try_clone()?, remainder, diff --git a/ftw/src/lib.rs b/ftw/src/lib.rs index 6af5ee6be..595140165 100644 --- a/ftw/src/lib.rs +++ b/ftw/src/lib.rs @@ -759,14 +759,15 @@ where /// Every prefix component is opened `O_RDONLY | O_DIRECTORY | O_CLOEXEC` plus `open_flags` (a /// walk's descent flags, so `O_NOFOLLOW` when it does not follow links). `O_DIRECTORY` refuses a /// FIFO or device swapped in for a component before the open can block on it or open the -/// device. With `identities` (one recorded `(dev, ino)` per component of `path`), each opened -/// component must also be the very directory the walk recorded there. +/// device. With `identities` (giving one recorded `(dev, ino)` per component of `path`, and +/// called only once a component is to be opened), each opened component must also be the very +/// directory the walk recorded there. fn open_long_filename<'a, H>( mut starting_dir: FileDescriptor, path: &'a Path, mut path_stack: Option<&mut Vec>>, open_flags: libc::c_int, - identities: Option<&[(libc::dev_t, libc::ino_t)]>, + identities: Option<&dyn Fn() -> Vec<(libc::dev_t, libc::ino_t)>>, err_reporter: &mut H, ) -> io::Result<(FileDescriptor, std::path::Components<'a>)> where @@ -774,6 +775,7 @@ where { let mut path_components = path.components(); let mut opened = 0usize; + let mut recorded: Option> = None; // If `path` is too long, start at a prefix of `path` loop { @@ -823,7 +825,7 @@ where ) .and_then(|fd| match identities { // Fail closed: a component with no recorded identity, or the wrong one, is refused. - Some(ids) => match ids.get(opened) { + Some(gather) => match recorded.get_or_insert_with(gather).get(opened) { Some(&(dev, ino)) if fd_matches(&fd, dev, ino) => Ok(fd), _ => Err(io::Error::from_raw_os_error(libc::ENOTDIR)), }, diff --git a/ftw/tests/race.rs b/ftw/tests/race.rs index c1d87f5d9..9f9174a29 100644 --- a/ftw/tests/race.rs +++ b/ftw/tests/race.rs @@ -508,6 +508,84 @@ fn deferred_long_path_reopen_refuses_fifo_component() { } } +/// A deferred directory reopened one component at a time checks every component against the +/// identity the walk recorded for it, not only the directory reached: a component replaced by +/// another directory holding the rest of the path (the same directories below) is refused. +#[test] +fn deferred_long_path_reopen_refuses_a_replaced_component() { + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + + let tmp = plib::tmp::Builder::new() + .prefix("ftw_race_long_ident") + .tempdir_in(env!("CARGO_TARGET_TMPDIR")) + .unwrap(); + let base = tmp.path().to_path_buf(); + let root = base.join("root"); + fs::create_dir(&root).unwrap(); + + // Twenty 250-byte components: far past PATH_MAX from `root`, so made through descriptors. + const DEPTH: usize = 20; + let names: Vec = (0..DEPTH) + .map(|i| format!("{i:02}{}", "d".repeat(248))) + .collect(); + let mut dir = fs::File::open(&root).unwrap(); + for name in &names { + let c = std::ffi::CString::new(name.as_str()).unwrap(); + assert_eq!( + unsafe { libc::mkdirat(dir.as_raw_fd(), c.as_ptr(), 0o755) }, + 0 + ); + let fd = unsafe { + libc::openat( + dir.as_raw_fd(), + c.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY, + ) + }; + assert!(fd >= 0); + dir = fs::File::from(unsafe { OwnedFd::from_raw_fd(fd) }); + } + drop(dir); + + // The second component is replaced by a new directory, and the third moved into it: the + // path from `root` again leads to the very same directories below. + let first = root.join(&names[0]); + let last_name = names[DEPTH - 1].clone(); + let mut swapped = false; + let mut long_errors = 0usize; + traverse_directory( + &root, + |entry| { + if !swapped && entry.file_name().to_bytes() == last_name.as_bytes() { + let old = first.join(&names[1]); + fs::rename(&old, base.join("moved")).unwrap(); + fs::create_dir(&old).unwrap(); + fs::rename(base.join("moved").join(&names[2]), old.join(&names[2])).unwrap(); + swapped = true; + } + Ok(true) + }, + |_entry, _exit| Ok(()), + |entry, _err| { + // Only a reopen longer than `PATH_MAX` goes component by component; a shorter one + // is refused by the identity of the directory it reaches. + if entry.path().as_inner().as_os_str().len() >= libc::PATH_MAX as usize { + long_errors += 1; + } + }, + TraverseDirectoryOpts { + // Conserve descriptors from the first level on. + caller_fds_per_level: 4096, + ..Default::default() + }, + ); + assert!(swapped, "the swap must have run"); + assert!( + long_errors > 0, + "a long reopen through the replaced component must be refused" + ); +} + /// Walk `root` after the handler for the starting point itself has replaced it using `swap`. /// Returns the names visited and the number of errors reported. fn walk_after_root_swap(tag: &str, swap: impl Fn(&Path, &Path)) -> (HashSet, usize) { From 2ed34e462384008f057ac7876e8fd0dbd81ce0d3 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 19:11:19 +0000 Subject: [PATCH 08/71] rm: accept operands that are not UTF-8, and quote names in every diagnostic rm parsed its operands as UTF-8 strings (to allow the empty operand), so any name that is not valid UTF-8 was refused by the argument parser with "invalid UTF-8 was detected" and status 2: such a file could not be removed by name at all. Its diagnostics and prompts also showed names through a lossy UTF-8 rendering between hand-written quotes, so a byte that is not a character became U+FFFD and a quote or newline in a name came out ambiguous. Operands are now taken as OsString (any bytes, the empty string included). Every diagnostic and prompt shows a name through display_cleaned, which now quotes its bytes with the shared quote_bytes as cp, mv and rm -v do, matching GNU coreutils 9.4, e.g. rm: cannot remove 'x'$'\377''y': No such file or directory rm: cannot remove "it's": No such file or directory Plain names come out exactly as before. Co-Authored-By: Claude Opus 5.5 --- tree/rm.rs | 161 ++++++++++++++++++++----------------------- tree/tests/rm/mod.rs | 76 ++++++++++++++++++++ 2 files changed, 150 insertions(+), 87 deletions(-) diff --git a/tree/rm.rs b/tree/rm.rs index 4e1f0b9d0..6adfad858 100644 --- a/tree/rm.rs +++ b/tree/rm.rs @@ -15,14 +15,14 @@ use ftw::{self, traverse_directory}; use gettextrs::gettext; use std::{ cell::{Cell, RefCell}, - ffi::{CStr, CString}, + ffi::{CStr, CString, OsString}, fs, io::{self, IsTerminal}, os::{ fd::AsRawFd, unix::{ffi::OsStrExt, fs::MetadataExt}, }, - path::{Path, PathBuf}, + path::Path, }; /// rm - remove directory entries @@ -44,13 +44,9 @@ struct Args { #[arg(short, long, help = gettext("Write the name of each removed file to standard output"))] verbose: bool, - #[arg(value_parser = parse_pathbuf, help = gettext("Filepaths to remove"))] - files: Vec, -} - -// Parser for `PathBuf` that allows empty strings -fn parse_pathbuf(s: &str) -> Result { - Ok(PathBuf::from(s)) + // Any bytes but NUL, the empty string included. + #[arg(value_parser = clap::builder::ValueParser::os_string(), help = gettext("Filepaths to remove"))] + files: Vec, } struct RmConfig { @@ -71,13 +67,24 @@ fn prompt_user(prompt: &str) -> bool { plib::locale::is_affirmative(response.trim_end_matches(['\r', '\n'])) } -// Simplifies trailing slashes -fn display_cleaned(filepath: &Path) -> String { - let mut s = format!("{}", filepath.display()); - while s.ends_with("//") { - s.pop(); +/// `filepath`'s bytes with any run of trailing slashes reduced to one. +fn cleaned_bytes(filepath: &Path) -> &[u8] { + let mut name = filepath.as_os_str().as_bytes(); + while name.ends_with(b"//") { + name = &name[..name.len() - 1]; } - s + name +} + +/// `filepath` as diagnostics, prompts and `-v` show it: trailing slashes reduced to one, and +/// quoted as cp and mv quote names, so that every byte of it comes out unambiguous. +fn display_cleaned(filepath: &Path) -> String { + quote_bytes(cleaned_bytes(filepath)) +} + +/// The path of a walked entry, shown as `display_cleaned` shows an operand. +fn entry_shown(entry: &ftw::Entry) -> String { + display_cleaned(entry.path().as_inner()) } /// Whether `e` only says that the file is not there, which with `-f` is no error and no @@ -94,14 +101,10 @@ fn ask_for_prompt(cfg: &RmConfig, writable: bool) -> bool { // With `-v`, write the name of each removed entry to standard output (format unspecified by POSIX; // matches the `removed '…'` / `removed directory '…'` wording of common implementations), quoted -// as cp and mv quote names, trailing slashes reduced to one as in diagnostics. +// as diagnostics show names (`display_cleaned`). fn report_removed(cfg: &RmConfig, is_dir: bool, path: &Path) { if cfg.args.verbose { - let mut name = path.as_os_str().as_bytes(); - while name.ends_with(b"//") { - name = &name[..name.len() - 1]; - } - let name = quote_bytes(name); + let name = display_cleaned(path); let msg = if is_dir { gettext!("removed directory {}", name) } else { @@ -118,7 +121,7 @@ fn refuse_dot_dotdot_root(filepath: &Path) -> io::Result<()> { let dot_dotdot_pattern = regex::bytes::Regex::new(r"(?:^|/)\.{1,2}/*$").unwrap(); if dot_dotdot_pattern.is_match(filepath.as_os_str().as_bytes()) { let err_str = gettext!( - "refusing to remove '.' or '..' directory: skipping '{}'", + "refusing to remove '.' or '..' directory: skipping {}", display_cleaned(filepath) ); return Err(io::Error::other(err_str)); @@ -127,7 +130,7 @@ fn refuse_dot_dotdot_root(filepath: &Path) -> io::Result<()> { if let Ok(abspath) = fs::canonicalize(filepath) { if abspath.as_os_str() == "/" { return Err(io::Error::other(dangerous_root_message( - &filepath.display().to_string(), + filepath.as_os_str().as_bytes(), ))); } } @@ -136,13 +139,13 @@ fn refuse_dot_dotdot_root(filepath: &Path) -> io::Result<()> { } /// The refusal of an operand that is the root directory, named as `shown`. -fn dangerous_root_message(shown: &str) -> String { - if shown == "/" { +fn dangerous_root_message(shown: &[u8]) -> String { + if shown == b"/" { gettext("it is dangerous to operate recursively on '/'") } else { gettext!( - "it is dangerous to operate recursively on '{}' (same as '/')", - shown + "it is dangerous to operate recursively on {} (same as '/')", + quote_bytes(shown) ) } } @@ -171,14 +174,11 @@ fn descend_into_directory(cfg: &RmConfig, entry: &ftw::Entry) -> bool { let writable = entry.is_writable(); if ask_for_prompt(cfg, writable) { let prompt = if writable { - gettext!( - "descend into directory '{}'?", - entry.path().clean_trailing_slashes() - ) + gettext!("descend into directory {}?", entry_shown(entry)) } else { gettext!( - "descend into write-protected directory '{}'?", - entry.path().clean_trailing_slashes() + "descend into write-protected directory {}?", + entry_shown(entry) ) }; if !prompt_user(&prompt) { @@ -192,15 +192,9 @@ fn should_remove_directory(cfg: &RmConfig, entry: &ftw::Entry) -> bool { let writable = entry.is_writable(); if ask_for_prompt(cfg, writable) { let prompt = if writable { - gettext!( - "remove directory '{}'?", - entry.path().clean_trailing_slashes() - ) + gettext!("remove directory {}?", entry_shown(entry)) } else { - gettext!( - "remove write-protected directory '{}'?", - entry.path().clean_trailing_slashes(), - ) + gettext!("remove write-protected directory {}?", entry_shown(entry),) }; if !prompt_user(&prompt) { return false; @@ -226,44 +220,44 @@ where let file_type = metadata.file_type(); let prompt = match file_type { ftw::FileType::Socket => { - gettext!("remove socket '{}'?", filename_fn()) + gettext!("remove socket {}?", filename_fn()) } ftw::FileType::SymbolicLink => { - gettext!("remove symbolic link '{}'?", filename_fn()) + gettext!("remove symbolic link {}?", filename_fn()) } ftw::FileType::BlockDevice => { - gettext!("remove block special file '{}'?", filename_fn()) + gettext!("remove block special file {}?", filename_fn()) } ftw::FileType::CharacterDevice => { - gettext!("remove character special file '{}'?", filename_fn()) + gettext!("remove character special file {}?", filename_fn()) } ftw::FileType::Fifo => { - gettext!("remove fifo '{}'?", filename_fn()) + gettext!("remove fifo {}?", filename_fn()) } ftw::FileType::RegularFile => { let is_empty = metadata.size() == 0; if writable { if is_empty { - gettext!("remove regular empty file '{}'?", filename_fn()) + gettext!("remove regular empty file {}?", filename_fn()) } else { - gettext!("remove regular file '{}'?", filename_fn()) + gettext!("remove regular file {}?", filename_fn()) } } else if is_empty { gettext!( - "remove write-protected regular empty file '{}'?", + "remove write-protected regular empty file {}?", filename_fn() ) } else { - gettext!("remove write-protected regular file '{}'?", filename_fn()) + gettext!("remove write-protected regular file {}?", filename_fn()) } } // Directories are handled by the caller before reaching here; fall back to a generic // prompt rather than panicking if that ever changes. ftw::FileType::Directory => { - gettext!("remove directory '{}'?", filename_fn()) + gettext!("remove directory {}?", filename_fn()) } ftw::FileType::Unknown => { - gettext!("remove '{}'?", filename_fn()) + gettext!("remove {}?", filename_fn()) } }; @@ -297,14 +291,14 @@ fn process_directory(cfg: &RmConfig, entry: &ftw::Entry) -> io::Result io::Result io::Result { if !cfg.args.recurse { let err_str = gettext!( - "cannot remove '{}': Is a directory", + "cannot remove {}: Is a directory", display_cleaned(filepath) ); return Err(io::Error::other(err_str)); @@ -346,7 +340,7 @@ fn rm_directory(cfg: &RmConfig, filepath: &Path) -> io::Result { // the removal is refused rather than walked unguarded. if cfg.root_identity.is_none() { let err_str = gettext!( - "cannot remove '{}': the root directory could not be identified", + "cannot remove {}: the root directory could not be identified", display_cleaned(filepath) ); return Err(io::Error::other(err_str)); @@ -455,16 +449,16 @@ fn remove_walked(cfg: &RmConfig, entry: &ftw::Entry) -> Result { eprintln!( "rm: {}", gettext!( - "cannot remove '{}': {}", - entry.path().clean_trailing_slashes(), + "cannot remove {}: {}", + entry_shown(entry), error_string(&io::Error::from_raw_os_error(libc::ENOTDIR)) ) ); return Err(()); } if is_root_directory(cfg, md) { - let shown = entry.path().clean_trailing_slashes(); - eprintln!("rm: {}", dangerous_root_message(&shown)); + let path = entry.path(); + eprintln!("rm: {}", dangerous_root_message(cleaned_bytes(&path))); return Err(()); } match process_directory(cfg, entry) { @@ -515,15 +509,11 @@ fn remove_left_directory( // one was kept at its prompt. let not_empty = matches!(e.raw_os_error(), Some(libc::ENOTEMPTY) | Some(libc::EEXIST)); let err_str = if not_empty { - gettext!( - "cannot remove '{}': {}", - entry.path().clean_trailing_slashes(), - error_string(&e) - ) + gettext!("cannot remove {}: {}", entry_shown(entry), error_string(&e)) } else { gettext!( - "cannot remove directory '{}': {}", - entry.path().clean_trailing_slashes(), + "cannot remove directory {}: {}", + entry_shown(entry), error_string(&e) ) }; @@ -544,8 +534,8 @@ fn report_walk_error(entry: &ftw::Entry, kind: ftw::ErrorKind, error: &io::Error eprintln!( "rm: {}", gettext!( - "cannot access directory '{}': {}", - entry.path().clean_trailing_slashes(), + "cannot access directory {}: {}", + entry_shown(entry), error_string(error) ) ); @@ -553,18 +543,15 @@ fn report_walk_error(entry: &ftw::Entry, kind: ftw::ErrorKind, error: &io::Error ftw::ErrorKind::ReadDir => { eprintln!( "rm: {}", - gettext!( - "error accessing directory entry: {}", - entry.path().clean_trailing_slashes(), - ) + gettext!("error accessing directory entry: {}", entry_shown(entry),) ); } ftw::ErrorKind::Stat | ftw::ErrorKind::Cycle => { eprintln!( "rm: {}", gettext!( - "cannot remove '{}': {}", - entry.path().clean_trailing_slashes(), + "cannot remove {}: {}", + entry_shown(entry), error_string(error) ) ); @@ -573,8 +560,8 @@ fn report_walk_error(entry: &ftw::Entry, kind: ftw::ErrorKind, error: &io::Error eprintln!( "rm: {}", gettext!( - "cannot remove '{}': {}", - entry.path().clean_trailing_slashes(), + "cannot remove {}: {}", + entry_shown(entry), error_string(error) ) ); @@ -584,8 +571,8 @@ fn report_walk_error(entry: &ftw::Entry, kind: ftw::ErrorKind, error: &io::Error eprintln!( "rm: {}", gettext!( - "cannot read symbolic link '{}': {}", - entry.path().clean_trailing_slashes(), + "cannot read symbolic link {}: {}", + entry_shown(entry), error_string(error) ) ); @@ -667,7 +654,7 @@ fn remove_nondir_at( if is_already_gone(cfg, &e) { return Ok(()); } - let err_str = gettext!("cannot remove '{}': {}", shown(), error_string(&e)); + let err_str = gettext!("cannot remove {}: {}", shown(), error_string(&e)); return Err(io::Error::other(err_str)); } report_removed(cfg, false, path); @@ -687,10 +674,10 @@ fn rm_dir_empty(cfg: &RmConfig, filepath: &Path) -> io::Result { let writable = ftw::is_writable_at(libc::AT_FDCWD, &filename_cstr); if ask_for_prompt(cfg, writable) { let prompt = if writable { - gettext!("remove directory '{}'?", display_cleaned(filepath)) + gettext!("remove directory {}?", display_cleaned(filepath)) } else { gettext!( - "remove write-protected directory '{}'?", + "remove write-protected directory {}?", display_cleaned(filepath) ) }; @@ -704,7 +691,7 @@ fn rm_dir_empty(cfg: &RmConfig, filepath: &Path) -> io::Result { Err(e) if is_already_gone(cfg, &e) => (), Err(e) => { let err_str = gettext!( - "cannot remove '{}': {}", + "cannot remove {}: {}", display_cleaned(filepath), error_string(&e) ); @@ -724,7 +711,7 @@ fn rm_path(cfg: &RmConfig, filepath: &Path) -> io::Result { return Ok(true); } else { let err_str = gettext!( - "cannot remove '{}': {}", + "cannot remove {}: {}", display_cleaned(filepath), error_string(&e) ); @@ -772,7 +759,7 @@ fn main() -> Result<(), Box> { let mut exit_code = 0; for filepath in &cfg.args.files { - match rm_path(&cfg, filepath) { + match rm_path(&cfg, Path::new(filepath)) { Ok(success) => { if !success { exit_code = 1; diff --git a/tree/tests/rm/mod.rs b/tree/tests/rm/mod.rs index 761dc347e..9cf46b667 100644 --- a/tree/tests/rm/mod.rs +++ b/tree/tests/rm/mod.rs @@ -1548,3 +1548,79 @@ fn test_rm_v_quotes_names() { ); assert_eq!(rm_in(&["-dv", "e\nf"]), ["removed directory 'e'$'\\n''f'"]); } + +/// Operands that are not valid UTF-8 are file names like any other, and every diagnostic shows +/// a name's bytes quoted as GNU coreutils 9.4 does, not a lossy rendering of them. +#[test] +fn test_rm_non_utf8_operands_and_quoted_diagnostics() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let tmp = plib::tmp::tempdir().unwrap(); + let dir = tmp.path(); + let rm_in = |args: &[&OsStr]| { + let output = Command::new(env!("CARGO_BIN_EXE_rm")) + .args(args) + .current_dir(dir) + .stdin(Stdio::null()) + .output() + .unwrap(); + ( + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(output.stderr).unwrap(), + output.status.code().unwrap_or(-1), + ) + }; + let os = |s: &'static str| OsStr::new(s); + + // A quote in a name is shown the way the shell would read it back. + assert_eq!( + rm_in(&[os("it's")]), + ( + String::new(), + "rm: cannot remove \"it's\": No such file or directory\n".into(), + 1 + ) + ); + + let Some(file) = + plib::testing::create_non_utf8(dir, b"x\xffy", |p| fs::File::create(p).map(drop)) + else { + return; + }; + let name = OsStr::from_bytes(b"x\xffy"); + assert_eq!( + rm_in(&[os("-v"), name]), + (String::from("removed 'x'$'\\377''y'\n"), String::new(), 0) + ); + assert!(!file.exists()); + assert_eq!( + rm_in(&[name]), + ( + String::new(), + "rm: cannot remove 'x'$'\\377''y': No such file or directory\n".into(), + 1 + ) + ); + + fs::create_dir(&file).unwrap(); + assert_eq!( + rm_in(&[name]), + ( + String::new(), + "rm: cannot remove 'x'$'\\377''y': Is a directory\n".into(), + 1 + ) + ); + let dot = OsStr::from_bytes(b"x\xffy/."); + assert_eq!( + rm_in(&[os("-r"), dot]), + ( + String::new(), + "rm: refusing to remove '.' or '..' directory: skipping 'x'$'\\377''y/.'\n".into(), + 1 + ) + ); + assert_eq!(rm_in(&[os("-d"), name]), (String::new(), String::new(), 0)); + assert!(!file.exists()); +} From 34bbb3c70d56e649c5b5b4cc5de564131a07f9a2 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 19:14:27 +0000 Subject: [PATCH 09/71] plib: check a made directory's emptiness with ftw::is_empty_dir_fd, deleting madefs's copy plib::madefs and ftw each had their own is_empty_dir_fd plus a private lists_nothing: the same openat of `.` relative to the descriptor, then the same readdir loop with errno reset before each call and any errno but 0 an error. Two copies of one rule. plib now depends on ftw (Unix only, as madefs is; the Windows build of plib is unchanged), and madefs's verify_made_dir calls ftw::is_empty_dir_fd. madefs's copy and its lists_nothing are deleted. ftw does not depend on plib (only its tests do), so the dependency goes this way: ftw stays small, with libc and errno only. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + ftw/src/lib.rs | 3 ++- plib/Cargo.toml | 5 +++++ plib/src/madefs.rs | 42 +----------------------------------------- 4 files changed, 9 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca13a5c7c..fcc166b5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -862,6 +862,7 @@ dependencies = [ "chrono", "clap", "errno", + "ftw", "gettext-rs", "libc", ] diff --git a/ftw/src/lib.rs b/ftw/src/lib.rs index 595140165..97112e9df 100644 --- a/ftw/src/lib.rs +++ b/ftw/src/lib.rs @@ -1646,7 +1646,8 @@ fn lists_nothing(dir: OwnedDir) -> io::Result { /// Whether the directory open on `dir_fd` is empty. /// /// It is read through a new open of `.` relative to `dir_fd` -- the same directory, which no -/// rename can swap -- so `dir_fd`'s own read position is left alone. +/// rename can swap -- so `dir_fd` itself (its read position, or a descriptor held for search +/// only) is left alone. `plib::madefs` checks a directory it made with this too. pub fn is_empty_dir_fd(dir_fd: RawFd) -> io::Result { let fd = unsafe { libc::openat( diff --git a/plib/Cargo.toml b/plib/Cargo.toml index 1325d47c4..67d1588af 100644 --- a/plib/Cargo.toml +++ b/plib/Cargo.toml @@ -18,6 +18,11 @@ chrono.workspace = true # Reads which options take an argument from a command's definition (optarg.rs). clap.workspace = true +# Race-free directory access shared with the tree walk: madefs's emptiness check +# (madefs.rs). ftw is Unix-only, as madefs is. +[target.'cfg(unix)'.dependencies] +ftw = { path = "../ftw" } + # Compiles the vendored musl regex for a Windows target (see build.rs). Not # under [target.'cfg(windows)'.build-dependencies]: Cargo matches that against # the host, so a Linux host cross-building for Windows would not get it. diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index f5f50d13a..e9e39cda7 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -311,53 +311,13 @@ pub fn verify_made_dir(parent_fd: RawFd, dir_fd: RawFd) -> io::Result io::Result { - let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC; - let fd = unsafe { libc::openat(dir_fd, c".".as_ptr(), flags) }; - if fd < 0 { - return Err(io::Error::last_os_error()); - } - let dir = unsafe { libc::fdopendir(fd) }; - if dir.is_null() { - let e = io::Error::last_os_error(); - unsafe { libc::close(fd) }; - return Err(e); - } - let empty = lists_nothing(dir); - unsafe { libc::closedir(dir) }; - empty -} - -/// Whether the open directory stream `dir` holds nothing but `.` and `..`. -fn lists_nothing(dir: *mut libc::DIR) -> io::Result { - loop { - errno::set_errno(errno::Errno(0)); - let entry = unsafe { libc::readdir(dir) }; - if entry.is_null() { - let e = io::Error::last_os_error(); - return match e.raw_os_error() { - Some(0) | None => Ok(true), - Some(_) => Err(e), - }; - } - let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); - if name != b"." && name != b".." { - return Ok(false); - } - } -} - /// `check` -- whether the directory open on `dir_fd`, with `st`, is empty -- and, only if it /// fails with EACCES on a directory the effective user `euid` owns, again with the owner's read /// and search permission lent through the descriptor, the mode put back afterwards. From f03f9dab416bd2e058df44127ac1c269c88a47f2 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 19:52:28 +0000 Subject: [PATCH 10/71] pax: store an owner or link count too wide for odc or binary cpio as the largest value that fits A uid, gid or link count wider than an odc field (18 bits) or a binary one (16 bits) dropped the file from the archive. It is now archived with the largest value the field holds -- 262143 for odc, 65535 for binary -- and the first substitute each field takes is reported once per run, as an error, so the run exits 1. GNU cpio 2.15 masks instead, silently and with exit 0: measured under fakeroot, uid 300000 is stored as 37856 by -H odc and -H bin, and uid 70000 as 4464 by -H bin; a mask can land on root. The largest value that fits never does, so it is used rather than GNU's choice. A substitute owner is not the file's owner, so the set-user-ID or set-group-ID bit tied to a substituted id is cleared, rather than handing its privilege to whoever holds the substitute id. newc has no such case: its 32-bit fields hold any uid_t, gid_t and the link count an entry carries. Co-Authored-By: Claude Opus 5.5 --- pax/formats/cpio.rs | 231 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 190 insertions(+), 41 deletions(-) diff --git a/pax/formats/cpio.rs b/pax/formats/cpio.rs index 950a90b72..f737e9fd3 100644 --- a/pax/formats/cpio.rs +++ b/pax/formats/cpio.rs @@ -150,6 +150,10 @@ const C_ISCHR: u32 = 0o020000; const C_ISFIFO: u32 = 0o010000; const C_ISSOCK: u32 = 0o140000; +// c_mode set-ID bits +const C_ISUID: u32 = 0o004000; +const C_ISGID: u32 = 0o002000; + // c_mode permission mask const C_PERM_MASK: u32 = 0o7777; @@ -313,6 +317,9 @@ pub struct CpioWriter { linked_ino: u64, /// c_ino given to each multiply-linked file, by its (dev, ino) on disk link_inos: HashMap<(u64, u64), u64>, + /// The fields a substitute value has been stored in and reported for + /// (`Substituted`): each is reported once per run. + substituted: Vec<&'static str>, } impl CpioWriter { @@ -326,6 +333,27 @@ impl CpioWriter { single_ino: 0, linked_ino: format.max_ino() + 1, link_inos: HashMap::new(), + substituted: Vec::new(), + } + } + + /// Report the substitutes stored for `entry`, the first time each field + /// takes one. Reported as an error, so that the run's exit status says + /// the archive does not hold the file's real owner or link count. + fn report_substitutes(&mut self, entry: &ArchiveEntry, substitutes: &[Substituted]) { + for sub in substitutes { + if self.substituted.contains(&sub.field) { + continue; + } + self.substituted.push(sub.field); + crate::error::report_error( + &entry.path, + format!( + "value {} too large for the {} field of the cpio {} format; \ + stored as {}, here and for every later member that needs it", + sub.value, sub.field, sub.format, sub.stored + ), + ); } } @@ -415,13 +443,15 @@ impl ArchiveWriter for CpioWriter { ))); } + let mut substitutes = Vec::new(); let header = match self.format { - CpioFormat::Odc => build_odc_header(entry, ids, namesize)?, + CpioFormat::Odc => build_odc_header(entry, ids, namesize, &mut substitutes)?, CpioFormat::Newc | CpioFormat::NewcCrc => { build_newc_header(entry, ids, namesize, self.format)? } - CpioFormat::Binary => build_bin_header(entry, ids, namesize)?, + CpioFormat::Binary => build_bin_header(entry, ids, namesize, &mut substitutes)?, }; + self.report_substitutes(entry, &substitutes); self.writer.write_all(&header)?; self.writer.write_all(name)?; self.writer.write_all(&[0])?; @@ -881,12 +911,17 @@ fn header_nlink(entry: &ArchiveEntry) -> u64 { } /// Build a cpio ODC (POSIX octet-oriented) header. `ids` is the (c_ino, -/// c_nlink) the writer assigned. +/// c_nlink) the writer assigned; an owner or link count too wide for its field +/// is stored as a substitute and recorded in `substitutes` (`OwnerFields`). fn build_odc_header( entry: &ArchiveEntry, (ino, nlink): (u64, u64), namesize: usize, + substitutes: &mut Vec, ) -> PaxResult> { + // Six octal digits: 18 bits. + let owner = OwnerFields::fit(entry, nlink, 0o777777, "odc", substitutes); + let mut header = Vec::with_capacity(ODC_HEADER_SIZE); // c_magic @@ -899,16 +934,12 @@ fn build_odc_header( write_octal_field_masked(&mut header, ino, 6); // c_mode (file type + permissions) - let mode = build_mode(entry); - write_octal_field(&mut header, mode as u64, 6, "c_mode")?; - - // c_uid and c_gid: six digits, 18 bits. A wider id is refused, not - // masked onto some other owner. - write_octal_field(&mut header, entry.uid as u64, 6, "c_uid")?; - write_octal_field(&mut header, entry.gid as u64, 6, "c_gid")?; + write_octal_field(&mut header, owner.mode as u64, 6, "c_mode")?; - // c_nlink - write_octal_field(&mut header, nlink, 6, "c_nlink")?; + // c_uid, c_gid and c_nlink, each within its field (`OwnerFields::fit`). + write_octal_field(&mut header, owner.uid, 6, "c_uid")?; + write_octal_field(&mut header, owner.gid, 6, "c_gid")?; + write_octal_field(&mut header, owner.nlink, 6, "c_nlink")?; // c_rdev (device major/minor for block/char devices) write_octal_field(&mut header, packed_rdev(entry)?, 6, "c_rdev")?; @@ -990,7 +1021,9 @@ fn build_bin_header( entry: &ArchiveEntry, (ino, nlink): (u64, u64), namesize: usize, + substitutes: &mut Vec, ) -> PaxResult> { + let owner = OwnerFields::fit(entry, nlink, u16::MAX.into(), "binary", substitutes); let mut header = Vec::with_capacity(BIN_HEADER_SIZE); let mut push_u16 = |val: u64| header.extend_from_slice(&(val as u16).to_ne_bytes()); @@ -998,12 +1031,10 @@ fn build_bin_header( push_u16(BIN_MAGIC as u64); push_u16(entry.dev & 0xffff); push_u16(ino & 0xffff); - push_u16(build_mode(entry) as u64 & 0xffff); - // A wider owner or link count is refused, not masked: uid 65536 masked - // to 0 archived a setuid file as root's. - push_u16(fit_bin(entry.uid as u64, u16::MAX, "c_uid")?); - push_u16(fit_bin(entry.gid as u64, u16::MAX, "c_gid")?); - push_u16(fit_bin(nlink, u16::MAX, "c_nlink")?); + push_u16(owner.mode as u64 & 0xffff); + push_u16(owner.uid); + push_u16(owner.gid); + push_u16(owner.nlink); push_u16(packed_rdev(entry)?); let mtime = fit_bin(entry.unsigned_mtime()?, u32::MAX, "c_mtime")?; @@ -1025,6 +1056,71 @@ fn build_bin_header( Ok(header) } +/// A value too large for its header field, and the substitute stored in its +/// place. +#[derive(Debug, PartialEq, Eq)] +struct Substituted { + field: &'static str, + format: &'static str, + value: u64, + stored: u64, +} + +/// c_mode, c_uid, c_gid and c_nlink as a header whose fields hold at most +/// `max` stores them. +/// +/// An owner or link count too wide for the field is stored as `max`, the +/// largest value that fits, and the file is archived; masking it, as GNU cpio +/// does, would keep only the low bits, and uid 65536 masked to 16 bits +/// archives the file as root's. A substitute owner is not the file's owner, +/// so the set-user-ID or set-group-ID bit that would hand its privilege to +/// that owner is cleared. +struct OwnerFields { + mode: u32, + uid: u64, + gid: u64, + nlink: u64, +} + +impl OwnerFields { + fn fit( + entry: &ArchiveEntry, + nlink: u64, + max: u64, + format: &'static str, + substitutes: &mut Vec, + ) -> Self { + let mut fit = |value: u64, field: &'static str| { + if value <= max { + return (value, false); + } + substitutes.push(Substituted { + field, + format, + value, + stored: max, + }); + (max, true) + }; + let (uid, uid_substituted) = fit(entry.uid as u64, "c_uid"); + let (gid, gid_substituted) = fit(entry.gid as u64, "c_gid"); + let (nlink, _) = fit(nlink, "c_nlink"); + let mut mode = build_mode(entry); + if uid_substituted { + mode &= !C_ISUID; + } + if gid_substituted { + mode &= !C_ISGID; + } + OwnerFields { + mode, + uid, + gid, + nlink, + } + } +} + /// A binary header value that must fit its field whole: one or two 16-bit /// words, so `max` is `u16::MAX` or `u32::MAX`. fn fit_bin(val: u64, max: impl Into, field: &str) -> PaxResult { @@ -1273,7 +1369,7 @@ mod tests { ..Default::default() }; - let header = build_bin_header(&entry, (3, 1), 6).unwrap(); + let header = build_bin_header(&entry, (3, 1), 6, &mut Vec::new()).unwrap(); assert_eq!(header.len(), BIN_HEADER_SIZE); let word = |i: usize| u16::from_ne_bytes([header[i * 2], header[i * 2 + 1]]); assert_eq!(word(0), BIN_MAGIC); @@ -1292,7 +1388,7 @@ mod tests { size: u32::MAX as u64 + 1, ..entry }; - assert!(build_bin_header(&too_big, (3, 1), 6).is_err()); + assert!(build_bin_header(&too_big, (3, 1), 6, &mut Vec::new()).is_err()); } #[test] @@ -1391,42 +1487,95 @@ mod tests { ..Default::default() }; for (major, minor) in [(300, 1), (1, 300)] { - assert!(build_odc_header(&device(major, minor), (1, 1), 4).is_err()); - assert!(build_bin_header(&device(major, minor), (1, 1), 4).is_err()); + assert!(build_odc_header(&device(major, minor), (1, 1), 4, &mut Vec::new()).is_err()); + assert!(build_bin_header(&device(major, minor), (1, 1), 4, &mut Vec::new()).is_err()); } - assert!(build_odc_header(&device(255, 255), (1, 1), 4).is_ok()); - assert!(build_bin_header(&device(255, 255), (1, 1), 4).is_ok()); + assert!(build_odc_header(&device(255, 255), (1, 1), 4, &mut Vec::new()).is_ok()); + assert!(build_bin_header(&device(255, 255), (1, 1), 4, &mut Vec::new()).is_ok()); // newc has a field for each, and stores them whole. assert!(build_newc_header(&device(300, 300), (1, 1), 4, CpioFormat::Newc).is_ok()); } - /// An odc c_uid holds 18 bits and a binary one 16; a wider owner used to - /// be masked, so a setuid file of uid 262144 (odc) or 65536 (binary) was - /// archived as root's. It is refused, as a wide device number is. So is - /// a link count the binary format cannot hold, which odc already refused. + /// An odc c_uid holds 18 bits and a binary one 16. A wider owner, or a + /// link count the field cannot hold, used to drop the file; masking it + /// instead, as GNU cpio does, archived uid 65536 as root. It is stored as + /// the largest value that fits, the substitute is recorded for the writer + /// to report, and a set-ID bit for a substituted id is cleared. #[test] - fn test_wide_owner_ids_are_refused_by_odc_and_binary() { + fn test_wide_owner_ids_are_stored_as_the_largest_that_fits() { let owned = |uid, gid| ArchiveEntry { path: PathBuf::from("f"), - mode: 0o4755, + mode: 0o6755, entry_type: EntryType::Regular, uid, gid, ..Default::default() }; - for (uid, gid) in [(1 << 18, 0), (0, 1 << 18)] { - assert!(build_odc_header(&owned(uid, gid), (1, 1), 2).is_err()); + let odc_field = |h: &[u8], at: usize| { + u64::from_str_radix(std::str::from_utf8(&h[at..at + 6]).unwrap(), 8).unwrap() + }; + // odc: c_mode at 18, c_uid at 24, c_gid at 30, c_nlink at 36. + let mut subs = Vec::new(); + let h = build_odc_header(&owned(300_000, 7), (1, 1 << 18), 2, &mut subs).unwrap(); + assert_eq!(odc_field(&h, 24), 0o777777); + assert_eq!(odc_field(&h, 30), 7); + assert_eq!(odc_field(&h, 36), 0o777777); + assert_eq!(odc_field(&h, 18) as u32, C_ISREG | 0o2755); + let fields: Vec<_> = subs.iter().map(|s| (s.field, s.value, s.stored)).collect(); + assert_eq!( + fields, + [("c_uid", 300_000, 0o777777), ("c_nlink", 1 << 18, 0o777777)] + ); + + // An id that fits is stored as it is, with nothing recorded. + let mut subs = Vec::new(); + let max = (1 << 18) - 1; + let h = build_odc_header(&owned(max, max), (1, 1), 2, &mut subs).unwrap(); + assert_eq!( + (odc_field(&h, 24), odc_field(&h, 30)), + (max as u64, max as u64) + ); + assert_eq!(odc_field(&h, 18) as u32, C_ISREG | 0o6755); + assert!(subs.is_empty()); + + // binary: c_mode is word 3, c_uid 4, c_gid 5, c_nlink 6. + let mut subs = Vec::new(); + let h = build_bin_header(&owned(5, 1 << 16), (1, 1 << 16), 2, &mut subs).unwrap(); + let word = |i: usize| u16::from_ne_bytes([h[i * 2], h[i * 2 + 1]]); + assert_eq!((word(4), word(5), word(6)), (5, u16::MAX, u16::MAX)); + assert_eq!(u32::from(word(3)), C_ISREG | 0o4755); + let fields: Vec<_> = subs.iter().map(|s| (s.field, s.format)).collect(); + assert_eq!(fields, [("c_gid", "binary"), ("c_nlink", "binary")]); + + let mut subs = Vec::new(); + assert!(build_bin_header(&owned(0xffff, 0xffff), (1, 1), 2, &mut subs).is_ok()); + assert!(subs.is_empty()); + + // newc's fields are 32 bits, as wide as a uid_t, a gid_t and the + // link count an entry carries: nothing needs a substitute there. + let wide = owned(u32::MAX, u32::MAX); + let h = build_newc_header(&wide, (1, u32::MAX.into()), 2, CpioFormat::Newc).unwrap(); + assert_eq!(&h[6 + 16..6 + 40], b"FFFFFFFFFFFFFFFFFFFFFFFF"); + } + + /// The writer reports each field's first substitute once, as an error, + /// and archives every member. + #[test] + fn test_writer_reports_each_substituted_field_once() { + let mut out = Vec::new(); + let mut w = CpioWriter::with_format(&mut out, CpioFormat::Odc); + for (name, uid) in [("a", 300_000), ("b", 300_001), ("c", 0)] { + let mut e = ArchiveEntry::new(PathBuf::from(name), EntryType::Regular); + e.uid = uid; + w.write_entry(&e).unwrap(); + w.finish_entry().unwrap(); } - assert!(build_odc_header(&owned((1 << 18) - 1, (1 << 18) - 1), (1, 1), 2).is_ok()); - for (uid, gid) in [(1 << 16, 0), (0, 1 << 16)] { - assert!(build_bin_header(&owned(uid, gid), (1, 1), 2).is_err()); + assert_eq!(w.substituted, ["c_uid"]); + w.finish().unwrap(); + let names = [&b"a\0"[..], b"b\0", b"c\0"]; + for name in names { + assert!(out.windows(2).any(|win| win == name)); } - assert!(build_bin_header(&owned(0xffff, 0xffff), (1, 1), 2).is_ok()); - assert!(build_bin_header(&owned(0, 0), (1, 1 << 16), 2).is_err()); - assert!(build_odc_header(&owned(0, 0), (1, 1 << 18), 2).is_err()); - // newc's fields are 32 bits, as wide as a uid_t. - let wide = owned(u32::MAX, u32::MAX); - assert!(build_newc_header(&wide, (1, 1), 2, CpioFormat::Newc).is_ok()); } #[test] From b725e50e8807b5e7d0e4f9952109ea2db3e09ee3 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 19:54:35 +0000 Subject: [PATCH 11/71] mv: treat a name of slashes only as the root directory in names_dot_or_dotdot The doc comment listed `/` among the names whose last component is `.` or `..`. That held only because the pin stores `/` as `.` in the root directory: an operand left reached by its pathname (Anchor::Path) keeps the whole name, and `/` there was not matched. A name of slashes only now counts as the root directory however it is held, the comment says so, and a unit test pins the matching names, the non-matching ones, and real pins of `/`, `//`, a directory and its `/.`. Co-Authored-By: Claude Opus 5.5 --- tree/common/pinned.rs | 47 +++++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/tree/common/pinned.rs b/tree/common/pinned.rs index 839d04f54..ac2b4d693 100644 --- a/tree/common/pinned.rs +++ b/tree/common/pinned.rs @@ -91,17 +91,12 @@ impl PinnedEntry { &self.display_parent } - /// Whether the last component, trailing slashes aside, is `.` or `..` (as for `D/.`, `D/..`, - /// `link/./` or `/`): a name for a directory that is not its entry in a parent, so that - /// nothing can be renamed or removed by it. + /// Whether the last component, trailing slashes aside, is `.` or `..` (as for `D/.`, `D/..` + /// or `link/./`), or the name is the root directory (`/`, pinned as `.` in it, or kept whole + /// when reached by pathname): a name for a directory that is not its entry in a parent, so + /// that nothing can be renamed or removed by it. pub fn names_dot_or_dotdot(&self) -> bool { - let name = self.name.to_bytes(); - let end = name - .iter() - .rposition(|&b| b != b'/') - .map_or(0, |last| last + 1); - let last = name[..end].rsplit(|&b| b == b'/').next().unwrap_or(b""); - matches!(last, b"." | b"..") + names_dot_or_dotdot(self.name.to_bytes()) } /// `fstatat` of the entry in its pinned directory. @@ -193,6 +188,15 @@ impl PinnedDir { } } +/// `PinnedEntry::names_dot_or_dotdot` for the name `name`. +fn names_dot_or_dotdot(name: &[u8]) -> bool { + let Some(last) = name.iter().rposition(|&b| b != b'/') else { + return !name.is_empty(); + }; + let component = name[..=last].rsplit(|&b| b == b'/').next().unwrap_or(b""); + matches!(component, b"." | b"..") +} + /// `path` split before its last component: the part before it (`None` when there is none) and /// the component itself, trailing slashes included. A path of slashes only is the root /// directory's `.`, and an empty path is an empty name in the current directory -- both looked @@ -300,7 +304,28 @@ impl CopiedSources { #[cfg(test)] mod tests { - use super::{split_last_component, Anchor, PinnedDir, PinnedDirs}; + use super::{names_dot_or_dotdot, split_last_component, Anchor, PinnedDir, PinnedDirs}; + + /// `.` and `..` as the last component, and the root directory however it is spelled or + /// reached, name no entry in a parent; any other name does. + #[test] + fn dot_dotdot_and_root_name_no_entry() { + for name in [ + ".", "..", "./", "D/.", "D/..", "D//./", "link/./", "/", "//", "/.", + ] { + assert!(names_dot_or_dotdot(name.as_bytes()), "{name:?}"); + } + for name in ["", "D", "D/", "/D", ".D", "D.", "...", "D/.x"] { + assert!(!names_dot_or_dotdot(name.as_bytes()), "{name:?}"); + } + let pinned = |p: &str| PinnedDirs::default().pin(p.as_ref()).unwrap(); + for operand in ["/", "//"] { + assert!(pinned(operand).names_dot_or_dotdot(), "{operand:?}"); + } + let tmp = plib::tmp::tempdir().unwrap(); + assert!(!pinned(tmp.path().to_str().unwrap()).names_dot_or_dotdot()); + assert!(pinned(&format!("{}/.", tmp.path().display())).names_dot_or_dotdot()); + } /// A directory that may not be opened leaves the operand reached by its pathname, for a /// rename to report on as it always did, instead of failing the pin. From 9f00c208d40c5b27296d311b1992fbd3f8337546 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 19:57:10 +0000 Subject: [PATCH 12/71] pax: settle a cpio link set of empty files, closing its pin and letting -n stop A link set whose names were all empty never counted as having its data, because none ever came. Extraction kept the descriptor pinning its file open for the rest of the run -- one per set, so an archive with more such sets than the descriptor limit failed every member past it with EMFILE -- and -n read the whole remaining archive, waiting for that data. A set now waits for data only while it can still come: none has come, its format stores the data with the last name only (newc and crc; odc and the old binary format store it with every name, as GNU cpio 2.15 measured does), and fewer than c_nlink of its names have been read. LinkSets counts every name read, extracted or not. The extract loop closes a set's pin as soon as it is settled, and -n stops once every set is. A newc set whose archive holds fewer names than its c_nlink, all empty, still waits to the end of the archive, pinned: nothing tells it from one whose data is still to come. Tests: 200 empty linked sets in odc and in newc extracted with a limit of 64 descriptors (before: EMFILE from e58a on); -n e1 e2 sel over an empty linked set and a member, followed by a damaged header that must not be read (before: "Invalid header: invalid hex", exit 1). Co-Authored-By: Claude Opus 5.5 --- pax/archive.rs | 89 ++++++++++++++++++++++++++++- pax/modes/read.rs | 33 +++++++---- pax/tests/cpio/mod.rs | 127 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 14 deletions(-) diff --git a/pax/archive.rs b/pax/archive.rs index 3eaa49080..66610cc8b 100644 --- a/pax/archive.rs +++ b/pax/archive.rs @@ -446,6 +446,37 @@ struct LinkSet { /// The (size, mode, mtime) of the first of its names to carry data. newc /// stores the data with the last name only, the earlier ones empty. data: Option<(u64, u32, i64)>, + /// Whether its format stores the data with the last name only + /// (`defers_link_data`). + defers_data: bool, + /// The c_nlink its first name recorded. + nlink: u32, + /// How many of its names have been read (`count_name`). + names: u32, +} + +impl LinkSet { + /// Whether data may still come on a later name: none has come yet, the + /// format stores it with the last name, and not every name has been read. + /// A file every name of which is empty is empty: in odc and the old + /// binary format each name carries the data, so the first one shows it, + /// and in newc the last one does. + fn awaits_data(&self) -> bool { + self.data.is_none() && self.defers_data && self.names < self.nlink + } +} + +/// Whether `entry`'s format stores a link set's data with its last name only, +/// the earlier ones empty: newc and crc. odc and the old binary format store it +/// with every name. +fn defers_link_data(entry: &ArchiveEntry) -> bool { + use crate::formats::cpio::CpioFormat; + matches!( + entry.source_header, + Some(SourceHeader::Cpio { + format: CpioFormat::Newc | CpioFormat::NewcCrc + }) + ) } /// The (size, mode, mtime) a member carrying data brings, if it brings any. @@ -482,9 +513,24 @@ impl LinkSets { Some(&mut set.value) } - /// What was remembered about every set started so far. - pub fn values(&self) -> impl Iterator { - self.sets.values().map(|set| &set.value) + /// Count `entry` as a name read of the set an earlier name started, if + /// it is one, whether or not it is extracted. + pub fn count_name(&mut self, entry: &ArchiveEntry) { + if let Some(set) = Self::key(entry).and_then(|key| self.sets.get_mut(&key)) { + set.names = set.names.saturating_add(1); + } + } + + /// Whether no set started so far awaits data on a later name. + pub fn all_settled(&self) -> bool { + !self.sets.values().any(LinkSet::awaits_data) + } + + /// What was remembered about the set `entry` is a name of, once no data + /// can still come for it on a later name. + pub fn settled_mut(&mut self, entry: &ArchiveEntry) -> Option<&mut T> { + let set = self.sets.get_mut(&Self::key(entry)?)?; + (!set.awaits_data()).then_some(&mut set.value) } /// Start a set at `entry`, its first name. Nothing happens for a member @@ -495,6 +541,9 @@ impl LinkSets { self.sets.entry(key).or_insert_with(|| LinkSet { value: value(), data: data_shape(entry), + defers_data: defers_link_data(entry), + nlink: entry.nlink, + names: 1, }); } } @@ -557,6 +606,40 @@ mod tests { } } + /// A set awaits data only while its format stores it with the last name, + /// none has come, and not every name has been read: an empty file's set + /// settles too. + #[test] + fn test_link_sets_settle_when_no_data_can_follow() { + use crate::formats::cpio::CpioFormat; + let in_format = |format, ino, size| ArchiveEntry { + source_header: Some(SourceHeader::Cpio { format }), + ..named(ino, 3, size) + }; + let mut sets: LinkSets<&str> = LinkSets::default(); + + // odc: every name carries the data, so an empty first name is final. + sets.insert(&in_format(CpioFormat::Odc, 1, 0), || "odc"); + assert_eq!(sets.settled_mut(&named(1, 3, 0)).copied(), Some("odc")); + + // newc: waits for its last name, or for one bringing data. + let newc = |ino, size| in_format(CpioFormat::Newc, ino, size); + sets.insert(&newc(2, 0), || "empty"); + sets.insert(&newc(3, 0), || "data"); + assert!(!sets.all_settled()); + for _ in 0..2 { + assert_eq!(sets.settled_mut(&newc(2, 0)), None); + sets.count_name(&newc(2, 0)); + sets.find_mut(&newc(2, 0)); + } + assert_eq!(sets.settled_mut(&newc(2, 0)).copied(), Some("empty")); + assert!(!sets.all_settled()); + sets.count_name(&newc(3, 4)); + sets.find_mut(&newc(3, 4)); + assert_eq!(sets.settled_mut(&newc(3, 0)).copied(), Some("data")); + assert!(sets.all_settled()); + } + /// Members sharing a key whose data differs are unrelated files; the /// newc set whose data arrives with its last name is still one file. #[test] diff --git a/pax/modes/read.rs b/pax/modes/read.rs index f16ef7741..1cca80e47 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -149,6 +149,7 @@ fn extract_members( if let Some(ref records) = option_records { records.apply(&mut entry); } + link_sets.count_name(&entry); if select_member(&mut selector, &mut entry, options, &mut prompter, tree)? { // Per POSIX CONSEQUENCES OF ERRORS: diagnose a per-file failure and // set a non-zero exit, but continue with the next member. Skip any @@ -162,9 +163,13 @@ fn extract_members( report_unless_fatal(&entry, r)?; } archive.skip_data()?; + // A set no data can still come for needs its file pinned no longer. + if let Some(set) = link_sets.settled_mut(&entry) { + set.unpin(); + } // A newc set's data comes with its last name, which -n must still // read even when every pattern has been used by an earlier one. - if selector.is_done() && link_sets.values().all(|set| set.has_data) { + if selector.is_done() && link_sets.all_settled() { reached_end = false; break; } @@ -531,16 +536,17 @@ struct CreatedSet { /// Whether that file has its contents yet. newc stores them with the last /// name of a set only, so the earlier names are created empty. has_data: bool, - /// The file held open until its data arrives. Its names can be replaced - /// by other members meanwhile, and a filesystem that reuses inode numbers - /// (ext4) then hands this file's number to the next file created, which - /// `file` would take for this one. Open, it keeps its number. - _pin: Option, + /// The file held open while data may still come for it on a later name + /// (`LinkSets::settled_mut`). Its names can be replaced by other members + /// meanwhile, and a filesystem that reuses inode numbers (ext4) then hands + /// this file's number to the next file created, which `file` would take + /// for this one. Open, it keeps its number. + pin: Option, } impl CreatedSet { - /// The set as created at `name` below `dirfd`: pinned while the data is - /// still to come on a later name. + /// The set as created at `name` below `dirfd`: pinned when created empty, + /// until the extract loop finds no data can still come for it (`unpin`). fn new( names: Vec, file: (u64, u64), @@ -548,15 +554,20 @@ impl CreatedSet { dirfd: BorrowedFd<'_>, name: &CStr, ) -> Self { - let _pin = (!has_data).then(|| pin_file(dirfd, name, file)).flatten(); + let pin = (!has_data).then(|| pin_file(dirfd, name, file)).flatten(); CreatedSet { names, file, has_data, - _pin, + pin, } } + /// Close the pin once no data can still come for the set. + fn unpin(&mut self) { + self.pin = None; + } + /// The names that still hold the set's file, each with the directory and /// leaf it was found at: an earlier name may since have been replaced by /// another member of the same name. @@ -682,7 +693,7 @@ fn fill_link_set( names, file, has_data: true, - _pin: None, + pin: None, }; Ok(()) } diff --git a/pax/tests/cpio/mod.rs b/pax/tests/cpio/mod.rs index 57e1ef4c1..3db5bdba6 100644 --- a/pax/tests/cpio/mod.rs +++ b/pax/tests/cpio/mod.rs @@ -801,3 +801,130 @@ fn test_cpio_newc_set_data_arrives_after_its_first_name_is_replaced() { file_id(&temp.path().join("c")) ); } + +/// An odc member: six-digit octal fields, the name, the body, no padding. +fn odc_member(name: &[u8], ino: u32, nlink: u32, body: &[u8]) -> Vec { + let mut out = b"070707".to_vec(); + for v in [0, ino, 0o100644, 0, 0, nlink, 0] { + out.extend_from_slice(format!("{v:06o}").as_bytes()); + } + out.extend_from_slice(format!("{:011o}", 0).as_bytes()); // c_mtime + out.extend_from_slice(format!("{:06o}", name.len() + 1).as_bytes()); + out.extend_from_slice(format!("{:011o}", body.len()).as_bytes()); + out.extend_from_slice(name); + out.push(0); + out.extend_from_slice(body); + out +} + +/// Run pax with `args` in `dir` allowed only `limit` open descriptors. +fn pax_with_fd_limit(args: &[&str], dir: &Path, limit: u64) -> std::process::Output { + use std::os::unix::process::CommandExt; + let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_pax")); + command.args(args).current_dir(dir); + // SAFETY: setrlimit is async-signal-safe. + unsafe { + command.pre_exec(move || { + let lim = libc::rlimit { + rlim_cur: limit as libc::rlim_t, + rlim_max: limit as libc::rlim_t, + }; + if libc::setrlimit(libc::RLIMIT_NOFILE, &lim) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + command.output().unwrap() +} + +/// A link set of empty files never gets data, and the descriptor pinning its +/// file until the data came was never closed: one per set, so an archive of +/// more sets than the descriptor limit failed every member past it. In odc +/// every name carries the data, in newc the last one; neither has any to +/// wait for here. +#[test] +fn test_cpio_empty_link_sets_do_not_hold_a_descriptor_each() { + const SETS: u32 = 200; + for format in ["odc", "newc"] { + let mut archive = Vec::new(); + for i in 0..SETS { + for name in [format!("e{i}a"), format!("e{i}b")] { + let member = match format { + "odc" => odc_member(name.as_bytes(), i + 1, 2, b""), + _ => CpioNewc { + name: name.as_bytes(), + ino: i + 1, + nlink: 2, + ..Default::default() + } + .member(), + }; + archive.extend_from_slice(&member); + } + } + archive.extend_from_slice(&odc_member(b"TRAILER!!!", 0, 1, b"")); + if format == "newc" { + archive.truncate(archive.len() - odc_member(b"TRAILER!!!", 0, 1, b"").len()); + archive.extend_from_slice( + &CpioNewc { + name: b"TRAILER!!!", + ..Default::default() + } + .member(), + ); + } + + let temp = TempDir::new().unwrap(); + let path = temp.path().join("archive.cpio"); + fs::write(&path, &archive).unwrap(); + let dest = temp.path().join("dest"); + fs::create_dir(&dest).unwrap(); + let out = pax_with_fd_limit(&["-r", "-f", path.to_str().unwrap()], &dest, 64); + assert_success(&out, &format!("pax -r of {SETS} empty {format} link sets")); + for i in 0..SETS { + let a = dest.join(format!("e{i}a")); + let b = dest.join(format!("e{i}b")); + assert_eq!(fs::read(&b).unwrap(), b"", "{format} e{i}b"); + assert_eq!(file_id(&a), file_id(&b), "{format} set {i} is linked"); + } + } +} + +/// -n stops reading once every pattern is used and no link set still waits +/// for data on a later name. An empty set waited for ever, so pax read the +/// rest of the archive: here, a damaged header it must never reach. +#[test] +fn test_cpio_first_match_stops_after_an_empty_link_set() { + let link = |name| CpioNewc { + name, + ino: 5, + nlink: 2, + ..Default::default() + }; + let mut archive = link(b"e1").member(); + archive.extend_from_slice(&link(b"e2").member()); + archive.extend_from_slice( + &CpioNewc { + name: b"sel", + body: b"selected", + ino: 6, + ..Default::default() + } + .member(), + ); + // Not a header: reading it is an error. + archive.extend_from_slice(b"070701"); + archive.extend_from_slice(&[b'Z'; 200]); + + let temp = TempDir::new().unwrap(); + let path = temp.path().join("archive.cpio"); + fs::write(&path, &archive).unwrap(); + let dest = temp.path().join("dest"); + fs::create_dir(&dest).unwrap(); + let args = ["-r", "-n", "-f", path.to_str().unwrap(), "e1", "e2", "sel"]; + let out = crate::common::run_pax_in_dir(&args, &dest); + assert_success(&out, "pax -r -n e1 e2 sel"); + assert_eq!(fs::read(dest.join("sel")).unwrap(), b"selected"); + assert_eq!(file_id(&dest.join("e1")), file_id(&dest.join("e2"))); +} From bdf7242bf134da49757dd8fd48c4452e731de5ab Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 19:58:21 +0000 Subject: [PATCH 13/71] pax: store a uid or gid too wide for odc or binary cpio as the overflow id 65534 f03f9dab stored an owner too wide for its field as the largest value the field holds: 262143 for odc, 65535 for binary. That is still an arbitrary real id on many systems, and root extracting with -pe hands the file to whoever holds it. An owner is now stored as 65534, the Linux kernel's own substitute for an id that does not fit a narrow field: the default of /proc/sys/fs/overflowuid and overflowgid, which the 16-bit uid system calls report, and which NFS and user namespaces show for an id they cannot map. It is "nobody" on most systems and fits both formats. It is a constant rather than the host's setting, so that an archive does not depend on the host that wrote it. A link count too wide for its field is still stored as the largest value that fits: it only says the file has many names. The set-ID bit of a substituted owner is still cleared, and each field's first substitute is still reported once, with exit status 1. Co-Authored-By: Claude Opus 5.5 --- pax/formats/cpio.rs | 50 ++++++++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/pax/formats/cpio.rs b/pax/formats/cpio.rs index f737e9fd3..67e72862d 100644 --- a/pax/formats/cpio.rs +++ b/pax/formats/cpio.rs @@ -1066,15 +1066,26 @@ struct Substituted { stored: u64, } +/// The owner stored for a uid or gid too wide for its field: the Linux +/// kernel's own substitute for an id that does not fit a narrow field (the +/// default of `/proc/sys/fs/overflowuid` and `overflowgid`, which the 16-bit +/// uid system calls, NFS and user namespaces report), "nobody" on most +/// systems. It fits both odc and binary fields. A constant, not the host's +/// setting, so that the archive does not depend on the host that wrote it. +const OVERFLOW_ID: u64 = 65534; + /// c_mode, c_uid, c_gid and c_nlink as a header whose fields hold at most /// `max` stores them. /// -/// An owner or link count too wide for the field is stored as `max`, the -/// largest value that fits, and the file is archived; masking it, as GNU cpio -/// does, would keep only the low bits, and uid 65536 masked to 16 bits -/// archives the file as root's. A substitute owner is not the file's owner, -/// so the set-user-ID or set-group-ID bit that would hand its privilege to -/// that owner is cleared. +/// The file is archived even when an owner or link count is too wide for the +/// field. Masking the value, as GNU cpio does, would keep only its low bits, +/// and uid 65536 masked to 16 bits archives the file as root's. The largest +/// value that fits is no better for an owner: it is an arbitrary real id on +/// many systems, and root extracting with `-pe` would hand the file to +/// whoever holds it. An owner is stored as `OVERFLOW_ID` instead; a link count +/// as `max`, which only says the file has many names. A substitute owner is +/// not the file's owner either, so the set-user-ID or set-group-ID bit that +/// would hand its privilege to that owner is cleared. struct OwnerFields { mode: u32, uid: u64, @@ -1090,7 +1101,7 @@ impl OwnerFields { format: &'static str, substitutes: &mut Vec, ) -> Self { - let mut fit = |value: u64, field: &'static str| { + let mut fit = |value: u64, field: &'static str, stored: u64| { if value <= max { return (value, false); } @@ -1098,13 +1109,13 @@ impl OwnerFields { field, format, value, - stored: max, + stored, }); - (max, true) + (stored, true) }; - let (uid, uid_substituted) = fit(entry.uid as u64, "c_uid"); - let (gid, gid_substituted) = fit(entry.gid as u64, "c_gid"); - let (nlink, _) = fit(nlink, "c_nlink"); + let (uid, uid_substituted) = fit(entry.uid as u64, "c_uid", OVERFLOW_ID); + let (gid, gid_substituted) = fit(entry.gid as u64, "c_gid", OVERFLOW_ID); + let (nlink, _) = fit(nlink, "c_nlink", max); let mut mode = build_mode(entry); if uid_substituted { mode &= !C_ISUID; @@ -1498,11 +1509,12 @@ mod tests { /// An odc c_uid holds 18 bits and a binary one 16. A wider owner, or a /// link count the field cannot hold, used to drop the file; masking it - /// instead, as GNU cpio does, archived uid 65536 as root. It is stored as - /// the largest value that fits, the substitute is recorded for the writer - /// to report, and a set-ID bit for a substituted id is cleared. + /// instead, as GNU cpio does, archived uid 65536 as root. An owner is + /// stored as the kernel's overflow id, 65534, and a link count as the + /// largest value that fits; the substitute is recorded for the writer to + /// report, and a set-ID bit for a substituted id is cleared. #[test] - fn test_wide_owner_ids_are_stored_as_the_largest_that_fits() { + fn test_wide_owner_ids_are_stored_as_the_overflow_id() { let owned = |uid, gid| ArchiveEntry { path: PathBuf::from("f"), mode: 0o6755, @@ -1517,14 +1529,14 @@ mod tests { // odc: c_mode at 18, c_uid at 24, c_gid at 30, c_nlink at 36. let mut subs = Vec::new(); let h = build_odc_header(&owned(300_000, 7), (1, 1 << 18), 2, &mut subs).unwrap(); - assert_eq!(odc_field(&h, 24), 0o777777); + assert_eq!(odc_field(&h, 24), 65534); assert_eq!(odc_field(&h, 30), 7); assert_eq!(odc_field(&h, 36), 0o777777); assert_eq!(odc_field(&h, 18) as u32, C_ISREG | 0o2755); let fields: Vec<_> = subs.iter().map(|s| (s.field, s.value, s.stored)).collect(); assert_eq!( fields, - [("c_uid", 300_000, 0o777777), ("c_nlink", 1 << 18, 0o777777)] + [("c_uid", 300_000, 65534), ("c_nlink", 1 << 18, 0o777777)] ); // An id that fits is stored as it is, with nothing recorded. @@ -1542,7 +1554,7 @@ mod tests { let mut subs = Vec::new(); let h = build_bin_header(&owned(5, 1 << 16), (1, 1 << 16), 2, &mut subs).unwrap(); let word = |i: usize| u16::from_ne_bytes([h[i * 2], h[i * 2 + 1]]); - assert_eq!((word(4), word(5), word(6)), (5, u16::MAX, u16::MAX)); + assert_eq!((word(4), word(5), word(6)), (5, 65534, u16::MAX)); assert_eq!(u32::from(word(3)), C_ISREG | 0o4755); let fields: Vec<_> = subs.iter().map(|s| (s.field, s.format)).collect(); assert_eq!(fields, [("c_gid", "binary"), ("c_nlink", "binary")]); From 374dcaaedf28a001372f2b987eb07c2989aa7397 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 20:04:46 +0000 Subject: [PATCH 14/71] pax -rw: do not copy again what -s or -i put in a directory the walk is still reading A directory that maps onto itself under -s or -i is walked while its entries are copied into it under their new names. Its readdir then met those new files, and pax copied each onto itself: "file would overwrite itself", or under -l "Unable to link file to itself", exit 1. A directory of 2000 files renamed by -s ',^d/f,d/g,' met 504 of its own copies. The copy now records what it makes, with the existing dest_ids: - dest_ids says what each destination directory is: one that was there already (the destination operand, one entered, and now also one that maps onto itself), which met as a source is still "cannot copy directory into itself"; or one this copy made, which is its own output and is passed over. - Under -s or -i, each file made is recorded by the identity of the directory it was made in and its name, and the walk passes over a source found there. A name rather than the file's identity: under -l the file made is the source itself, and a name the source already had must still be copied. Without -s or -i every member lands at its source's own name, below a destination the walk never enters, so nothing is recorded or looked up. Test: d with 2000 files, a hard link and a subdirectory, copied with -rw and -rw -l and -s ',^d/f,d/g,' d . (before: "d/g1071: file would overwrite itself; not copied" and 503 more, exit 1). Co-Authored-By: Claude Opus 5.5 --- pax/modes/copy.rs | 118 +++++++++++++++++++++++++++++++++++------- pax/tests/copy/mod.rs | 46 ++++++++++++++++ 2 files changed, 144 insertions(+), 20 deletions(-) diff --git a/pax/modes/copy.rs b/pax/modes/copy.rs index 694dc7608..734e763fe 100644 --- a/pax/modes/copy.rs +++ b/pax/modes/copy.rs @@ -25,7 +25,7 @@ use crate::modes::followed_link; use crate::modes::write::FileNames; use crate::subst::{substitute_name, Substitution}; use std::cell::RefCell; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::ffi::{CStr, CString}; use std::fs::File; use std::io::{Read, Write}; @@ -106,7 +106,8 @@ pub fn copy_files( tree: &tree, options, link_tracker: RefCell::new(HardLinkTracker::new()), - dest_ids: RefCell::new(HashSet::new()), + dest_ids: RefCell::new(HashMap::new()), + made_files: RefCell::new(HashMap::new()), prompter: RefCell::new(if options.interactive { Some(InteractivePrompter::new()?) } else { @@ -118,7 +119,7 @@ pub fn copy_files( fatal: RefCell::new(None), }; if let Some(st) = stat_at(tree.root(), c".") { - walk.dest_ids.borrow_mut().insert(file_id(&st)); + walk.record_dest(file_id(&st), Dest::Entered); } // Directories take their attributes once everything has been copied -- @@ -167,6 +168,20 @@ impl CopyWalk<'_> { } } +/// What a destination in `CopyWalk::dest_ids` is to the walk, should the walk +/// meet it as a source. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Dest { + /// A directory that was there already: the destination operand, one + /// entered, or one that maps onto itself. A source directory that is one + /// is one being copied *into*. + Entered, + /// A directory this copy made. -s and -i can put it inside a directory + /// the walk has still to read (as with `CopyWalk::made_files`), and it is + /// then the copy's own output, which is not copied again. + Made, +} + /// State the three traversal callbacks share. /// /// The destination side is untouched by this: every leaf is still resolved @@ -180,8 +195,16 @@ struct CopyWalk<'a> { options: &'a CopyOptions, link_tracker: RefCell, /// `(st_dev, st_ino)` of every destination directory this copy has created - /// or entered. A source directory found in here is one being copied *into*. - dest_ids: RefCell>, + /// or entered, with which it is (`Dest`). + dest_ids: RefCell>, + /// The names of the files this copy has made under -s or -i, by the + /// `(st_dev, st_ino)` of the directory they were made in. -s and -i can + /// put a file inside a directory the walk has still to read -- the very + /// one being read, when that maps onto itself -- and the walk then meets + /// the copy's own output, which is not copied again. A name, not the + /// file's identity: under -l the file made is the source itself, linked, + /// and another name the source already had is still to be copied. + made_files: RefCell>>, prompter: RefCell>, /// Destination directories still to take their source attributes, which /// wait until everything -- not only the walk below them, but any later @@ -266,20 +289,29 @@ impl CopyWalk<'_> { // being copied into. Following it walks the copy's own output back // into itself until the pathname runs out of room; identity cannot be // spelled two ways, where a path comparison could be defeated by any - // other spelling. - if metadata.is_dir() - && self - .dest_ids - .borrow() - .contains(&(metadata.dev(), metadata.ino())) - { - // The path is not repeated in the message: `visit` reports this - // against `src`, byte-accurately, as the diagnostic's subject. - // Interpolating `src.display()` here both duplicated it and - // reintroduced the lossy rendering the rest of this branch removed. - return Err(PaxError::InvalidFormat( - "cannot copy directory into itself".to_string(), - )); + // other spelling. A source this copy made is its own output, met again + // by a walk that is still reading the directory it was put in. + if self.made_here(entry, metadata) { + return Ok(false); + } + let dest = self + .dest_ids + .borrow() + .get(&(metadata.dev(), metadata.ino())) + .copied(); + match dest { + Some(Dest::Made) => return Ok(false), + Some(Dest::Entered) if metadata.is_dir() => { + // The path is not repeated in the message: `visit` reports + // this against `src`, byte-accurately, as the diagnostic's + // subject. Interpolating `src.display()` here both duplicated + // it and reintroduced the lossy rendering the rest of this + // branch removed. + return Err(PaxError::InvalidFormat( + "cannot copy directory into itself".to_string(), + )); + } + _ => {} } // -s applies before -i (POSIX: the order of -o, -p and -s is @@ -365,11 +397,50 @@ impl CopyWalk<'_> { )?; } else if let Err(e) = copy_special_file(pfd, name, metadata, self.options) { crate::error::report_error(src, e); + return Ok(false); } + self.record_made_file(pfd, name); Ok(false) } + /// Record `id` as a destination of kind `dest`, unless it already is one. + fn record_dest(&self, id: (u64, u64), dest: Dest) { + self.dest_ids.borrow_mut().entry(id).or_insert(dest); + } + + /// Under -s or -i, record `name` in `dirfd` as a file this copy made + /// (`made_files`). Only a renamed member can land in a directory the walk + /// has still to read; without -s or -i every member lands at its source's + /// own name below the destination, which the walk never enters + /// (`Dest::Entered`). + fn record_made_file(&self, dirfd: BorrowedFd<'_>, name: &CStr) { + if self.options.substitutions.is_empty() && !self.options.interactive { + return; + } + if let Some(dir) = stat_at(dirfd, c".") { + self.made_files + .borrow_mut() + .entry(file_id(&dir)) + .or_default() + .insert(name.to_owned()); + } + } + + /// Whether the non-directory `entry` is a file this copy made, at the name + /// and in the directory the walk found it. + fn made_here(&self, entry: &ftw::Entry<'_>, metadata: &ftw::Metadata) -> bool { + let made = self.made_files.borrow(); + if made.is_empty() || metadata.is_dir() { + return false; + } + // SAFETY: the walk keeps the entry's directory open while it is visited. + let dir = unsafe { BorrowedFd::borrow_raw(entry.dir_fd()) }; + stat_at(dir, c".") + .and_then(|st| made.get(&file_id(&st))) + .is_some_and(|names| names.contains(entry.file_name())) + } + /// Create the destination directory and arrange for its attributes to be /// applied once its contents exist. /// @@ -433,7 +504,12 @@ impl CopyWalk<'_> { // Remember what this destination directory *is*, so the walk can // recognise it if the source tree leads back here. - self.dest_ids.borrow_mut().insert(file_id(&dest_st)); + let dest = if existing.is_some() { + Dest::Entered + } else { + Dest::Made + }; + self.record_dest(file_id(&dest_st), dest); self.print_verbose(src); @@ -465,6 +541,8 @@ impl CopyWalk<'_> { if self.options.substitutions.is_empty() && !self.options.interactive { return overwrites_itself(src); } + // It is a destination, and is being read while it is written to. + self.record_dest((metadata.dev(), metadata.ino()), Dest::Entered); self.print_verbose(src); self.descend(member, metadata) } diff --git a/pax/tests/copy/mod.rs b/pax/tests/copy/mod.rs index fb01a51ce..cc324fc86 100644 --- a/pax/tests/copy/mod.rs +++ b/pax/tests/copy/mod.rs @@ -1222,3 +1222,49 @@ fn test_copy_followed_link_onto_itself_keeps_the_link() { assert_eq!(fs::read_to_string(temp.path().join("f")).unwrap(), "F\n"); } } + +/// A directory that maps onto itself is walked while -s renames its entries +/// into it. The walk's own readdir then meets the files the copy has just +/// made there, and copied each onto itself again: "file would overwrite +/// itself", or under -l "Unable to link file to itself". With enough entries +/// the directory is read in several batches, and some of the new names fall +/// in a later one. Under -l the new names are the sources themselves, linked, +/// so a name the source already had must still be copied. +#[test] +fn test_copy_onto_itself_does_not_revisit_its_own_output() { + const FILES: usize = 2000; + for flags in [&["-rw"][..], &["-rw", "-l"][..]] { + let temp = TempDir::new().unwrap(); + let d = temp.path().join("d"); + fs::create_dir(&d).unwrap(); + for i in 0..FILES { + fs::write(d.join(format!("f{i}")), format!("{i}\n")).unwrap(); + } + fs::hard_link(d.join("f0"), d.join("fdup")).unwrap(); + fs::create_dir(d.join("fdir")).unwrap(); + fs::write(d.join("fdir/inner"), "inner\n").unwrap(); + + let mut args = flags.to_vec(); + args.extend(["-s", ",^d/f,d/g,", "d", "."]); + let out = run_pax_in_dir(&args, temp.path()); + assert_success(&out, &format!("pax {args:?}")); + assert!(!stderr_str(&out).contains("itself"), "{}", stderr_str(&out)); + for i in 0..FILES { + let copied = fs::read_to_string(d.join(format!("g{i}"))).unwrap(); + assert_eq!(copied, format!("{i}\n"), "{flags:?}"); + } + assert_eq!(fs::read_to_string(d.join("gdup")).unwrap(), "0\n"); + if flags.contains(&"-l") { + let ino = |name: &str| fs::metadata(d.join(name)).unwrap().ino(); + assert_eq!(ino("g7"), ino("f7")); + assert_eq!(ino("gdup"), ino("f0")); + } + assert_eq!(fs::read_to_string(d.join("gdir/inner")).unwrap(), "inner\n"); + // Each source once, and its copy once: nothing copied twice. + assert_eq!( + fs::read_dir(&d).unwrap().count(), + 2 * (FILES + 2), + "{flags:?}" + ); + } +} From cc637607af20bfcd5f9e32aaab8c924a3f3220c2 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 20:15:16 +0000 Subject: [PATCH 15/71] pax -rw: do not walk a directory this run made, ending -s recursion into itself The directories pax makes to hold a member below them (DirTree's implicit ones) were recorded neither in dest_ids nor in made_files. Under -s, when such a directory is made inside a directory the walk has still to read, the walk met it, copied it again under the same substitution one level deeper, and so on without end: pax -rw -s ',^[de]/\(.*\)$,e/q/\1/w,' d e . makes e/q while visiting e, before e is read, so the walk meets e/q, makes e/q/q, and recurses whatever the readdir order. With a directory of 2000 files, -s ',^d/\(.*\)$,d/q/\1/w,' d . made 259122 directories, 86 deep, in 5 seconds before it was killed. copy_one now passes over a source directory this run made -- for a member, only to hold members below it, or unverified -- by asking the tree's own registry (DirTree::made_by_run, a new accessor over the implicit, made and unverified sets). That registry already knows every directory made, so the Made kind 374dcaae added to dest_ids duplicated it and is gone: dest_ids is again the set of directories that were there already, met as a source as "cannot copy directory into itself". Test: the two-operand command above, under a 5 second deadline, must finish, exit 0, and leave e two levels deep (before: killed at the deadline). Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 9 +++++ pax/modes/copy.rs | 81 ++++++++++++++++--------------------------- pax/tests/copy/mod.rs | 34 ++++++++++++++++++ 3 files changed, 73 insertions(+), 51 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index dcc2adf27..c0253d929 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -485,6 +485,15 @@ impl DirTree { Ok(fd) } + /// Whether the directory with `id` is one this run made -- to hold + /// members below it, for a member naming it, or unverified -- wherever it + /// is met. + pub(crate) fn made_by_run(&self, id: (u64, u64)) -> bool { + self.implicit.borrow().contains_key(&id) + || self.made.borrow().contains_key(&id) + || self.unverified.borrow().contains(&id) + } + /// Whether `st`, met at `member`, is a directory this run created there /// only to hold members below it, rather than one that was there before. pub(crate) fn is_implicit(&self, st: &libc::stat, member: &MemberPath) -> bool { diff --git a/pax/modes/copy.rs b/pax/modes/copy.rs index 734e763fe..39b15572b 100644 --- a/pax/modes/copy.rs +++ b/pax/modes/copy.rs @@ -106,7 +106,7 @@ pub fn copy_files( tree: &tree, options, link_tracker: RefCell::new(HardLinkTracker::new()), - dest_ids: RefCell::new(HashMap::new()), + dest_ids: RefCell::new(HashSet::new()), made_files: RefCell::new(HashMap::new()), prompter: RefCell::new(if options.interactive { Some(InteractivePrompter::new()?) @@ -119,7 +119,7 @@ pub fn copy_files( fatal: RefCell::new(None), }; if let Some(st) = stat_at(tree.root(), c".") { - walk.record_dest(file_id(&st), Dest::Entered); + walk.dest_ids.borrow_mut().insert(file_id(&st)); } // Directories take their attributes once everything has been copied -- @@ -168,20 +168,6 @@ impl CopyWalk<'_> { } } -/// What a destination in `CopyWalk::dest_ids` is to the walk, should the walk -/// meet it as a source. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Dest { - /// A directory that was there already: the destination operand, one - /// entered, or one that maps onto itself. A source directory that is one - /// is one being copied *into*. - Entered, - /// A directory this copy made. -s and -i can put it inside a directory - /// the walk has still to read (as with `CopyWalk::made_files`), and it is - /// then the copy's own output, which is not copied again. - Made, -} - /// State the three traversal callbacks share. /// /// The destination side is untouched by this: every leaf is still resolved @@ -195,8 +181,10 @@ struct CopyWalk<'a> { options: &'a CopyOptions, link_tracker: RefCell, /// `(st_dev, st_ino)` of every destination directory this copy has created - /// or entered, with which it is (`Dest`). - dest_ids: RefCell>, + /// or entered, and of every directory that maps onto itself. A source + /// directory found in here is one being copied *into* -- unless this copy + /// made it (`DirTree::made_by_run`). + dest_ids: RefCell>, /// The names of the files this copy has made under -s or -i, by the /// `(st_dev, st_ino)` of the directory they were made in. -s and -i can /// put a file inside a directory the walk has still to read -- the very @@ -289,29 +277,28 @@ impl CopyWalk<'_> { // being copied into. Following it walks the copy's own output back // into itself until the pathname runs out of room; identity cannot be // spelled two ways, where a path comparison could be defeated by any - // other spelling. A source this copy made is its own output, met again - // by a walk that is still reading the directory it was put in. + // other spelling. + // + // A source this copy made is its own output, met again by a walk that + // is still reading the directory -s or -i put it in: a file + // (`made_here`), or a directory, made for a member or only to hold + // members below it. Walked, such a directory is copied again under + // the same substitution, one level deeper each time, without end. if self.made_here(entry, metadata) { return Ok(false); } - let dest = self - .dest_ids - .borrow() - .get(&(metadata.dev(), metadata.ino())) - .copied(); - match dest { - Some(Dest::Made) => return Ok(false), - Some(Dest::Entered) if metadata.is_dir() => { - // The path is not repeated in the message: `visit` reports - // this against `src`, byte-accurately, as the diagnostic's - // subject. Interpolating `src.display()` here both duplicated - // it and reintroduced the lossy rendering the rest of this - // branch removed. - return Err(PaxError::InvalidFormat( - "cannot copy directory into itself".to_string(), - )); - } - _ => {} + let id = (metadata.dev(), metadata.ino()); + if metadata.is_dir() && self.tree.made_by_run(id) { + return Ok(false); + } + if metadata.is_dir() && self.dest_ids.borrow().contains(&id) { + // The path is not repeated in the message: `visit` reports this + // against `src`, byte-accurately, as the diagnostic's subject. + // Interpolating `src.display()` here both duplicated it and + // reintroduced the lossy rendering the rest of this branch removed. + return Err(PaxError::InvalidFormat( + "cannot copy directory into itself".to_string(), + )); } // -s applies before -i (POSIX: the order of -o, -p and -s is @@ -404,16 +391,11 @@ impl CopyWalk<'_> { Ok(false) } - /// Record `id` as a destination of kind `dest`, unless it already is one. - fn record_dest(&self, id: (u64, u64), dest: Dest) { - self.dest_ids.borrow_mut().entry(id).or_insert(dest); - } - /// Under -s or -i, record `name` in `dirfd` as a file this copy made /// (`made_files`). Only a renamed member can land in a directory the walk /// has still to read; without -s or -i every member lands at its source's /// own name below the destination, which the walk never enters - /// (`Dest::Entered`). + /// (`dest_ids`). fn record_made_file(&self, dirfd: BorrowedFd<'_>, name: &CStr) { if self.options.substitutions.is_empty() && !self.options.interactive { return; @@ -504,12 +486,7 @@ impl CopyWalk<'_> { // Remember what this destination directory *is*, so the walk can // recognise it if the source tree leads back here. - let dest = if existing.is_some() { - Dest::Entered - } else { - Dest::Made - }; - self.record_dest(file_id(&dest_st), dest); + self.dest_ids.borrow_mut().insert(file_id(&dest_st)); self.print_verbose(src); @@ -542,7 +519,9 @@ impl CopyWalk<'_> { return overwrites_itself(src); } // It is a destination, and is being read while it is written to. - self.record_dest((metadata.dev(), metadata.ino()), Dest::Entered); + self.dest_ids + .borrow_mut() + .insert((metadata.dev(), metadata.ino())); self.print_verbose(src); self.descend(member, metadata) } diff --git a/pax/tests/copy/mod.rs b/pax/tests/copy/mod.rs index cc324fc86..b33a29ff9 100644 --- a/pax/tests/copy/mod.rs +++ b/pax/tests/copy/mod.rs @@ -1268,3 +1268,37 @@ fn test_copy_onto_itself_does_not_revisit_its_own_output() { ); } } + +/// The deepest directory below `dir`, in components. +fn deepest_dir(dir: &std::path::Path) -> usize { + fs::read_dir(dir) + .unwrap() + .filter_map(Result::ok) + .filter(|e| e.file_type().unwrap().is_dir()) + .map(|e| 1 + deepest_dir(&e.path())) + .max() + .unwrap_or(0) +} + +/// -s can send a member below a directory the walk has still to read, and the +/// directories made to hold it are the copy's own output too. Walked as a +/// source, each was copied again under the same substitution, one level +/// deeper, without end: here `e/q` is made while `e` is visited, before the +/// walk reads `e`, so every level is met whatever the readdir order. Bounded +/// by a deadline, since the regression neither ends nor fails. +#[test] +fn test_copy_does_not_walk_directories_it_made_to_hold_members() { + let temp = TempDir::new().unwrap(); + fs::create_dir(temp.path().join("d")).unwrap(); + fs::create_dir(temp.path().join("e")).unwrap(); + fs::write(temp.path().join("d/f"), "F\n").unwrap(); + let args = ["-rw", "-s", r",^[de]/\(.*\)$,e/q/\1/w,", "d", "e", "."]; + let out = run_pax_with_deadline(&args, temp.path(), std::time::Duration::from_secs(5)) + .expect("pax -rw -s into a directory it made did not finish"); + assert_success(&out, "pax -rw -s into a directory it made"); + assert_eq!( + fs::read_to_string(temp.path().join("e/q/f/w")).unwrap(), + "F\n" + ); + assert_eq!(deepest_dir(&temp.path().join("e")), 2); +} From 394299fdf127874e13b481816cdef3599f7c87ab Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 20:17:43 +0000 Subject: [PATCH 16/71] pax: cap the descriptors pinning unfinished cpio link sets A newc link set waits for its data until c_nlink of its names have been read, and its file stays pinned by an open descriptor meanwhile. A crafted archive can start any number of sets that never finish -- one empty name each, with c_nlink 2 or 0xffffffff -- and each held a descriptor to the end of the archive, so every member after the descriptor limit failed with EMFILE. The extract loop now keeps the pinned sets in a FIFO, at most a quarter of the RLIMIT_NOFILE soft limit and never more than 256. Past that it closes the oldest set's pin; the set falls back to the bare (st_dev, st_ino) check every set already relies on once its data is in. A set's entry leaves the FIFO when its pin closes, as it does once the set is settled. Test: 300 one-name newc sets, alternating c_nlink 2 and 0xffffffff, followed by 20 ordinary members, extracted with a limit of 64 descriptors (before: "s58: I/O error: Too many open files" onward, exit 1). Co-Authored-By: Claude Opus 5.5 --- pax/archive.rs | 7 +++- pax/modes/read.rs | 74 ++++++++++++++++++++++++++++++++++++++++--- pax/tests/cpio/mod.rs | 59 ++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 5 deletions(-) diff --git a/pax/archive.rs b/pax/archive.rs index 66610cc8b..1a89964a4 100644 --- a/pax/archive.rs +++ b/pax/archive.rs @@ -495,7 +495,7 @@ impl Default for LinkSets { impl LinkSets { /// The key of the set `entry` would be a name of, or `None` for a member /// that is not one of several names of a file. - fn key(entry: &ArchiveEntry) -> Option<(u64, u64)> { + pub fn key(entry: &ArchiveEntry) -> Option<(u64, u64)> { (entry.entry_type == EntryType::Regular && entry.nlink > 1) .then_some((entry.dev, entry.ino)) } @@ -521,6 +521,11 @@ impl LinkSets { } } + /// What was remembered about the set whose key (`key`) is `key`. + pub fn by_key_mut(&mut self, key: (u64, u64)) -> Option<&mut T> { + self.sets.get_mut(&key).map(|set| &mut set.value) + } + /// Whether no set started so far awaits data on a later name. pub fn all_settled(&self) -> bool { !self.sets.values().any(LinkSet::awaits_data) diff --git a/pax/modes/read.rs b/pax/modes/read.rs index 1cca80e47..5ffba7575 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -126,6 +126,7 @@ fn extract_members( pending_dirs: &mut PendingDirs, ) -> PaxResult<()> { let mut link_sets: LinkSets = LinkSets::default(); + let mut pins = PinBudget::new(); let mut selector = Selector::new( &options.patterns, options.exclude, @@ -167,6 +168,7 @@ fn extract_members( if let Some(set) = link_sets.settled_mut(&entry) { set.unpin(); } + pins.account(&entry, &mut link_sets); // A newc set's data comes with its last name, which -n must still // read even when every pattern has been used by an earlier one. if selector.is_done() && link_sets.all_settled() { @@ -526,6 +528,69 @@ fn extract_hardlink( Ok(()) } +/// The link sets whose files are pinned (`CreatedSet::pin`), oldest first, +/// and how many may be at once. +/// +/// A newc set waits for its data until c_nlink of its names have been read, +/// and an archive can start any number of sets that never finish: one name +/// each, with a c_nlink of 2 or of 0xffffffff. Pinned until the end, one +/// descriptor each, they would leave none for the members after them. Past +/// the budget the oldest set's pin is closed, and that set falls back to the +/// bare `(st_dev, st_ino)` check every set relies on once its data is in. +struct PinBudget { + /// The keys (`LinkSets::key`) of the sets holding a pin, oldest first. + held: std::collections::VecDeque<(u64, u64)>, + /// How many may be held at once: a quarter of the descriptor limit, and + /// at most 256. + limit: usize, +} + +impl PinBudget { + fn new() -> Self { + let mut lim = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + let soft = if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut lim) } == 0 { + lim.rlim_cur + } else { + 0 + }; + PinBudget { + held: std::collections::VecDeque::new(), + limit: usize::try_from(soft / 4).map_or(256, |quarter| quarter.min(256)), + } + } + + /// Account for the set `entry` is a name of, after the member: forget it + /// once it holds no pin, note it when it has taken one, and close the + /// oldest pin when that puts the budget over. + fn account(&mut self, entry: &ArchiveEntry, link_sets: &mut LinkSets) { + let Some(key) = LinkSets::::key(entry) else { + return; + }; + let Some(set) = link_sets.by_key_mut(key) else { + return; + }; + let held_at = self.held.iter().position(|&k| k == key); + match (set.pin.is_some(), held_at) { + (false, Some(at)) => { + self.held.remove(at); + } + (true, None) => self.held.push_back(key), + _ => {} + } + while self.held.len() > self.limit { + let Some(oldest) = self.held.pop_front() else { + break; + }; + if let Some(set) = link_sets.by_key_mut(oldest) { + set.unpin(); + } + } + } +} + /// What extraction remembers about a cpio link set. struct CreatedSet { /// The names created for the set so far, as extracted (after -s and -i). @@ -537,10 +602,11 @@ struct CreatedSet { /// name of a set only, so the earlier names are created empty. has_data: bool, /// The file held open while data may still come for it on a later name - /// (`LinkSets::settled_mut`). Its names can be replaced by other members - /// meanwhile, and a filesystem that reuses inode numbers (ext4) then hands - /// this file's number to the next file created, which `file` would take - /// for this one. Open, it keeps its number. + /// (`LinkSets::settled_mut`), within the budget (`PinBudget`). Its names + /// can be replaced by other members meanwhile, and a filesystem that + /// reuses inode numbers (ext4) then hands this file's number to the next + /// file created, which `file` would take for this one. Open, it keeps its + /// number. pin: Option, } diff --git a/pax/tests/cpio/mod.rs b/pax/tests/cpio/mod.rs index 3db5bdba6..1e21bc3dd 100644 --- a/pax/tests/cpio/mod.rs +++ b/pax/tests/cpio/mod.rs @@ -928,3 +928,62 @@ fn test_cpio_first_match_stops_after_an_empty_link_set() { assert_eq!(fs::read(dest.join("sel")).unwrap(), b"selected"); assert_eq!(file_id(&dest.join("e1")), file_id(&dest.join("e2"))); } + +/// A newc set waits for data until c_nlink of its names have been read, and +/// its file stays pinned meanwhile. A crafted archive can start any number of +/// sets that never finish -- one name each, with c_nlink 2 or 0xffffffff -- +/// and held one descriptor each, every member after the descriptor limit +/// failed. The pins are capped; past the cap the oldest is closed. +#[test] +fn test_cpio_unfinished_link_sets_do_not_exhaust_descriptors() { + const SETS: u32 = 300; + let mut archive = Vec::new(); + for i in 0..SETS { + let name = format!("s{i}"); + let nlink = if i % 2 == 0 { 2 } else { u32::MAX }; + archive.extend_from_slice( + &CpioNewc { + name: name.as_bytes(), + ino: i + 1, + nlink, + ..Default::default() + } + .member(), + ); + } + for i in 0..20 { + let name = format!("later{i}"); + let body = format!("body {i}\n"); + archive.extend_from_slice( + &CpioNewc { + name: name.as_bytes(), + body: body.as_bytes(), + ino: SETS + 1 + i, + ..Default::default() + } + .member(), + ); + } + archive.extend_from_slice( + &CpioNewc { + name: b"TRAILER!!!", + ..Default::default() + } + .member(), + ); + + let temp = TempDir::new().unwrap(); + let path = temp.path().join("archive.cpio"); + fs::write(&path, &archive).unwrap(); + let dest = temp.path().join("dest"); + fs::create_dir(&dest).unwrap(); + let out = pax_with_fd_limit(&["-r", "-f", path.to_str().unwrap()], &dest, 64); + assert_success(&out, &format!("pax -r of {SETS} unfinished newc link sets")); + for i in 0..SETS { + assert_eq!(fs::read(dest.join(format!("s{i}"))).unwrap(), b""); + } + for i in 0..20 { + let body = fs::read_to_string(dest.join(format!("later{i}"))).unwrap(); + assert_eq!(body, format!("body {i}\n")); + } +} From e8f8df3bdc3397f3ecd50837ccd1ae6fa66e0e47 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 18:20:30 +0000 Subject: [PATCH 17/71] cp: leave a found directory's mode, owner and times alone below a directory others can write `cp -p` (and -a) gave every destination directory it found already existing the source's owner, mode and times. Where someone else can create entries in the destination -- or in any directory between the one the user named and the one found -- the directory at the source's name may be anyone's, renamed there before cp ran: the user's own private 0700 directory included, which -p would then open up to 0755. cp now follows the rule pax already does, through the same plib::madefs::ChainTrust: the destination directory the user named is the anchor (for `cp -R src new`, the directory `new` is made in; for --parents, the target), and the trust is carried down from it on the descriptors cp holds: a found directory hands on what it was handed and its own, one this run made and verified in full starts afresh. A found directory takes what -p asks only where the chain is trusted; elsewhere it keeps everything, times included, cp says so naming it, and the exit status is 1. Its contents are still copied. Without -p nothing changes. A directory this run made, found again at the same path by a later operand (`cp -pR s1/x s2/x dir`, or --parents walking through what an earlier operand made), is still the run's own, as GNU cp treats it. One found at another path is not: whoever renamed it there could have renamed it to a name it does not stand for. What a directory is given is now recorded on its own level of the walk when it is entered, instead of being looked up by inode number when it is finished: a directory this copy made, renamed by someone else to a sibling's name, was taken for made there and given that sibling's mode. cp_n_recursive_merges_without_overwriting now sets its destination 0755: under a umask of 002 it was group-writable, and its found directory was rightly left alone. Co-Authored-By: Claude Opus 5.5 --- tree/common/copy.rs | 325 ++++++++++++++++++++++++++++------ tree/common/mod.rs | 4 +- tree/cp.rs | 11 +- tree/parents.rs | 86 ++++++--- tree/tests/cp/debhelper.rs | 3 + tree/tests/cp/existing_dir.rs | 283 +++++++++++++++++++++++++++++ tree/tests/cp/mod.rs | 1 + 7 files changed, 628 insertions(+), 85 deletions(-) create mode 100644 tree/tests/cp/existing_dir.rs diff --git a/tree/common/copy.rs b/tree/common/copy.rs index 120528f9e..cd7abf847 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -11,10 +11,11 @@ use super::pinned::{CopiedSources, PinnedEntry, SourceState}; use super::{error_string, quote, Verbose}; use ftw::{self, traverse_directory}; use gettextrs::gettext; +pub use plib::madefs::ChainTrust; pub use plib::madefs::MadeTrust; #[cfg(target_os = "linux")] use plib::madefs::{chmod_pinned, proc_fd_name, procfs_dir, utimens_link_if_still}; -use plib::madefs::{fs_owners, made_by_us, FsOwners, MadeObject}; +use plib::madefs::{fs_owners, made_by_us, FoundDir, FsOwners, MadeObject, Preserve}; use std::{ cell::RefCell, collections::{HashMap, HashSet}, @@ -221,6 +222,144 @@ enum DirOrigin { Found { dev: u64, ino: u64 }, } +/// What a copy's destination operand is handed by the directory it is made in, should it be +/// found existing there: whether, as a directory, it may take the source's mode and owner under +/// -p (`ChainTrust`). The anchor the trust is carried down from is the destination directory +/// the user named. +#[derive(Clone, Copy, Debug)] +pub enum OperandTrust { + /// The operand is that directory itself, copied into (`cp -R src/. dest`): the anchor, + /// which takes what -p asks wherever it is, as GNU cp gives it. + Named, + /// The directory the operand is made in is the anchor: `dir` for `cp -R src dir` finding + /// `dir/src`, and the directory `new` was to be made in for `cp -R src new` finding `new` + /// there after all. It is read, only once the operand is found, from the found directory's + /// own `..`, which names wherever that directory actually is. + Parent, + /// Handed down the directories walked from the anchor to the one the operand is made in + /// (`cp --parents`). + Chain(ChainTrust), +} + +/// Every destination directory a cp run has made and verified in full (`MadeTrust::Full`), by +/// `(st_dev, st_ino)`, with the destination path it was made at. +/// +/// Found again at that path -- by a later operand, or by `--parents` on the way to one -- it is +/// still the run's own: it takes what -p asks, and hands the directories found in it the trust +/// of a directory made (`ChainTrust::made`), wherever it is. Found at any other path it is one +/// found existing there, as pax counts it: whoever renamed it there could have renamed it to +/// the name of a source directory it does not stand for, and it would take that one's mode. +/// Recording a directory replaces whatever its number stood for: a directory made can reuse +/// the number of one removed during the run. +#[derive(Default)] +pub struct MadeDirs(HashMap<(u64, u64), PathBuf>); + +impl MadeDirs { + /// Record the directory with `id`, just made at `path` and verified in full. + pub fn record(&mut self, id: (u64, u64), path: &Path) { + self.0.insert(id, path.to_path_buf()); + } + + /// Whether the directory with `id`, found at `path`, is one this run made there. + pub fn made_at(&self, id: (u64, u64), path: &Path) -> bool { + self.0.get(&id).is_some_and(|at| at == path) + } +} + +/// What a cp run carries from one operand to the next. +#[derive(Default)] +pub struct CopyRun { + /// Destinations already written, so a later source cannot clobber one. + pub created_files: HashSet, + /// Destination directories made (`MadeDirs`). + pub made_dirs: MadeDirs, +} + +/// What a destination directory the copy entered is given once its contents are copied. +#[derive(Clone, Copy, Debug)] +enum DirFinish { + /// This copy made it, and `verify_made_dir` trusts it this far. + Made(MadeTrust), + /// It was there already, or was made earlier in the run (`MadeDirs`). + Found(FoundDir), +} + +/// A destination directory the walk is in: the one the operand is made in at the bottom of the +/// stack, then one per source directory entered. +struct DestDir { + fd: Rc, + /// What it hands the directories found in it. + hands: OperandTrust, + /// What it is given once its contents are copied; `None` for the operand's own directory, + /// which the copy only makes the operand in. + finish: Option, +} + +/// What a directory that may take what -p asks of it is given: `ChainTrust::found_dir` where +/// the chain is trusted. +fn as_asked(requested: Preserve) -> FoundDir { + if requested.mode || requested.owner { + FoundDir::AsRequested + } else { + FoundDir::TimesOnly + } +} + +/// The directory that the directory open on `fd` is in, through its own `..`: wherever it +/// actually is, never by name. +fn open_parent_dir(fd: &ftw::FileDescriptor, target: &Path) -> io::Result { + let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC; + ftw::FileDescriptor::open_at(fd, c"..", flags).map_err(|e| { + io::Error::other(gettext!( + "cannot open the directory holding '{}': {}", + target.display(), + error_string(&e) + )) + }) +} + +/// For a directory found existing at the destination path `target`, open on `fd` with identity +/// `id`, in a directory handing it `hands`: what it is given once its contents are copied, and +/// what it hands the directories found in it. +/// +/// One this run made at that path is its own (`MadeDirs`). Any other takes what -p asks only +/// where nobody else could have created its name, in its parent or any directory above it up +/// to the anchor; elsewhere it is left as it is, times included, and that is reported +/// (`ChainTrust::found_dir`, which pax follows too). +fn found_dir_trust( + hands: OperandTrust, + fd: &ftw::FileDescriptor, + id: (u64, u64), + target: &Path, + made_dirs: &MadeDirs, + requested: Preserve, +) -> io::Result<(DirFinish, ChainTrust)> { + if made_dirs.made_at(id, target) { + let finish = DirFinish::Found(as_asked(requested)); + return Ok((finish, ChainTrust::made(fd.as_raw_fd())?)); + } + let chain = match hands { + OperandTrust::Named => { + let finish = DirFinish::Found(as_asked(requested)); + return Ok((finish, ChainTrust::anchor(fd.as_raw_fd())?)); + } + OperandTrust::Parent => ChainTrust::anchor(open_parent_dir(fd, target)?.as_raw_fd())?, + OperandTrust::Chain(chain) => chain, + }; + let finish = DirFinish::Found(chain.found_dir(requested)); + Ok((finish, chain.found(fd.as_raw_fd())?)) +} + +/// The -p failure reported for a directory found existing where others could have created its +/// name (`FoundDir::LeaveAlone`): nothing of it is changed, times included. +fn found_dir_left_alone(target: &Path) -> io::Error { + io::Error::other(gettext!( + "not preserving the owner, permissions or times of '{}': the directory was already \ + there, and others can create entries beside it", + target.display() + )) +} + /// `fstat` of a descriptor the caller keeps open (and goes on owning). fn fd_metadata(fd: libc::c_int) -> io::Result { std::mem::ManuallyDrop::new(unsafe { fs::File::from_raw_fd(fd) }).metadata() @@ -490,32 +629,37 @@ pub fn finish_made_dir_mode( } /// A destination directory's attributes, once its contents are copied so nothing written into -/// it moves its times afterwards: under -p the source's owner, mode and times, on every -/// directory; without -p the final mode of one this copy made (`finish_made_dir_mode`), and -/// nothing on one it found. Applied through `fd`, the descriptor the copy has held for the -/// directory since it entered it, never by name; whether it was made is read from that -/// descriptor's identity. The source's metadata is what the walk recorded when it stat'ed the -/// directory, before reading it: the read moved its access time, and GNU keeps the original. +/// it moves its times afterwards: under -p the source's owner, mode and times on one this copy +/// made, and on one it found only where nobody else could have created its name +/// (`found_dir_trust`); without -p the final mode of one this copy made +/// (`finish_made_dir_mode`), and nothing on one it found. Applied through `fd`, the descriptor +/// the copy has held for the directory since it entered it, never by name. The source's +/// metadata is what the walk recorded when it stat'ed the directory, before reading it: the +/// read moved its access time, and GNU keeps the original. fn finish_dir( fd: libc::c_int, source: &ftw::Entry<'_>, - made_dirs: &HashMap<(u64, u64), MadeTrust>, + finish: DirFinish, preserve: bool, umask: u32, target: &Path, ) -> io::Result<()> { - let dest_md = fd_metadata(fd)?; - let made = made_dirs.get(&(dest_md.dev(), dest_md.ino())).copied(); - if !preserve && made.is_none() { - return Ok(()); - } + let trust = match finish { + DirFinish::Made(trust) => trust, + // cp's -p asks for the times along with the mode and owner, and without it a + // directory found is given nothing. + DirFinish::Found(FoundDir::TimesOnly) => return Ok(()), + DirFinish::Found(FoundDir::LeaveAlone) => return Err(found_dir_left_alone(target)), + DirFinish::Found(FoundDir::AsRequested) if preserve => MadeTrust::Full, + DirFinish::Found(FoundDir::AsRequested) => return Ok(()), + }; let source_md = source .metadata() .ok_or_else(|| io::Error::other(gettext!("cannot stat '{}'", source.path())))?; if preserve { // A directory made but trusted only as owned like its parent gets no owner and no // mode. - preserve_through_fd(fd, source_md, target, made.unwrap_or(MadeTrust::Full)) + preserve_through_fd(fd, source_md, target, trust) } else { finish_made_dir_mode(fd, source_md, umask, target) } @@ -1725,48 +1869,55 @@ fn preserve_made( .err() } +/// Copy `source_arg` to `target_arg`, resolved from the working directory. `trust` is what the +/// directory the operand is made in hands it should it be found existing (`OperandTrust`); +/// `run` carries what the run has done so far (`CopyRun`). pub fn copy_file( cfg: &CopyConfig, source_arg: &Path, target_arg: &Path, - created_files: &mut HashSet, + trust: OperandTrust, + run: &mut CopyRun, inode_map: Option<&mut InodeMap>, prompt_fn: F, ) -> io::Result<()> where F: Copy + Fn(&str) -> bool, { - copy_file_at( + copy_tree( cfg, - source_arg, - target_arg, - None, - created_files, + SourceRoot::Path(source_arg), + TargetRoot::of_path(target_arg, None, trust), + &mut run.created_files, + &mut run.made_dirs, inode_map, prompt_fn, ) } -/// `copy_file`, with the destination's directory optionally given as a descriptor: with -/// `Some(dir)`, the copy is made as `target_arg`'s last component inside `dir` and `target_arg` -/// is only named in diagnostics, so no part of it is resolved by path. +/// `copy_file`, with the destination's directory given as a descriptor, with the trust it +/// hands the directories found in it, carried down from the anchor: the copy is made as +/// `target_arg`'s last component inside that directory and `target_arg` is only named in +/// diagnostics, so no part of it is resolved by path. pub fn copy_file_at( cfg: &CopyConfig, source_arg: &Path, target_arg: &Path, - target_dir: Option, - created_files: &mut HashSet, + target_dir: (ftw::FileDescriptor, ChainTrust), + run: &mut CopyRun, inode_map: Option<&mut InodeMap>, prompt_fn: F, ) -> io::Result<()> where F: Copy + Fn(&str) -> bool, { + let (dir, trust) = target_dir; copy_tree( cfg, SourceRoot::Path(source_arg), - TargetRoot::of_path(target_arg, target_dir), - created_files, + TargetRoot::of_path(target_arg, Some(dir), OperandTrust::Chain(trust)), + &mut run.created_files, + &mut run.made_dirs, inode_map, prompt_fn, ) @@ -1776,6 +1927,8 @@ where struct TargetRoot<'a> { /// The directory it is made in. dir: Rc, + /// What `dir` hands it, should it be found existing. + trust: OperandTrust, /// Its name in `dir`. name: &'a OsStr, /// What precedes `name` in the operand, for diagnostics. @@ -1787,21 +1940,31 @@ struct TargetRoot<'a> { impl<'a> TargetRoot<'a> { /// cp: the operand resolved from the working directory, or with `Some(dir)` its last /// component in `dir`. - fn of_path(operand: &'a Path, dir: Option) -> Self { - let (dir, name, display_parent) = match (dir, operand.file_name()) { + fn of_path(operand: &'a Path, dir: Option, trust: OperandTrust) -> Self { + let (dir, trust, name, display_parent) = match (dir, operand.file_name()) { (Some(dir), Some(name)) => ( dir, + trust, name, operand.parent().unwrap_or(Path::new("")).to_path_buf(), ), - _ => ( + // Resolved from the working directory, it is in whatever directory that names. + (Some(_), None) => ( ftw::FileDescriptor::cwd(), + OperandTrust::Parent, + operand.as_os_str(), + PathBuf::new(), + ), + (None, _) => ( + ftw::FileDescriptor::cwd(), + trust, operand.as_os_str(), PathBuf::new(), ), }; TargetRoot { dir: Rc::new(dir), + trust, name, display_parent, operand, @@ -1812,6 +1975,9 @@ impl<'a> TargetRoot<'a> { fn pinned(entry: &'a PinnedEntry) -> Self { TargetRoot { dir: entry.dir_rc(), + // The move's copy must create its operand (`Destination::MustCreate`), so it is + // never found; the directory it was pinned in is the anchor. + trust: OperandTrust::Parent, name: OsStr::from_bytes(entry.name().to_bytes()), display_parent: entry.display_parent().to_path_buf(), operand: entry.path(), @@ -1833,6 +1999,9 @@ pub struct MoveSource<'a> { /// The duplication step of `mv` across filesystems (POSIX mv step 6): `copy_file_at`, walking /// the source from the directory it was pinned in, recording what it copied, and making the /// destination in the directory it was pinned in. +/// +/// Every directory it enters it made itself, so the directories one move made need not be +/// known to the next (`MadeDirs`). pub fn copy_moved_file( cfg: &CopyConfig, source: MoveSource<'_>, @@ -1845,6 +2014,7 @@ pub fn copy_moved_file( SourceRoot::Pinned(source), TargetRoot::pinned(target), created_files, + &mut MadeDirs::default(), Some(inode_map), // mv never asks: it already asked its own question. |_| false, @@ -1872,6 +2042,7 @@ fn copy_tree( source_root: SourceRoot<'_>, target_root: TargetRoot<'_>, created_files: &mut HashSet, + made_by_run: &mut MadeDirs, mut inode_map: Option<&mut InodeMap>, prompt_fn: F, ) -> io::Result<()> @@ -1889,20 +2060,34 @@ where // The operand's own directory and name. let TargetRoot { dir: top_dir, + trust: top_trust, name: top_name, display_parent: top_dir_path, operand: target_arg, } = target_root; + // Without -p a directory found is given nothing wherever it is, so the trust is never + // asked for, and `OperandTrust::Parent` need not read the operand's `..`. + let top_trust = if cfg.preserve { + top_trust + } else { + OperandTrust::Named + }; + // What -p asks of a directory: cp's preserves mode and owner together. + let requested = Preserve { + mode: cfg.preserve, + owner: cfg.preserve, + }; // `RefCell` to allow sharing these between closures. The bottom entry is the operand's // directory, so a stack of one means the entry is the operand itself. - let target_dirfd_stack = RefCell::new(vec![top_dir]); + let target_dirfd_stack = RefCell::new(vec![DestDir { + fd: top_dir, + hands: top_trust, + finish: None, + }]); // (st_dev, st_ino) of every destination directory this copy creates or enters. A source // directory found in here is one we are copying *into*. let dest_dir_ids = RefCell::new(HashSet::<(u64, u64)>::new()); - // (st_dev, st_ino) of every destination directory this copy made, with how far - // `verify_made_dir` trusts it. Without -p each one gets its final mode once filled; under - // -p one trusted only as `MadeTrust::ParentOwnerOnly` gets no owner and no mode. - let made_dirs = RefCell::new(HashMap::<(u64, u64), MadeTrust>::new()); + let made_by_run = RefCell::new(made_by_run); // Read once: each read is a pair of umask(2) calls. let umask = plib::modestr::umask(); let target_dir_path = RefCell::new(top_dir_path); @@ -1922,7 +2107,9 @@ where } let at_top_level = target_dirfd_stack_borrowed.len() == 1; - let target_dirfd = target_dirfd_stack_borrowed.last().unwrap(); + let target_level = target_dirfd_stack_borrowed.last().unwrap(); + let target_dirfd = &target_level.fd; + let hands = target_level.hands; let target_filename = if at_top_level { top_name @@ -2054,6 +2241,11 @@ where // `lstat` saw, or for one this copy made, what a fresh `mkdirat` // yields (`verify_made_dir`). Its identity is read from the // descriptor, never by name. + // + // From the same descriptor comes what it is given once filled, and the + // trust it hands the directories found in it: one this copy made and + // verified in full is trusted afresh (`ChainTrust::made`); any other is + // judged by what its parent hands it (`found_dir_trust`). let cannot_open = |e: io::Error| { io::Error::other(gettext!( "cannot open directory '{}': {}", @@ -2079,7 +2271,15 @@ where target.display() ))); } - Ok((fd, md)) + let (finish, trust) = found_dir_trust( + hands, + &fd, + (dev, ino), + &target, + &made_by_run.borrow(), + requested, + )?; + Ok((fd, md, finish, trust)) }), DirOrigin::Made => open_made_dir( target_dirfd.as_raw_fd(), @@ -2087,15 +2287,25 @@ where &target, ) .map_err(|e| made_dir_open_error(&target, e)) - .and_then(|(fd, trust)| { + .and_then(|(fd, made)| { let fd = ftw::FileDescriptor::from(fd); let md = fd_metadata(fd.as_raw_fd())?; - made_dirs.borrow_mut().insert((md.dev(), md.ino()), trust); + let id = (md.dev(), md.ino()); + let trust = if made == MadeTrust::Full { + made_by_run.borrow_mut().record(id, &target); + ChainTrust::made(fd.as_raw_fd())? + } else { + // Owned like its parent only, it may be someone else's: it + // hands on what a directory found would. + let made_dirs = made_by_run.borrow(); + found_dir_trust(hands, &fd, id, &target, &made_dirs, requested)? + .1 + }; report_copied(cfg, source.path().as_inner(), &target, true); - Ok((fd, md)) + Ok((fd, md, DirFinish::Made(made), trust)) }), }; - let (new_target_dirfd, new_target_md) = match opened { + let (new_target_dirfd, new_target_md, finish, trust) = match opened { Ok(pair) => pair, Err(e) => { if cfg.continue_on_error { @@ -2119,7 +2329,11 @@ where copied.record(SourceState::of(source_md)); } - target_dirfd_stack_borrowed.push(Rc::new(new_target_dirfd)); + target_dirfd_stack_borrowed.push(DestDir { + fd: Rc::new(new_target_dirfd), + hands: OperandTrust::Chain(trust), + finish: Some(finish), + }); target_dir_path_borrowed.push(target_filename); true @@ -2168,11 +2382,16 @@ where target_dir_path_borrowed.pop(); let target_dir = target_dirfd_stack_borrowed.pop(); - if let Some(target_dir) = target_dir { + if let Some(DestDir { + fd, + finish: Some(finish), + .. + }) = target_dir + { let finished = finish_dir( - target_dir.as_raw_fd(), + fd.as_raw_fd(), &source, - &made_dirs.borrow(), + finish, cfg.preserve, umask, &dir_path, @@ -2252,7 +2471,7 @@ where { let mut result = Some(()); - let mut created_files = HashSet::new(); + let mut run = CopyRun::default(); // loop through sources, moving each to target for source in sources { @@ -2266,13 +2485,15 @@ where false }; - let new_target = if source.is_dir() && ends_with_slash_dot(source) { + // `target` is the destination directory the user named, and the anchor: the operand is + // either an entry of it or, for `src/.`, the directory itself. + let (new_target, trust) = if source.is_dir() && ends_with_slash_dot(source) { // This causes the contents of `source` to be copied instead of // `source` itself - target.to_path_buf() + (target.to_path_buf(), OperandTrust::Named) } else { match source.file_name() { - Some(file_name) => target.join(file_name), + Some(file_name) => (target.join(file_name), OperandTrust::Parent), None => { let err_str = gettext!("invalid filename: {}", source.display()); eprintln!("{}: {}", cfg.prog, err_str); @@ -2286,7 +2507,8 @@ where cfg, source, &new_target, - &mut created_files, + trust, + &mut run, inode_map.as_deref_mut(), prompt_fn, ) { @@ -2490,7 +2712,8 @@ mod tests { &must_create_config(), source, target, - &mut std::collections::HashSet::new(), + super::OperandTrust::Parent, + &mut super::CopyRun::default(), None, |_| false, ); diff --git a/tree/common/mod.rs b/tree/common/mod.rs index 0cbe57809..a825ccd2b 100644 --- a/tree/common/mod.rs +++ b/tree/common/mod.rs @@ -138,8 +138,8 @@ pub fn quote_bytes(name: &[u8]) -> String { // cp and mv pub use copy::{ copy_file, copy_file_at, copy_files, copy_moved_file, finish_made_dir_mode, - made_dir_open_error, open_made_dir, preserve_through_fd, CopyConfig, DerefMode, Destination, - InodeMap, MadeTrust, MoveSource, + made_dir_open_error, open_made_dir, preserve_through_fd, ChainTrust, CopyConfig, CopyRun, + DerefMode, Destination, InodeMap, MadeDirs, MadeTrust, MoveSource, OperandTrust, }; // mv diff --git a/tree/cp.rs b/tree/cp.rs index 668c3205c..98d8f2a5c 100644 --- a/tree/cp.rs +++ b/tree/cp.rs @@ -11,12 +11,11 @@ mod common; mod parents; use self::common::{ - copy_file, copy_files, error_string, exit_after_verbose, CopyConfig, DerefMode, Destination, - InodeMap, Verbose, + copy_file, copy_files, error_string, exit_after_verbose, CopyConfig, CopyRun, DerefMode, + Destination, InodeMap, OperandTrust, Verbose, }; use clap::Parser; use gettextrs::gettext; -use std::collections::HashSet; use std::path::PathBuf; use std::{fs, io}; @@ -223,13 +222,13 @@ fn main() -> Result<(), Box> { } else if dir_exists { copy_files(&cfg, sources, target, inode_map, prompt_user).is_some() } else { - let mut created_files = HashSet::new(); - + // The target names no directory yet: the one it is made in is the anchor. match copy_file( &cfg, &sources[0], target, - &mut created_files, + OperandTrust::Parent, + &mut CopyRun::default(), inode_map, prompt_user, ) { diff --git a/tree/parents.rs b/tree/parents.rs index 26ef511b4..d160e8d98 100644 --- a/tree/parents.rs +++ b/tree/parents.rs @@ -17,10 +17,10 @@ use crate::common::{ copy_file_at, error_string, finish_made_dir_mode, made_dir_open_error, open_made_dir, - preserve_through_fd, report_verbose_bytes, CopyConfig, InodeMap, MadeTrust, + preserve_through_fd, report_verbose_bytes, ChainTrust, CopyConfig, CopyRun, InodeMap, MadeDirs, + MadeTrust, }; use gettextrs::gettext; -use std::collections::HashSet; use std::ffi::CString; use std::fs::File; use std::io; @@ -54,6 +54,12 @@ fn open_dir_at(dirfd: libc::c_int, name: &CString, extra_flags: libc::c_int) -> Ok(File::from(unsafe { OwnedFd::from_raw_fd(fd) })) } +/// `(st_dev, st_ino)` of the directory open as `dir`. +fn dir_id(dir: &File) -> io::Result<(u64, u64)> { + let md = dir.metadata()?; + Ok((md.dev(), md.ino())) +} + fn cstring(bytes: &[u8]) -> io::Result { CString::new(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e)) } @@ -68,16 +74,23 @@ fn cstring(bytes: &[u8]) -> io::Result { /// GNU cp follows a symbolic link it finds, but a link planted between a failed lookup and the /// `mkdirat` is indistinguishable from one that was there before, so none is followed. Nothing /// is stat'ed before its open; the identity used afterwards is the opened descriptor's own. +/// +/// The target is the anchor, and the trust it hands is carried down the walk with the +/// descriptors (`ChainTrust`): a directory found hands on what it was handed and its own, one +/// this run made and verified in full -- now, or earlier at the same path (`MadeDirs`) -- +/// starts afresh. The last one's is returned, for the directories the copy finds in it. fn make_parents( source: &Path, target: &Path, preserve: bool, verbose: bool, -) -> io::Result<(Vec, File)> { + made_dirs: &mut MadeDirs, +) -> io::Result<(Vec, File, ChainTrust)> { let mut made = Vec::new(); let mut dest_dir = open_dir_at(libc::AT_FDCWD, &cstring(target.as_os_str().as_bytes())?, 0)?; + let mut trust = ChainTrust::anchor(dest_dir.as_raw_fd())?; let Some(parent) = source.parent() else { - return Ok((made, dest_dir)); + return Ok((made, dest_dir, trust)); }; let start = if source.is_absolute() { "/" } else { "." }; let mut src_dir = open_dir_at(libc::AT_FDCWD, &cstring(start.as_bytes())?, 0)?; @@ -129,32 +142,47 @@ fn make_parents( // parent could have swapped in a directory of their own; and the umask may have // withheld the owner permission the directory needs to be filled // (`open_made_dir`). - let (opened, trust) = open_made_dir(dest_dir.as_raw_fd(), &name, &dest_path) + let (opened, made_trust) = open_made_dir(dest_dir.as_raw_fd(), &name, &dest_path) .map_err(|e| made_dir_open_error(&dest_path, e))?; let next_dest = File::from(opened); + trust = if made_trust == MadeTrust::Full { + made_dirs.record(dir_id(&next_dest)?, &dest_path); + ChainTrust::made(next_dest.as_raw_fd())? + } else { + // Owned like its parent only, it may be someone else's: it hands on what a + // directory found would. + trust.found(next_dest.as_raw_fd())? + }; made.push(MadeDir { dest: next_dest.try_clone()?, source: src_md, path: dest_path.clone(), - trust, + trust: made_trust, }); if verbose { report_made_dir(&src_path, &dest_path); } next_dest } else { - open_dir_at(dest_dir.as_raw_fd(), &name, libc::O_NOFOLLOW).map_err(|e| { - io::Error::other(gettext!( - "'{}' exists but is not a directory: {}", - dest_path.display(), - error_string(&e) - )) - })? + let found = + open_dir_at(dest_dir.as_raw_fd(), &name, libc::O_NOFOLLOW).map_err(|e| { + io::Error::other(gettext!( + "'{}' exists but is not a directory: {}", + dest_path.display(), + error_string(&e) + )) + })?; + trust = if made_dirs.made_at(dir_id(&found)?, &dest_path) { + ChainTrust::made(found.as_raw_fd())? + } else { + trust.found(found.as_raw_fd())? + }; + found }; src_dir = next_src; dest_dir = next_dest; } - Ok((made, dest_dir)) + Ok((made, dest_dir, trust)) } /// GNU `cp -v --parents` for a directory made on the way: `source -> dest`, unquoted, unlike @@ -198,19 +226,25 @@ where F: Copy + Fn(&str) -> bool, { let mut ok = true; - let mut created_files = HashSet::new(); + let mut run = CopyRun::default(); // Read once: each read is a pair of umask(2) calls. let umask = plib::modestr::umask(); for source in sources { - let (made, dest_dir) = - match make_parents(source, target, cfg.preserve, cfg.verbose.is_some()) { - Ok(pair) => pair, - Err(e) => { - eprintln!("cp: {}", error_string(&e)); - ok = false; - continue; - } - }; + let walked = make_parents( + source, + target, + cfg.preserve, + cfg.verbose.is_some(), + &mut run.made_dirs, + ); + let (made, dest_dir, trust) = match walked { + Ok(walked) => walked, + Err(e) => { + eprintln!("cp: {}", error_string(&e)); + ok = false; + continue; + } + }; let relative: PathBuf = source .components() .filter(|c| !matches!(c, Component::RootDir | Component::Prefix(_))) @@ -222,8 +256,8 @@ where cfg, source, &dest, - Some(OwnedFd::from(dest_dir).into()), - &mut created_files, + (OwnedFd::from(dest_dir).into(), trust), + &mut run, inode_map.as_deref_mut(), prompt_fn, ) { diff --git a/tree/tests/cp/debhelper.rs b/tree/tests/cp/debhelper.rs index 25856efb3..cd7156b6e 100644 --- a/tree/tests/cp/debhelper.rs +++ b/tree/tests/cp/debhelper.rs @@ -101,6 +101,9 @@ fn cp_n_recursive_merges_without_overwriting() { fs::write(d.join("src/b"), "new b\n").unwrap(); fs::create_dir_all(d.join("dst/src")).unwrap(); fs::write(d.join("dst/src/a"), "old a\n").unwrap(); + // Only the user can create entries in `dst`, whatever the umask, so the `dst/src` found + // there takes the source's attributes (tests/cp/existing_dir.rs has the other case). + fs::set_permissions(d.join("dst"), fs::Permissions::from_mode(0o755)).unwrap(); let (err, code) = cp_in(&d, &["-an", "src", "dst/"]); assert_eq!((err.as_str(), code), ("", 0)); assert_eq!(fs::read_to_string(d.join("dst/src/a")).unwrap(), "old a\n"); diff --git a/tree/tests/cp/existing_dir.rs b/tree/tests/cp/existing_dir.rs new file mode 100644 index 000000000..bd1fed552 --- /dev/null +++ b/tree/tests/cp/existing_dir.rs @@ -0,0 +1,283 @@ +// +// Copyright (c) 2026 Jeff Garzik +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +//! A source directory whose destination name is already a directory. cp copies into it, and +//! under -p gives it the source's owner, mode and times -- but, as pax does, only where nobody +//! else could have created that name first: in a destination others can create entries in, +//! the directory there may be anyone's renamed to the name, a private one of the user's own +//! included, and giving it the source's mode would open it up. There it keeps all its own, and +//! that is diagnosed. The trust is carried down from the destination directory the user named, +//! one directory at a time, and a directory cp made itself starts it afresh. + +use plib::testing::get_binary_path; +use plib::tmp::{tempdir, TempDir}; +use std::fs::{self, File, FileTimes}; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::path::Path; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, SystemTime}; + +/// The modification time every source directory is given, far from any time a copy runs at. +const OLD_MTIME: i64 = 1_000_000_000; + +const DIAGNOSTIC: &str = "the directory was already there, and others can create entries"; + +fn set_mode(path: &Path, mode: u32) { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap(); +} + +fn mode_of(path: &Path) -> u32 { + fs::symlink_metadata(path).unwrap().mode() & 0o7777 +} + +fn mtime_of(path: &Path) -> i64 { + fs::symlink_metadata(path).unwrap().mtime() +} + +/// Give the directory `path` the modification time `OLD_MTIME`. +fn set_old_mtime(path: &Path) { + let old = SystemTime::UNIX_EPOCH + Duration::from_secs(OLD_MTIME as u64); + File::open(path) + .unwrap() + .set_times(FileTimes::new().set_modified(old).set_accessed(old)) + .unwrap(); +} + +/// A source directory `dir` (mode `mode`, modified at `OLD_MTIME`) holding a file `f`. +fn source_dir(dir: &Path, mode: u32) { + fs::create_dir_all(dir).unwrap(); + fs::write(dir.join("f"), "data\n").unwrap(); + set_mode(dir, mode); + set_old_mtime(dir); +} + +/// A directory `dir` of mode `mode` holding an existing directory `name` of mode 0700 with a +/// file in it -- in the attack, the user's own private `secrets`, renamed to the name the +/// source directory maps to before cp runs. +fn with_private_dir(dir: &Path, mode: u32, name: &str) { + fs::create_dir_all(dir).unwrap(); + let secrets = dir.join("secrets"); + fs::create_dir(&secrets).unwrap(); + fs::write(secrets.join("key"), "secret\n").unwrap(); + set_mode(&secrets, 0o700); + fs::rename(&secrets, dir.join(name)).unwrap(); + set_mode(dir, mode); +} + +/// Run cp with `args` in `cwd`. +fn cp(cwd: &Path, args: &[&str]) -> Output { + Command::new(get_binary_path("cp")) + .args(args) + .current_dir(cwd) + .stdin(Stdio::null()) + .output() + .expect("failed to execute cp") +} + +/// `src/d` (0755) and a destination `dest` of mode `dest_mode` with a private `d` in it. +fn secrets_scenario(dest_mode: u32) -> TempDir { + let temp = tempdir().unwrap(); + source_dir(&temp.path().join("src/d"), 0o755); + with_private_dir(&temp.path().join("dest"), dest_mode, "d"); + temp +} + +/// The private directory renamed to the source's name keeps its mode and its own times under +/// -p wherever others can create entries in the destination: group or other writable, or +/// sticky and world writable. That is diagnosed, naming it, and the exit status is 1; its +/// contents are still copied. +#[test] +fn cp_pr_leaves_a_found_directory_alone_below_an_open_destination() { + for dest_mode in [0o777, 0o1777, 0o775] { + let temp = secrets_scenario(dest_mode); + let out = cp(temp.path(), &["-pR", "src/d", "dest"]); + let stderr = String::from_utf8_lossy(&out.stderr); + let d = temp.path().join("dest/d"); + assert_eq!( + mode_of(&d), + 0o700, + "{dest_mode:o}: the private directory was opened up" + ); + assert_ne!( + mtime_of(&d), + OLD_MTIME, + "{dest_mode:o}: its times were changed" + ); + assert_eq!( + out.status.code(), + Some(1), + "{dest_mode:o}: stderr: {stderr}" + ); + assert!( + stderr.contains(DIAGNOSTIC), + "{dest_mode:o}: stderr: {stderr}" + ); + assert!( + stderr.contains("'dest/d'"), + "{dest_mode:o}: stderr: {stderr}" + ); + assert_eq!(fs::read_to_string(d.join("f")).unwrap(), "data\n"); + assert_eq!(fs::read_to_string(d.join("key")).unwrap(), "secret\n"); + set_mode(&temp.path().join("dest"), 0o755); + } +} + +/// Without -p nothing is asked for: the found directory keeps its mode, as always, and nothing +/// is said. +#[test] +fn cp_r_without_p_leaves_a_found_directory_mode_alone_silently() { + let temp = secrets_scenario(0o777); + let out = cp(temp.path(), &["-R", "src/d", "dest"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(0), "stderr: {stderr}"); + assert_eq!(stderr, ""); + assert_eq!(mode_of(&temp.path().join("dest/d")), 0o700); + assert!(temp.path().join("dest/d/f").exists()); + set_mode(&temp.path().join("dest"), 0o755); +} + +/// Where nobody but the user can create entries in the destination, -p gives a found +/// directory the source's mode and times, as GNU cp does. +#[test] +fn cp_pr_stamps_a_found_directory_in_a_private_destination() { + let temp = secrets_scenario(0o755); + let out = cp(temp.path(), &["-pR", "src/d", "dest"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(0), "stderr: {stderr}"); + let d = temp.path().join("dest/d"); + assert_eq!(mode_of(&d), 0o755); + assert_eq!(mtime_of(&d), OLD_MTIME); +} + +/// The destination directory the user named is the anchor, and is never judged itself: +/// copying a directory's contents into it (`src/.`) gives it the source's attributes under -p +/// wherever it is, as GNU cp does. +#[test] +fn cp_pr_stamps_the_named_destination_itself() { + let temp = tempdir().unwrap(); + source_dir(&temp.path().join("src/d"), 0o755); + with_private_dir(&temp.path().join("open"), 0o777, "dest"); + let out = cp(temp.path(), &["-pR", "src/d/.", "open/dest"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(0), "stderr: {stderr}"); + let dest = temp.path().join("open/dest"); + assert_eq!(mode_of(&dest), 0o755); + assert_eq!(mtime_of(&dest), OLD_MTIME); + set_mode(&temp.path().join("open"), 0o755); +} + +/// A directory this run made, found again by a later operand, is still the run's own, wherever +/// it is: merging two sources into one directory of an open destination gives it the second +/// one's attributes under -p, as GNU cp does -- whether it was made by the first operand's +/// copy or on the way by `--parents`. +#[test] +fn cp_pr_trusts_a_directory_it_made_when_found_again() { + let temp = tempdir().unwrap(); + source_dir(&temp.path().join("s1/x"), 0o700); + source_dir(&temp.path().join("s2/x"), 0o750); + // A file of its own: cp refuses to overwrite one it has just made. + fs::rename(temp.path().join("s2/x/f"), temp.path().join("s2/x/g")).unwrap(); + set_old_mtime(&temp.path().join("s2/x")); + fs::create_dir(temp.path().join("dest")).unwrap(); + set_mode(&temp.path().join("dest"), 0o777); + let out = cp(temp.path(), &["-pR", "s1/x", "s2/x", "dest"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(0), "stderr: {stderr}"); + let x = temp.path().join("dest/x"); + assert_eq!(mode_of(&x), 0o750); + assert_eq!(mtime_of(&x), OLD_MTIME); + set_mode(&temp.path().join("dest"), 0o755); + + // `--parents a/b/c` makes `dest/a` and `dest/a/b` under the open `dest`; `--parents a/b` + // then finds both, and `dest/a/b` -- inside a directory the run made -- takes `a/b`'s + // attributes. (`c` is empty: cp refuses to overwrite a file it has just made.) + let temp = tempdir().unwrap(); + fs::create_dir_all(temp.path().join("a/b/c")).unwrap(); + source_dir(&temp.path().join("a/b"), 0o750); + set_mode(&temp.path().join("a"), 0o755); + fs::create_dir(temp.path().join("dest")).unwrap(); + set_mode(&temp.path().join("dest"), 0o777); + let out = cp(temp.path(), &["-pR", "--parents", "a/b/c", "a/b", "dest"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(0), "stderr: {stderr}"); + let b = temp.path().join("dest/a/b"); + assert_eq!(mode_of(&b), 0o750); + assert_eq!(mtime_of(&b), OLD_MTIME); + assert!(b.join("f").exists() && b.join("c").is_dir()); + set_mode(&temp.path().join("dest"), 0o755); +} + +/// Trust holds along the whole chain. A found directory others can write breaks it for every +/// directory found below it, though the destination above is the user's alone; and one found +/// in an open destination -- its own mode private, but possibly renamed in -- breaks it too. +#[test] +fn cp_pr_trust_holds_along_the_chain() { + // (destination mode, `p`'s mode): `p` is found in the destination, `p/secret` in `p`. + for (dest_mode, p_mode) in [(0o755, 0o777), (0o775, 0o755)] { + let temp = tempdir().unwrap(); + source_dir(&temp.path().join("src/p/secret"), 0o755); + set_mode(&temp.path().join("src/p"), 0o755); + let dest = temp.path().join("dest"); + fs::create_dir(&dest).unwrap(); + with_private_dir(&dest.join("p"), p_mode, "secret"); + set_mode(&dest, dest_mode); + let case = format!("dest {dest_mode:o}, p {p_mode:o}"); + + let out = cp(temp.path(), &["-pR", "src/p", "dest"]); + let stderr = String::from_utf8_lossy(&out.stderr); + let secret = dest.join("p/secret"); + assert_eq!( + mode_of(&secret), + 0o700, + "{case}: the private directory was opened up" + ); + assert_ne!(mtime_of(&secret), OLD_MTIME, "{case}"); + assert_eq!(out.status.code(), Some(1), "{case}: stderr: {stderr}"); + assert!( + stderr.contains("'dest/p/secret'"), + "{case}: stderr: {stderr}" + ); + assert!(secret.join("f").exists(), "{case}"); + set_mode(&dest, 0o755); + } +} + +/// `--parents` carries the trust from the destination it names through every directory it +/// walks: `p`, found in an open destination, hands none to `p/secret` found in it. +#[test] +fn cp_parents_pr_trust_holds_along_the_chain() { + let temp = tempdir().unwrap(); + source_dir(&temp.path().join("p/secret"), 0o755); + set_mode(&temp.path().join("p"), 0o755); + let dest = temp.path().join("dest"); + fs::create_dir(&dest).unwrap(); + with_private_dir(&dest.join("p"), 0o755, "secret"); + set_mode(&dest, 0o775); + + let out = cp(temp.path(), &["-pR", "--parents", "p/secret", "dest"]); + let stderr = String::from_utf8_lossy(&out.stderr); + let secret = dest.join("p/secret"); + assert_eq!( + mode_of(&secret), + 0o700, + "the private directory was opened up" + ); + assert_ne!(mtime_of(&secret), OLD_MTIME); + assert_eq!(out.status.code(), Some(1), "stderr: {stderr}"); + assert!(stderr.contains(DIAGNOSTIC), "stderr: {stderr}"); + assert!(secret.join("f").exists()); + + // In a destination only the user can write, the same chain is trusted. + set_mode(&dest, 0o755); + let out = cp(temp.path(), &["-pR", "--parents", "p/secret", "dest"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(0), "stderr: {stderr}"); + assert_eq!(mode_of(&secret), 0o755); + assert_eq!(mtime_of(&secret), OLD_MTIME); +} diff --git a/tree/tests/cp/mod.rs b/tree/tests/cp/mod.rs index 3deae8921..645e16709 100644 --- a/tree/tests/cp/mod.rs +++ b/tree/tests/cp/mod.rs @@ -9,6 +9,7 @@ mod debhelper; mod dirmode; +mod existing_dir; mod link; #[cfg(all( target_os = "linux", From da27ce53900152427aa25725472fbf615559bb13 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 18:36:12 +0000 Subject: [PATCH 18/71] cp: judge a found directory's parent through a search-only descriptor To decide whether a directory cp -p found existing may take the source's attributes, cp opens the directory it is in, through the found one's `..`. That open was O_RDONLY, which needs read permission: under a destination the user may search and write but not list (0300), `cp -pR s2 r` with r/s2 already there failed and copied nothing, where it used to copy. Copying into the found directory needs search permission only, and so does judging it: the open is now O_PATH (Linux) or O_SEARCH (macOS, the BSDs). pax and cp each had their own copy of that flag; it now lives once in plib::madefs::SEARCH_ONLY. Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 26 ++++---------------------- plib/src/madefs.rs | 19 +++++++++++++++++++ tree/common/copy.rs | 15 +++------------ tree/tests/cp/existing_dir.rs | 21 +++++++++++++++++++++ 4 files changed, 47 insertions(+), 34 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index c0253d929..23e432665 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -22,7 +22,7 @@ use crate::error::{PaxError, PaxResult}; use crate::modes::made::{self, cvt, verify_made_dir, MadeNode, MadeTrust}; -use plib::madefs::{ChainTrust, FoundDir, Preserve}; +use plib::madefs::{ChainTrust, FoundDir, Preserve, SEARCH_ONLY}; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::ffi::{CStr, CString, OsStr}; @@ -146,27 +146,9 @@ impl MemberPath { } } -/// Open flags for a directory that is only ever walked through or used as the -/// `dirfd` of an `*at` call. -/// -/// Reaching a name below a directory takes search permission only, so opening -/// each component for reading refused a path through a directory the user may -/// search and write but not list (mode 0300) where `mkdir` or `open` by name -/// would have succeeded. `O_PATH` (Linux) and `O_SEARCH` (macOS, the BSDs) open -/// it for exactly that. Elsewhere `O_RDONLY` is the only option there is. -/// (An `O_PATH` descriptor takes attributes only through a verified procfs, -/// `set_attrs_search_only`, which is Linux's alone.) -#[cfg(target_os = "linux")] -const SEARCH_ONLY: libc::c_int = libc::O_PATH; -#[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "netbsd"))] -const SEARCH_ONLY: libc::c_int = libc::O_SEARCH; -#[cfg(not(any( - target_os = "linux", - target_os = "macos", - target_os = "freebsd", - target_os = "netbsd" -)))] -const SEARCH_ONLY: libc::c_int = libc::O_RDONLY; +// Directories walked through are opened `plib::madefs::SEARCH_ONLY`. (An +// `O_PATH` descriptor takes attributes only through a verified procfs, +// `set_attrs_search_only`, which is Linux's alone.) /// Flags for walking one directory component: search only, and never through /// a symbolic link or anything that is not a directory. diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index e9e39cda7..b5a5671ce 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -29,6 +29,25 @@ use std::os::fd::RawFd; #[cfg(target_os = "linux")] use std::os::fd::{AsRawFd, FromRawFd}; +/// Open flags for a directory that is only ever walked through, used as the `dirfd` of an `*at` +/// call, or `fstat`ed (`ChainTrust`). +/// +/// Reaching a name below a directory takes search permission only, so opening it for reading +/// refuses a directory the user may search and write but not list (mode 0300) where `mkdir` or +/// `open` by name would have succeeded. `O_PATH` (Linux) and `O_SEARCH` (macOS, the BSDs) open +/// it for exactly that. Elsewhere `O_RDONLY` is the only option there is. +#[cfg(target_os = "linux")] +pub const SEARCH_ONLY: libc::c_int = libc::O_PATH; +#[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "netbsd"))] +pub const SEARCH_ONLY: libc::c_int = libc::O_SEARCH; +#[cfg(not(any( + target_os = "linux", + target_os = "macos", + target_os = "freebsd", + target_os = "netbsd" +)))] +pub const SEARCH_ONLY: libc::c_int = libc::O_RDONLY; + /// How a filesystem keeps file owners, as far as its type says. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum FsOwners { diff --git a/tree/common/copy.rs b/tree/common/copy.rs index cd7abf847..bd3fca574 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -15,7 +15,7 @@ pub use plib::madefs::ChainTrust; pub use plib::madefs::MadeTrust; #[cfg(target_os = "linux")] use plib::madefs::{chmod_pinned, proc_fd_name, procfs_dir, utimens_link_if_still}; -use plib::madefs::{fs_owners, made_by_us, FoundDir, FsOwners, MadeObject, Preserve}; +use plib::madefs::{fs_owners, made_by_us, FoundDir, FsOwners, MadeObject, Preserve, SEARCH_ONLY}; use std::{ cell::RefCell, collections::{HashMap, HashSet}, @@ -306,9 +306,9 @@ fn as_asked(requested: Preserve) -> FoundDir { } /// The directory that the directory open on `fd` is in, through its own `..`: wherever it -/// actually is, never by name. +/// actually is, never by name. Opened for search only, as copying into `fd` needs no more. fn open_parent_dir(fd: &ftw::FileDescriptor, target: &Path) -> io::Result { - let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC; + let flags = SEARCH_ONLY | libc::O_DIRECTORY | libc::O_CLOEXEC; ftw::FileDescriptor::open_at(fd, c"..", flags).map_err(|e| { io::Error::other(gettext!( "cannot open the directory holding '{}': {}", @@ -396,15 +396,6 @@ fn open_fd_at(dirfd: libc::c_int, name: &CStr, flags: libc::c_int) -> io::Result Ok(unsafe { OwnedFd::from_raw_fd(fd) }) } -/// Open flags for a directory held for search only: it may deny its owner reading. `O_PATH` -/// (Linux) and `O_SEARCH` (macOS, FreeBSD) open it for exactly that; elsewhere only `O_RDONLY`. -#[cfg(target_os = "linux")] -const SEARCH_ONLY: libc::c_int = libc::O_PATH; -#[cfg(any(target_os = "macos", target_os = "freebsd"))] -const SEARCH_ONLY: libc::c_int = libc::O_SEARCH; -#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))] -const SEARCH_ONLY: libc::c_int = libc::O_RDONLY; - /// Open the directory this copy has just made with `mkdirat` at `name` in `parent_fd`, for /// reading, checked to be the one made (`verify_made_dir`), and with its owner's read, write and /// search permission. diff --git a/tree/tests/cp/existing_dir.rs b/tree/tests/cp/existing_dir.rs index bd1fed552..1e8f0f3fb 100644 --- a/tree/tests/cp/existing_dir.rs +++ b/tree/tests/cp/existing_dir.rs @@ -281,3 +281,24 @@ fn cp_parents_pr_trust_holds_along_the_chain() { assert_eq!(mode_of(&secret), 0o755); assert_eq!(mtime_of(&secret), OLD_MTIME); } + +/// Judging the directory a found one is in needs only search permission on it, as copying +/// into the found one does: under a destination the user may search and write but not read +/// (0300), the copy is made and the found directory stamped. +#[test] +fn cp_pr_judges_a_found_directory_in_an_unreadable_destination() { + let temp = tempdir().unwrap(); + source_dir(&temp.path().join("s2"), 0o750); + let r = temp.path().join("r"); + fs::create_dir_all(r.join("s2")).unwrap(); + set_mode(&r.join("s2"), 0o700); + set_mode(&r, 0o300); + + let out = cp(temp.path(), &["-pR", "s2", "r"]); + let stderr = String::from_utf8_lossy(&out.stderr); + set_mode(&r, 0o755); + assert_eq!(out.status.code(), Some(0), "stderr: {stderr}"); + assert_eq!(fs::read_to_string(r.join("s2/f")).unwrap(), "data\n"); + assert_eq!(mode_of(&r.join("s2")), 0o750); + assert_eq!(mtime_of(&r.join("s2")), OLD_MTIME); +} From 69c3c2cebc8343faaec55bc46d1ddd99fc12d4a7 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 18:39:30 +0000 Subject: [PATCH 19/71] cp: trust a found operand only when its own name in its parent is that directory cp -p decided whether a destination operand it found existing may take the source's attributes by reading the found directory's own `..`. But the operand is opened by its full path, and a trailing slash follows a symbolic link despite O_NOFOLLOW: with `cp -pR src /shared/new/`, someone who can write /shared could plant new -> /home/victim/.ssh after cp looked, and `..` was then the victim's own home directory, which only the victim can write. .ssh was given src's mode, owner and times. The anchor is now the directory the operand is named in: the held directory, or the operand's path less its last component, opened for search only. Its last component, read there without following links, must be the very directory found -- a directory has no other name -- or the found directory hands and takes no trust (plib::madefs::ChainTrust::unlocated): it keeps its attributes and that is reported. The copy itself still follows the link, as GNU cp's does; only the attributes are withheld. Co-Authored-By: Claude Opus 5.5 --- plib/src/madefs.rs | 15 +++++ tree/common/copy.rs | 150 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 147 insertions(+), 18 deletions(-) diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index b5a5671ce..72c388321 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -242,6 +242,14 @@ impl ChainTrust { }) } + /// The trust handed where the caller cannot tell which directory a directory found is in -- + /// reached through a symbolic link, say, and so in none it can judge: none. + pub fn unlocated() -> Self { + ChainTrust { + entries_safe: false, + } + } + /// The trust a directory found existing, open on `dir_fd`, in a directory that handed it /// `self`, hands its own entries. pub fn found(self, dir_fd: RawFd) -> io::Result { @@ -604,6 +612,13 @@ mod tests { let made_x = ChainTrust::made(fd("g/x").as_raw_fd()).unwrap(); assert_eq!(made_x.found_dir(mode), FoundDir::AsRequested); assert_eq!(made_x.found_dir(owner), FoundDir::AsRequested); + // A directory found in no directory the caller can locate takes nothing asked for, and + // hands that on. + let unlocated = ChainTrust::unlocated(); + assert_eq!(unlocated.found_dir(mode), FoundDir::LeaveAlone); + assert_eq!(unlocated.found_dir(none), FoundDir::TimesOnly); + let below = unlocated.found(fd("").as_raw_fd()).unwrap(); + assert_eq!(below.found_dir(mode), FoundDir::LeaveAlone); } /// Only the parent's owner, or anyone allowed to write a parent that is not sticky, can diff --git a/tree/common/copy.rs b/tree/common/copy.rs index bd3fca574..9ba0f8ee7 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -233,8 +233,7 @@ pub enum OperandTrust { Named, /// The directory the operand is made in is the anchor: `dir` for `cp -R src dir` finding /// `dir/src`, and the directory `new` was to be made in for `cp -R src new` finding `new` - /// there after all. It is read, only once the operand is found, from the found directory's - /// own `..`, which names wherever that directory actually is. + /// there after all. It is located only once the operand is found (`parent_anchor`). Parent, /// Handed down the directories walked from the anchor to the one the operand is made in /// (`cp --parents`). @@ -305,22 +304,63 @@ fn as_asked(requested: Preserve) -> FoundDir { } } -/// The directory that the directory open on `fd` is in, through its own `..`: wherever it -/// actually is, never by name. Opened for search only, as copying into `fd` needs no more. -fn open_parent_dir(fd: &ftw::FileDescriptor, target: &Path) -> io::Result { - let flags = SEARCH_ONLY | libc::O_DIRECTORY | libc::O_CLOEXEC; - ftw::FileDescriptor::open_at(fd, c"..", flags).map_err(|e| { - io::Error::other(gettext!( - "cannot open the directory holding '{}': {}", - target.display(), - error_string(&e) - )) - }) +/// The trust the anchor of `OperandTrust::Parent` hands the operand `target`, found existing as +/// the directory with identity `id`: the directory `target` names it in, when it is there under +/// its own name. +/// +/// That directory is `parent`, held, or, when `parent` is the working directory the operand is +/// resolved from, `target` less its last component, opened for search only. The operand's own +/// name must then be the very directory found -- read with `AT_SYMLINK_NOFOLLOW`, and a +/// directory has no other name. It is not when the operand ends in a slash and its name is a +/// symbolic link: the copy follows it, as GNU cp's does, but the directory reached is anywhere +/// the link's owner chose, and judging it by the directory the link is in, or by its own `..`, +/// would hand it trust that directory never gave. Anything not located hands none +/// (`ChainTrust::unlocated`). +fn parent_anchor( + parent: &ftw::FileDescriptor, + id: (u64, u64), + target: &Path, +) -> io::Result { + let Some(name) = target.file_name() else { + return Ok(ChainTrust::unlocated()); + }; + let Ok(name) = CString::new(name.as_bytes()) else { + return Ok(ChainTrust::unlocated()); + }; + let opened; + let dir = if parent.as_raw_fd() == libc::AT_FDCWD { + let path = match target.parent() { + Some(path) if !path.as_os_str().is_empty() => path, + _ => Path::new("."), + }; + let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { + return Ok(ChainTrust::unlocated()); + }; + let flags = SEARCH_ONLY | libc::O_DIRECTORY | libc::O_CLOEXEC; + match open_fd_at(libc::AT_FDCWD, &path, flags) { + Ok(fd) => opened = fd, + Err(_) => return Ok(ChainTrust::unlocated()), + } + opened.as_raw_fd() + } else { + parent.as_raw_fd() + }; + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + let flags = libc::AT_SYMLINK_NOFOLLOW; + if unsafe { libc::fstatat(dir, name.as_ptr(), &mut st, flags) } != 0 { + return Ok(ChainTrust::unlocated()); + } + // Cast needed: `dev_t` is i32 on macOS and u64 on Linux. + #[allow(clippy::unnecessary_cast)] + if (st.st_dev as u64, st.st_ino as u64) != id { + return Ok(ChainTrust::unlocated()); + } + ChainTrust::anchor(dir) } /// For a directory found existing at the destination path `target`, open on `fd` with identity -/// `id`, in a directory handing it `hands`: what it is given once its contents are copied, and -/// what it hands the directories found in it. +/// `id`, in the directory `parent` handing it `hands`: what it is given once its contents are +/// copied, and what it hands the directories found in it. /// /// One this run made at that path is its own (`MadeDirs`). Any other takes what -p asks only /// where nobody else could have created its name, in its parent or any directory above it up @@ -328,6 +368,7 @@ fn open_parent_dir(fd: &ftw::FileDescriptor, target: &Path) -> io::Result ChainTrust::anchor(open_parent_dir(fd, target)?.as_raw_fd())?, + OperandTrust::Parent => parent_anchor(parent, id, target)?, OperandTrust::Chain(chain) => chain, }; let finish = DirFinish::Found(chain.found_dir(requested)); @@ -2264,6 +2305,7 @@ where } let (finish, trust) = found_dir_trust( hands, + target_dirfd, &fd, (dev, ino), &target, @@ -2289,8 +2331,16 @@ where // Owned like its parent only, it may be someone else's: it // hands on what a directory found would. let made_dirs = made_by_run.borrow(); - found_dir_trust(hands, &fd, id, &target, &made_dirs, requested)? - .1 + let (_, trust) = found_dir_trust( + hands, + target_dirfd, + &fd, + id, + &target, + &made_dirs, + requested, + )?; + trust }; report_copied(cfg, source.path().as_inner(), &target, true); Ok((fd, md, DirFinish::Made(made), trust)) @@ -2674,6 +2724,70 @@ mod tests { assert_eq!(after.mtime(), 978_307_200, "the times were not applied"); } + /// `cp -pR`'s configuration. + fn cp_pr_config() -> super::CopyConfig { + super::CopyConfig { + force: false, + deref: super::DerefMode::Never, + interactive: false, + preserve: true, + recursive: true, + no_clobber: false, + link: false, + prog: "cp", + continue_on_error: true, + destination: super::Destination::MayExist, + verbose: None, + } + } + + /// `cp -pR src open/new/` where, after cp found no `open/new`, someone who can write `open` + /// planted `new` as a symbolic link to the user's private directory in a parent only the + /// user can write. The trailing slash follows the link, as GNU cp's copy does; but the + /// directory found is not `open/new`, so it is judged as one found in `open` -- or rather + /// not judged at all, being somewhere else: it keeps its mode, and that is reported. + #[test] + fn a_directory_reached_through_a_trailing_slash_link_is_left_alone() { + use std::fs; + use std::os::unix::fs::{symlink, PermissionsExt}; + + let tmp = plib::tmp::tempdir().unwrap(); + let dir = tmp.path(); + let source = dir.join("src"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("f"), b"f").unwrap(); + fs::set_permissions(&source, fs::Permissions::from_mode(0o755)).unwrap(); + let home = dir.join("home"); + let private = home.join("private"); + fs::create_dir_all(&private).unwrap(); + fs::set_permissions(&private, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(&home, fs::Permissions::from_mode(0o755)).unwrap(); + let open = dir.join("open"); + fs::create_dir(&open).unwrap(); + symlink(&private, open.join("new")).unwrap(); + fs::set_permissions(&open, fs::Permissions::from_mode(0o777)).unwrap(); + + let mut target = open.join("new").into_os_string(); + target.push("/"); + let result = super::copy_file( + &cp_pr_config(), + &source, + std::path::Path::new(&target), + super::OperandTrust::Parent, + &mut super::CopyRun::default(), + None, + |_| false, + ); + fs::set_permissions(&open, fs::Permissions::from_mode(0o755)).unwrap(); + let mode = fs::metadata(&private).unwrap().permissions().mode() & 0o7777; + assert_eq!(mode, 0o700, "the private directory was opened up"); + assert!(result.is_err(), "leaving it alone must be reported"); + assert!( + private.join("f").exists(), + "the copy follows the link, as GNU's does" + ); + } + /// The copy `mv` makes after its step 5, which must create the destination operand. fn must_create_config() -> super::CopyConfig { super::CopyConfig { From c94d737bf0709289e739bf51d6cbf581b9d1e287 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 18:41:43 +0000 Subject: [PATCH 20/71] cp: copy --parents . into the target itself and refuse a source ending in .. `cp -R --parents . ../t` copied into t/t: the destination was built as `../t/.`, whose last component Path takes to be `t`, and the copy was made under that name in t. GNU cp copies into t itself, and so does cp now: a source that is `.` (or `/`) is copied into the target as `cp -R src/. target` is, the target being the directory the user named. A source ending in `..` gave a destination ending in `..`, which named no entry of the directory --parents had walked to; the copy then fell back to resolving it from the working directory, dropping the trust carried down from the target -- under a target others can write, a found directory there was given the source's mode. Such a destination is no name inside the target but whatever directory the `..` reaches (for `..` itself, outside it), so it is refused. GNU cp copies onto that directory; in the probes it then mostly fails with "into itself". TargetRoot::of_path is split into in_cwd and in_dir, and in_dir refuses an operand with no last component instead of re-resolving it. Co-Authored-By: Claude Opus 5.5 --- tree/common/copy.rs | 55 +++++++++++++++++------------------ tree/parents.rs | 48 ++++++++++++++++++++++++++++-- tree/tests/cp/existing_dir.rs | 44 ++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 32 deletions(-) diff --git a/tree/common/copy.rs b/tree/common/copy.rs index 9ba0f8ee7..af9bc70bb 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -1919,7 +1919,7 @@ where copy_tree( cfg, SourceRoot::Path(source_arg), - TargetRoot::of_path(target_arg, None, trust), + TargetRoot::in_cwd(target_arg, trust), &mut run.created_files, &mut run.made_dirs, inode_map, @@ -1947,7 +1947,7 @@ where copy_tree( cfg, SourceRoot::Path(source_arg), - TargetRoot::of_path(target_arg, Some(dir), OperandTrust::Chain(trust)), + TargetRoot::in_dir(target_arg, dir, trust)?, &mut run.created_files, &mut run.made_dirs, inode_map, @@ -1970,39 +1970,36 @@ struct TargetRoot<'a> { } impl<'a> TargetRoot<'a> { - /// cp: the operand resolved from the working directory, or with `Some(dir)` its last - /// component in `dir`. - fn of_path(operand: &'a Path, dir: Option, trust: OperandTrust) -> Self { - let (dir, trust, name, display_parent) = match (dir, operand.file_name()) { - (Some(dir), Some(name)) => ( - dir, - trust, - name, - operand.parent().unwrap_or(Path::new("")).to_path_buf(), - ), - // Resolved from the working directory, it is in whatever directory that names. - (Some(_), None) => ( - ftw::FileDescriptor::cwd(), - OperandTrust::Parent, - operand.as_os_str(), - PathBuf::new(), - ), - (None, _) => ( - ftw::FileDescriptor::cwd(), - trust, - operand.as_os_str(), - PathBuf::new(), - ), - }; + /// cp: the operand resolved from the working directory. + fn in_cwd(operand: &'a Path, trust: OperandTrust) -> Self { TargetRoot { - dir: Rc::new(dir), + dir: Rc::new(ftw::FileDescriptor::cwd()), trust, - name, - display_parent, + name: operand.as_os_str(), + display_parent: PathBuf::new(), operand, } } + /// cp --parents: the operand's last component in `dir`. An operand with none (`..`, `/`) + /// is refused: it names no entry of `dir`, and resolving it some other way would leave + /// both the directory and the trust it hands behind. + fn in_dir(operand: &'a Path, dir: ftw::FileDescriptor, trust: ChainTrust) -> io::Result { + let name = operand.file_name().ok_or_else(|| { + io::Error::other(gettext!( + "'{}' names no entry of a directory", + operand.display() + )) + })?; + Ok(TargetRoot { + dir: Rc::new(dir), + trust: OperandTrust::Chain(trust), + name, + display_parent: operand.parent().unwrap_or(Path::new("")).to_path_buf(), + operand, + }) + } + /// mv: the destination operand pinned in the directory it was found in. fn pinned(entry: &'a PinnedEntry) -> Self { TargetRoot { diff --git a/tree/parents.rs b/tree/parents.rs index d160e8d98..ea20a9f59 100644 --- a/tree/parents.rs +++ b/tree/parents.rs @@ -16,9 +16,9 @@ //! descriptors. use crate::common::{ - copy_file_at, error_string, finish_made_dir_mode, made_dir_open_error, open_made_dir, - preserve_through_fd, report_verbose_bytes, ChainTrust, CopyConfig, CopyRun, InodeMap, MadeDirs, - MadeTrust, + copy_file, copy_file_at, error_string, finish_made_dir_mode, made_dir_open_error, + open_made_dir, preserve_through_fd, report_verbose_bytes, ChainTrust, CopyConfig, CopyRun, + InodeMap, MadeDirs, MadeTrust, OperandTrust, }; use gettextrs::gettext; use std::ffi::CString; @@ -230,6 +230,48 @@ where // Read once: each read is a pair of umask(2) calls. let umask = plib::modestr::umask(); for source in sources { + match source.components().next_back() { + Some(Component::Normal(_)) => {} + // A source ending in `..` has a destination ending in `..`: no name inside the + // target, but whatever directory that reaches -- the target's parent, for `..` + // itself -- which no chain from the target describes. GNU cp copies onto it. + Some(Component::ParentDir) => { + eprintln!( + "cp: {}", + gettext!( + "with --parents, '{}' would be copied to '{}', which is not a name \ + inside '{}'", + source.display(), + target.join(source).display(), + target.display() + ) + ); + ok = false; + continue; + } + // `.` (or `/`) is the directory every source path starts from, whose copy is the + // target itself: its contents are copied there, as for `cp -R src/. target`. Its + // last component taken for a name, `target/.` was copied into `target/target`. + _ => { + let copied = copy_file( + cfg, + source, + target, + OperandTrust::Named, + &mut run, + inode_map.as_deref_mut(), + prompt_fn, + ); + if let Err(e) = copied { + let s = error_string(&e); + if !s.is_empty() { + eprintln!("cp: {s}"); + } + ok = false; + } + continue; + } + } let walked = make_parents( source, target, diff --git a/tree/tests/cp/existing_dir.rs b/tree/tests/cp/existing_dir.rs index 1e8f0f3fb..2691e460f 100644 --- a/tree/tests/cp/existing_dir.rs +++ b/tree/tests/cp/existing_dir.rs @@ -302,3 +302,47 @@ fn cp_pr_judges_a_found_directory_in_an_unreadable_destination() { assert_eq!(mode_of(&r.join("s2")), 0o750); assert_eq!(mtime_of(&r.join("s2")), OLD_MTIME); } + +/// `--parents .` copies the working directory's contents into the target itself, as GNU cp +/// does -- not into a directory of the target's own name inside it. +#[test] +fn cp_parents_dot_copies_into_the_target_itself() { + let temp = tempdir().unwrap(); + let sub = temp.path().join("sub"); + fs::create_dir_all(sub.join("d")).unwrap(); + fs::write(sub.join("d/f"), "f\n").unwrap(); + fs::create_dir(temp.path().join("t")).unwrap(); + + let out = cp(&sub, &["-R", "--parents", ".", "../t"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(0), "stderr: {stderr}"); + let t = temp.path().join("t"); + assert_eq!(fs::read_to_string(t.join("d/f")).unwrap(), "f\n"); + assert!( + !t.join("t").exists(), + "copied into a directory named like the target" + ); +} + +/// A source ending in `..` has a destination ending in `..` too: not a name inside the target, +/// where `--parents` copies, but whatever directory that `..` reaches, which no chain of trust +/// from the target describes. It is refused, and nothing there is touched. +#[test] +fn cp_parents_refuses_a_source_ending_in_dot_dot() { + let temp = tempdir().unwrap(); + let a = temp.path().join("a"); + source_dir(&a.join("g"), 0o777); + let c = a.join("c"); + fs::create_dir_all(c.join("g")).unwrap(); + set_mode(&c.join("g"), 0o755); + fs::create_dir(c.join("t")).unwrap(); + set_mode(&c.join("t"), 0o777); + + let out = cp(&c, &["-pR", "--parents", "..", "t"]); + let stderr = String::from_utf8_lossy(&out.stderr); + set_mode(&c.join("t"), 0o755); + assert_eq!(out.status.code(), Some(1), "stderr: {stderr}"); + assert!(stderr.contains("'..'"), "stderr: {stderr}"); + assert_eq!(mode_of(&c.join("g")), 0o755); + assert!(!c.join("g/f").exists()); +} From d9395ae6af38f52eec2d378bde8f79498677800f Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 18:44:41 +0000 Subject: [PATCH 21/71] cp: count a directory as made by the run only while nobody else has changed it The run-wide record of the directories cp made was keyed by inode number and path. An inode number is reused -- ext4 hands a freed one straight back -- so whoever could remove an empty directory cp made (from a parent they can write that is not sticky) could make one of their own at its path under its number, and a later operand found it and gave it the source's attributes as the run's own. The record now also holds the directory's status-change time as cp last left it, and a directory found counts as made only with that time unchanged. A directory made and replaced has its making for a status- change time, after cp's last change. cp notes the time again after it fills and finishes a directory it holds as its own, from the descriptor held throughout (a directory held open keeps its number), so an honest later operand -- `cp -pR s1/x s2/x s3/x dir`, or --parents walking through what earlier operands made -- still finds it unchanged. The residual is the grain of the timestamp clock: a directory removed and remade under the same number within the tick of cp's last change passes. The birth time would not narrow that, being stamped from the same clock. Co-Authored-By: Claude Opus 5.5 --- tree/common/copy.rs | 205 ++++++++++++++++++++++++++++++++++++-------- tree/parents.rs | 55 +++++++++--- 2 files changed, 210 insertions(+), 50 deletions(-) diff --git a/tree/common/copy.rs b/tree/common/copy.rs index af9bc70bb..dce70cc47 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -248,20 +248,57 @@ pub enum OperandTrust { /// of a directory made (`ChainTrust::made`), wherever it is. Found at any other path it is one /// found existing there, as pax counts it: whoever renamed it there could have renamed it to /// the name of a source directory it does not stand for, and it would take that one's mode. -/// Recording a directory replaces whatever its number stood for: a directory made can reuse -/// the number of one removed during the run. +/// +/// Nor is it the run's own once anything but cp has changed it since cp last did: its +/// status-change time must be the one cp noted then. An inode number is reused -- ext4 hands a +/// freed one straight back -- so whoever could remove the directory (an empty one, from a +/// parent they can write that is not sticky) could make one of their own at its path under its +/// number; that one's status-change time is its making, after cp's last change. cp notes its +/// own changes from the descriptor it has held throughout -- a directory held open keeps its +/// number from being reused -- once it has filled and finished one (`refresh`), so an honest +/// later operand still finds it unchanged. Residual: a status-change time has the grain of the +/// clock tick the kernel stamps it with (a few milliseconds on Linux without fine-grained +/// timestamps), and a directory removed and remade within the tick of cp's last change, under +/// the same number, passes for the one made. The birth time (statx, `st_birthtime`) would not +/// narrow that: it is stamped from the same clock. Recording a directory replaces whatever its +/// number stood for. #[derive(Default)] -pub struct MadeDirs(HashMap<(u64, u64), PathBuf>); +pub struct MadeDirs(HashMap<(u64, u64), MadeAt>); -impl MadeDirs { - /// Record the directory with `id`, just made at `path` and verified in full. - pub fn record(&mut self, id: (u64, u64), path: &Path) { - self.0.insert(id, path.to_path_buf()); - } +/// Where a directory this run made is, and its status-change time when cp last changed it. +struct MadeAt { + path: PathBuf, + ctime: (i64, i64), +} + +/// `(st_dev, st_ino)` and `st_ctim` of `md`. +fn id_and_ctime(md: &impl MetadataExt) -> ((u64, u64), (i64, i64)) { + ((md.dev(), md.ino()), (md.ctime(), md.ctime_nsec())) +} - /// Whether the directory with `id`, found at `path`, is one this run made there. - pub fn made_at(&self, id: (u64, u64), path: &Path) -> bool { - self.0.get(&id).is_some_and(|at| at == path) +impl MadeDirs { + /// Record the directory `md` describes, just made at `path` and verified in full. + pub fn record(&mut self, md: &impl MetadataExt, path: &Path) { + let (id, ctime) = id_and_ctime(md); + let path = path.to_path_buf(); + self.0.insert(id, MadeAt { path, ctime }); + } + + /// Whether the directory `md` describes, found at `path`, is one this run made there. + pub fn made_at(&self, md: &impl MetadataExt, path: &Path) -> bool { + let (id, ctime) = id_and_ctime(md); + self.0 + .get(&id) + .is_some_and(|at| at.path == path && at.ctime == ctime) + } + + /// Note what cp has just changed on the directory `md` describes, which this run made at + /// `path` and has held since it found it to be so (`made_at`). + pub fn refresh(&mut self, md: &impl MetadataExt, path: &Path) { + let (id, ctime) = id_and_ctime(md); + if let Some(at) = self.0.get_mut(&id).filter(|at| at.path == path) { + at.ctime = ctime; + } } } @@ -292,6 +329,8 @@ struct DestDir { /// What it is given once its contents are copied; `None` for the operand's own directory, /// which the copy only makes the operand in. finish: Option, + /// Whether this run made it, here (`MadeDirs`): what cp changes on it is then noted. + own: bool, } /// What a directory that may take what -p asks of it is given: `ChainTrust::found_dir` where @@ -362,23 +401,18 @@ fn parent_anchor( /// `id`, in the directory `parent` handing it `hands`: what it is given once its contents are /// copied, and what it hands the directories found in it. /// -/// One this run made at that path is its own (`MadeDirs`). Any other takes what -p asks only -/// where nobody else could have created its name, in its parent or any directory above it up -/// to the anchor; elsewhere it is left as it is, times included, and that is reported -/// (`ChainTrust::found_dir`, which pax follows too). +/// It takes what -p asks only where nobody else could have created its name, in its parent or +/// any directory above it up to the anchor; elsewhere it is left as it is, times included, and +/// that is reported (`ChainTrust::found_dir`, which pax follows too). (One this run made at that +/// path is its own, and not judged at all: `own_dir_trust`.) fn found_dir_trust( hands: OperandTrust, parent: &ftw::FileDescriptor, fd: &ftw::FileDescriptor, id: (u64, u64), target: &Path, - made_dirs: &MadeDirs, requested: Preserve, ) -> io::Result<(DirFinish, ChainTrust)> { - if made_dirs.made_at(id, target) { - let finish = DirFinish::Found(as_asked(requested)); - return Ok((finish, ChainTrust::made(fd.as_raw_fd())?)); - } let chain = match hands { OperandTrust::Named => { let finish = DirFinish::Found(as_asked(requested)); @@ -391,6 +425,17 @@ fn found_dir_trust( Ok((finish, chain.found(fd.as_raw_fd())?)) } +/// For a directory found existing, open on `fd`, that this run made at the same path +/// (`MadeDirs::made_at`): what it is given once its contents are copied -- what -p asks, as +/// for any directory the run made -- and the trust it hands, afresh. +fn own_dir_trust( + fd: &ftw::FileDescriptor, + requested: Preserve, +) -> io::Result<(DirFinish, ChainTrust)> { + let finish = DirFinish::Found(as_asked(requested)); + Ok((finish, ChainTrust::made(fd.as_raw_fd())?)) +} + /// The -p failure reported for a directory found existing where others could have created its /// name (`FoundDir::LeaveAlone`): nothing of it is changed, times included. fn found_dir_left_alone(target: &Path) -> io::Error { @@ -2112,6 +2157,7 @@ where fd: top_dir, hands: top_trust, finish: None, + own: false, }]); // (st_dev, st_ino) of every destination directory this copy creates or enters. A source // directory found in here is one we are copying *into*. @@ -2300,16 +2346,20 @@ where target.display() ))); } - let (finish, trust) = found_dir_trust( - hands, - target_dirfd, - &fd, - (dev, ino), - &target, - &made_by_run.borrow(), - requested, - )?; - Ok((fd, md, finish, trust)) + let own = made_by_run.borrow().made_at(&md, &target); + let (finish, trust) = if own { + own_dir_trust(&fd, requested)? + } else { + found_dir_trust( + hands, + target_dirfd, + &fd, + (dev, ino), + &target, + requested, + )? + }; + Ok((fd, md, finish, trust, own)) }), DirOrigin::Made => open_made_dir( target_dirfd.as_raw_fd(), @@ -2320,30 +2370,28 @@ where .and_then(|(fd, made)| { let fd = ftw::FileDescriptor::from(fd); let md = fd_metadata(fd.as_raw_fd())?; - let id = (md.dev(), md.ino()); - let trust = if made == MadeTrust::Full { - made_by_run.borrow_mut().record(id, &target); + let own = made == MadeTrust::Full; + let trust = if own { + made_by_run.borrow_mut().record(&md, &target); ChainTrust::made(fd.as_raw_fd())? } else { // Owned like its parent only, it may be someone else's: it // hands on what a directory found would. - let made_dirs = made_by_run.borrow(); let (_, trust) = found_dir_trust( hands, target_dirfd, &fd, - id, + (md.dev(), md.ino()), &target, - &made_dirs, requested, )?; trust }; report_copied(cfg, source.path().as_inner(), &target, true); - Ok((fd, md, DirFinish::Made(made), trust)) + Ok((fd, md, DirFinish::Made(made), trust, own)) }), }; - let (new_target_dirfd, new_target_md, finish, trust) = match opened { + let (new_target_dirfd, new_target_md, finish, trust, own) = match opened { Ok(pair) => pair, Err(e) => { if cfg.continue_on_error { @@ -2371,6 +2419,7 @@ where fd: Rc::new(new_target_dirfd), hands: OperandTrust::Chain(trust), finish: Some(finish), + own, }); target_dir_path_borrowed.push(target_filename); @@ -2423,6 +2472,7 @@ where if let Some(DestDir { fd, finish: Some(finish), + own, .. }) = target_dir { @@ -2441,6 +2491,14 @@ where *had_error.borrow_mut() = true; } } + // Filled and finished through the descriptor held since it was found to be the + // run's own, it still is: what that changed is noted, so a later operand finding + // it unchanged since knows it again. + if own { + if let Ok(md) = fd_metadata(fd.as_raw_fd()) { + made_by_run.borrow_mut().refresh(&md, &dir_path); + } + } } Ok(()) @@ -2721,6 +2779,77 @@ mod tests { assert_eq!(after.mtime(), 978_307_200, "the times were not applied"); } + /// A directory this run made stops counting as made once anything but cp changes it -- + /// above all once it is removed and another made at its path, which may take its inode + /// number. What cp changes itself, it notes (`MadeDirs::refresh`). + #[test] + fn a_made_directory_changed_since_is_no_longer_made() { + use super::MadeDirs; + use std::fs; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let tmp = plib::tmp::tempdir().unwrap(); + let path = tmp.path().join("x"); + let ctime_moves = |path: &std::path::Path, before: &fs::Metadata| { + // ctime has the clock's tick for its grain; wait the change into a later one. + loop { + let mode = fs::metadata(path).unwrap().mode() ^ 0o001; + fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap(); + let after = fs::metadata(path).unwrap(); + if (after.ctime(), after.ctime_nsec()) != (before.ctime(), before.ctime_nsec()) { + return after; + } + } + }; + + fs::create_dir(&path).unwrap(); + let mut made = MadeDirs::default(); + let md = fs::metadata(&path).unwrap(); + made.record(&md, &path); + assert!(made.made_at(&md, &path)); + assert!( + !made.made_at(&md, &tmp.path().join("y")), + "found at another path" + ); + + // Changed by someone else. + let changed = ctime_moves(&path, &md); + assert!(!made.made_at(&changed, &path), "changed since it was made"); + // Changed by cp, which notes it. + made.refresh(&changed, &path); + assert!(made.made_at(&changed, &path)); + + // Removed, and another made at its path: when it takes the same inode number (ext4 + // often hands it straight back), it still is not the one made. + let mut reused = false; + for _ in 0..64 { + let before = fs::metadata(&path).unwrap(); + made.record(&before, &path); + fs::remove_dir(&path).unwrap(); + fs::create_dir(&path).unwrap(); + let other = fs::metadata(&path).unwrap(); + if other.ino() == before.ino() { + reused = true; + let other = if (other.ctime(), other.ctime_nsec()) + == (before.ctime(), before.ctime_nsec()) + { + // Same tick: the residual `MadeDirs` documents. Move it on. + ctime_moves(&path, &before) + } else { + other + }; + assert!( + !made.made_at(&other, &path), + "a new directory passed for the made one" + ); + break; + } + } + if !reused { + eprintln!("note: this filesystem did not reuse an inode number; reuse not exercised"); + } + } + /// `cp -pR`'s configuration. fn cp_pr_config() -> super::CopyConfig { super::CopyConfig { diff --git a/tree/parents.rs b/tree/parents.rs index ea20a9f59..29f36bf7c 100644 --- a/tree/parents.rs +++ b/tree/parents.rs @@ -54,12 +54,6 @@ fn open_dir_at(dirfd: libc::c_int, name: &CString, extra_flags: libc::c_int) -> Ok(File::from(unsafe { OwnedFd::from_raw_fd(fd) })) } -/// `(st_dev, st_ino)` of the directory open as `dir`. -fn dir_id(dir: &File) -> io::Result<(u64, u64)> { - let md = dir.metadata()?; - Ok((md.dev(), md.ino())) -} - fn cstring(bytes: &[u8]) -> io::Result { CString::new(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e)) } @@ -85,12 +79,18 @@ fn make_parents( preserve: bool, verbose: bool, made_dirs: &mut MadeDirs, -) -> io::Result<(Vec, File, ChainTrust)> { +) -> io::Result { let mut made = Vec::new(); + let mut own = Vec::new(); let mut dest_dir = open_dir_at(libc::AT_FDCWD, &cstring(target.as_os_str().as_bytes())?, 0)?; let mut trust = ChainTrust::anchor(dest_dir.as_raw_fd())?; let Some(parent) = source.parent() else { - return Ok((made, dest_dir, trust)); + return Ok(Walked { + made, + own, + dest: dest_dir, + trust, + }); }; let start = if source.is_absolute() { "/" } else { "." }; let mut src_dir = open_dir_at(libc::AT_FDCWD, &cstring(start.as_bytes())?, 0)?; @@ -146,7 +146,8 @@ fn make_parents( .map_err(|e| made_dir_open_error(&dest_path, e))?; let next_dest = File::from(opened); trust = if made_trust == MadeTrust::Full { - made_dirs.record(dir_id(&next_dest)?, &dest_path); + made_dirs.record(&next_dest.metadata()?, &dest_path); + own.push((next_dest.try_clone()?, dest_path.clone())); ChainTrust::made(next_dest.as_raw_fd())? } else { // Owned like its parent only, it may be someone else's: it hands on what a @@ -172,7 +173,8 @@ fn make_parents( error_string(&e) )) })?; - trust = if made_dirs.made_at(dir_id(&found)?, &dest_path) { + trust = if made_dirs.made_at(&found.metadata()?, &dest_path) { + own.push((found.try_clone()?, dest_path.clone())); ChainTrust::made(found.as_raw_fd())? } else { trust.found(found.as_raw_fd())? @@ -182,7 +184,25 @@ fn make_parents( src_dir = next_src; dest_dir = next_dest; } - Ok((made, dest_dir, trust)) + Ok(Walked { + made, + own, + dest: dest_dir, + trust, + }) +} + +/// What `make_parents` walked to. +struct Walked { + /// The directories it made. + made: Vec, + /// The directories on the way this run made, now or earlier (`MadeDirs`), and where: the + /// copy below them changes them, and that is noted once it is done (`MadeDirs::refresh`). + own: Vec<(File, PathBuf)>, + /// The last directory, where the copy itself goes. + dest: File, + /// The trust that directory hands the directories the copy finds in it. + trust: ChainTrust, } /// GNU `cp -v --parents` for a directory made on the way: `source -> dest`, unquoted, unlike @@ -279,7 +299,12 @@ where cfg.verbose.is_some(), &mut run.made_dirs, ); - let (made, dest_dir, trust) = match walked { + let Walked { + made, + own, + dest: dest_dir, + trust, + } = match walked { Ok(walked) => walked, Err(e) => { eprintln!("cp: {}", error_string(&e)); @@ -315,6 +340,12 @@ where ok = false; } } + // Held since they were found to be the run's own, they still are. + for (dir, path) in &own { + if let Ok(md) = dir.metadata() { + run.made_dirs.refresh(&md, path); + } + } } ok } From a1eba38ed8f4efb5a47ab451695630ad48d1dcb1 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 18:50:48 +0000 Subject: [PATCH 22/71] plib: count group write permission for the user's private group as the user's own cp -p and pax -p e leave a directory found existing alone where someone else could have created its name, and counted any group-writable directory on the way as such a place. Under a umask of 002 with user private groups (Debian's adduser default) every directory the user makes is group-writable, so the user's own destinations were refused: cp -a into one exited 1 with a diagnostic per directory found. By the user's decision, plib::madefs::nobody_else_can_create -- which cp and pax both reach through ChainTrust -- now counts group write permission as the user's own when the directory's group is the user's private group: the user's primary group, listing no member but the user, and nobody else's primary group (is_private_group). The databases are read through NSS, the whole passwd database through getpwent (new: plib::user::load, which reports an error rather than returning a shorter list), once per group in a process. Anything that cannot be read counts as not private. The doc comment records the residuals: NSS sources that do not enumerate, members known only to getgrouplist, group passwords, and changes made after the answer is read. The rule itself (group_is_private) and the mode test around it (nobody_else_can_create_with) are unit-tested without the databases. New cp and pax tests check that a destination of the user's private group under umask 002 is stamped; they skip with a note on a host without user private groups. The tests that need a group-writable destination others can write now give it a group the user shares (plib::testing::shared_group), or are world-writable where only the chain was under test. debhelper's cp_n_recursive_merges_without_overwriting no longer needs its 0755 workaround. Co-Authored-By: Claude Opus 5.5 --- pax/tests/security/existing_dir.rs | 40 +++++- plib/src/madefs.rs | 194 ++++++++++++++++++++++++++--- plib/src/testing.rs | 27 ++++ plib/src/user.rs | 31 +++++ tree/tests/cp/debhelper.rs | 3 - tree/tests/cp/existing_dir.rs | 42 ++++++- 6 files changed, 308 insertions(+), 29 deletions(-) diff --git a/pax/tests/security/existing_dir.rs b/pax/tests/security/existing_dir.rs index d0b099dad..4dd712d73 100644 --- a/pax/tests/security/existing_dir.rs +++ b/pax/tests/security/existing_dir.rs @@ -133,12 +133,18 @@ fn test_reextract_into_a_group_writable_destination_without_p() { assert_eq!(mode_of(&dest.join("d")), 0o700); } -/// The same with -p e: group members could have created the name first. +/// The same with -p e, the group being one others are in: they could have created the name +/// first. #[test] fn test_reextract_into_a_group_writable_destination_with_pe() { + let Some(shared) = plib::testing::shared_group() else { + eprintln!("note: the user belongs to no group shared with others; test skipped"); + return; + }; let temp = TempDir::new().unwrap(); let archive = archive_with_open_directory(&temp); let dest = dest_with_private_d(&temp, 0o775); + std::os::unix::fs::chown(&dest, None, Some(shared)).unwrap(); let out = pax(&dest, &["-r", "-p", "e", "-f", archive.to_str().unwrap()]); let stderr = String::from_utf8_lossy(&out.stderr); @@ -147,6 +153,32 @@ fn test_reextract_into_a_group_writable_destination_with_pe() { assert_eq!(mode_of(&dest.join("d")), 0o700); } +/// Under a umask of 002 the destination is group-writable; when its group is +/// the user's private group -- nobody else in it, nobody else's primary +/// group -- that write permission is the user's own, and -p e gives the +/// existing directory the member's mode, in both modes. +#[test] +fn test_pe_stamps_an_existing_directory_in_a_destination_of_the_users_private_group() { + if plib::testing::user_private_group().is_none() { + eprintln!("note: this host gives the user no private group; test skipped"); + return; + } + for copy in [false, true] { + let temp = TempDir::new().unwrap(); + let archive = archive_with_open_directory(&temp); + let src = temp.path().join("src"); + let dest = dest_with_private_d(&temp, 0o775); + let out = if copy { + pax(&src, &["-rw", "-p", "e", "d", dest.to_str().unwrap()]) + } else { + pax(&dest, &["-r", "-p", "e", "-f", archive.to_str().unwrap()]) + }; + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "copy={copy}: stderr: {stderr}"); + assert_eq!(mode_of(&dest.join("d")), 0o755, "copy={copy}"); + } +} + /// In a destination only the user can create entries in, -p e gives an /// existing directory the member's mode, as POSIX describes. #[test] @@ -248,7 +280,8 @@ fn test_extract_trust_holds_along_the_chain() { for (privs, code) in [(Some("e"), 1), (None, 0)] { let temp = TempDir::new().unwrap(); let (_, archive) = deep_source(&temp); - let dest = dest_with_renamed_chain(&temp, 0o775); + // World-writable: others can write it whatever its group. + let dest = dest_with_renamed_chain(&temp, 0o777); let mut args = vec!["-r"]; if let Some(p) = privs { args.extend(["-p", p]); @@ -273,7 +306,8 @@ fn test_copy_trust_holds_along_the_chain() { for (privs, code) in [(Some("e"), 1), (None, 0)] { let temp = TempDir::new().unwrap(); let (src, _) = deep_source(&temp); - let dest = dest_with_renamed_chain(&temp, 0o775); + // World-writable: others can write it whatever its group. + let dest = dest_with_renamed_chain(&temp, 0o777); let mut args = vec!["-rw"]; if let Some(p) = privs { args.extend(["-p", p]); diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index 72c388321..f503e13cd 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -19,6 +19,7 @@ #[cfg(target_os = "linux")] use gettextrs::gettext; +use std::collections::HashMap; use std::ffi::CStr; #[cfg(target_os = "linux")] use std::ffi::CString; @@ -28,6 +29,7 @@ use std::io; use std::os::fd::RawFd; #[cfg(target_os = "linux")] use std::os::fd::{AsRawFd, FromRawFd}; +use std::sync::Mutex; /// Open flags for a directory that is only ever walked through, used as the `dirfd` of an `*at` /// call, or `fstat`ed (`ChainTrust`). @@ -286,20 +288,96 @@ fn nobody_else_can_create_in(fd: RawFd) -> io::Result { } /// Whether nobody but `euid` can create entries in the directory `parent`: it is owned by -/// `euid` and grants no group or other write permission. A sticky directory others may write -/// counts as one they can create entries in. +/// `euid` and grants no other write permission, and no group write permission either unless +/// its group is the user's private group (`is_private_group`) -- the user's alone, so the +/// directories a umask of 002 leaves group-writable, as Debian-style user private groups +/// intend, count as the user's. A sticky directory others may write counts as one they can +/// create entries in. /// /// Only what `st_mode` shows is seen. Write permission a POSIX ACL grants to named users or /// groups shows there (in the group bits, the ACL mask); write permission a macOS or NFSv4 ACL /// grants does not, and is not taken into account. That is a residual: below a directory such /// an ACL lets others write, a directory found existing -- possibly one of theirs renamed -/// there -- is given the mode or owner asked for. +/// there -- is given the mode or owner asked for. (A POSIX ACL's named entries widen the mask, +/// and so the group bits, and the group then still has to be private: only its owning group's +/// members are counted, not the ACL's named users. That is a residual too.) pub fn nobody_else_can_create(parent: &libc::stat, euid: u32) -> bool { - // Cast needed: `mode_t` is u16 on macOS and u32 on Linux. S_IWGRP|S_IWOTH is 0o022 (fixed - // by POSIX). + nobody_else_can_create_with(parent, euid, is_private_group) +} + +/// `nobody_else_can_create`, asking `private(gid, euid)` whether a group is the user's alone. +fn nobody_else_can_create_with( + parent: &libc::stat, + euid: u32, + private: impl FnOnce(u32, u32) -> bool, +) -> bool { + // Cast needed: `mode_t` is u16 on macOS and u32 on Linux. S_IWGRP is 0o020 and S_IWOTH + // 0o002 (fixed by POSIX). #[allow(clippy::unnecessary_cast)] let mode = parent.st_mode as u32; - parent.st_uid == euid && mode & 0o022 == 0 + if parent.st_uid != euid || mode & 0o002 != 0 { + return false; + } + mode & 0o020 == 0 || private(parent.st_gid, euid) +} + +/// Whether the group `gid` is the private group of the user `euid`: nobody else is in it, so +/// its write permission is the user's own. All of these must hold: +/// - it is the user's primary group; +/// - it lists no members but, perhaps, the user; +/// - no other user has it for a primary group. +/// +/// What the user and group databases say is read through NSS (`getpwuid`, `getgrgid`, and the +/// whole passwd database through `getpwent`), once per group in a process. Anything that cannot +/// be read -- the user, the group, an error while enumerating -- counts as not private. +/// +/// Residuals: +/// - An NSS source that does not enumerate (sssd or LDAP with enumeration off, as is usual) +/// hands `getpwent` only the users it does list, without an error: a user it holds who has +/// the group for a primary group goes unseen. Nothing distinguishes that from a source with +/// no such user, so it cannot fail closed. +/// - Members known only to `getgrouplist` (an NSS source that adds supplementary groups it +/// does not list in `gr_mem`) are not seen either. +/// - A group password lets anyone who knows it `newgrp` into the group; with shadow groups +/// it is out of the user's reach to read, and is not considered. +/// - Users and members added after the answer is read, for the rest of the process. +pub fn is_private_group(gid: u32, euid: u32) -> bool { + static ANSWERS: Mutex>> = Mutex::new(None); + let mut answers = ANSWERS.lock().unwrap_or_else(|e| e.into_inner()); + let answers = answers.get_or_insert_with(HashMap::new); + *answers + .entry((gid, euid)) + .or_insert_with(|| read_private_group(gid, euid)) +} + +/// `is_private_group`, read from the databases. +fn read_private_group(gid: u32, euid: u32) -> bool { + let Some(user) = crate::user::get_by_uid(euid) else { + return false; + }; + let Some(group) = crate::group::get_by_gid(gid) else { + return false; + }; + let Ok(users) = crate::user::load() else { + return false; + }; + let primaries = users.iter().map(|other| (other.uid, other.gid)); + group_is_private(gid, &user, &group.members, primaries) +} + +/// The rule `is_private_group` follows, for the group `gid` and the user `user`, given the +/// names the group lists as members and every user's `(uid, primary gid)`. +pub fn group_is_private( + gid: u32, + user: &crate::user::User, + members: &[std::ffi::OsString], + primaries: impl IntoIterator, +) -> bool { + user.gid == gid + && members.iter().all(|member| *member == user.name) + && primaries + .into_iter() + .all(|(uid, primary)| primary != gid || uid == user.uid) } /// Check a directory the caller has just made with `mkdirat` in `parent_fd` and then opened as @@ -536,9 +614,9 @@ pub fn utimens_link_if_still( #[cfg(test)] mod tests { use super::{ - empty_lending_read, made_by_us, nobody_else_can_create, others_can_rename, - utimens_link_if_still, verify_made_dir, ChainTrust, FoundDir, FsOwners, MadeObject, - MadeTrust, Preserve, + empty_lending_read, group_is_private, is_private_group, made_by_us, + nobody_else_can_create_with, others_can_rename, utimens_link_if_still, verify_made_dir, + ChainTrust, FoundDir, FsOwners, MadeObject, MadeTrust, Preserve, }; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -555,17 +633,97 @@ mod tests { /// entries beside it. #[test] fn who_can_create_in_a_parent() { + let shared = |st: &libc::stat, euid| nobody_else_can_create_with(st, euid, |_, _| false); + let private = |st: &libc::stat, euid| nobody_else_can_create_with(st, euid, |_, _| true); // Nobody but the user can. - assert!(nobody_else_can_create(&parent(US, 0o755), US)); - assert!(nobody_else_can_create(&parent(0, 0o755), 0)); + assert!(shared(&parent(US, 0o755), US)); + assert!(shared(&parent(0, 0o755), 0)); // A sticky directory others may write -- /tmp, root extracting into it too. - assert!(!nobody_else_can_create(&parent(US, 0o1777), US)); - assert!(!nobody_else_can_create(&parent(0, 0o1777), 0)); + assert!(!shared(&parent(US, 0o1777), US)); + assert!(!shared(&parent(0, 0o1777), 0)); + assert!(!private(&parent(US, 0o1777), US)); // Group or other write permission. - assert!(!nobody_else_can_create(&parent(US, 0o775), US)); - assert!(!nobody_else_can_create(&parent(US, 0o757), US)); + assert!(!shared(&parent(US, 0o775), US)); + assert!(!shared(&parent(US, 0o757), US)); + // Group write permission for the user's private group is the user's own; other + // write permission never is. + assert!(private(&parent(US, 0o775), US)); + assert!(private(&parent(US, 0o2775), US)); + assert!(!private(&parent(US, 0o777), US)); + assert!(!private(&parent(US, 0o757), US)); // Someone else's directory: its owner can. - assert!(!nobody_else_can_create(&parent(OTHER, 0o755), US)); + assert!(!shared(&parent(OTHER, 0o755), US)); + assert!(!private(&parent(OTHER, 0o775), US)); + // The group asked about is the directory's. + let mut st = parent(US, 0o775); + st.st_gid = 4242; + assert!(nobody_else_can_create_with(&st, US, |gid, euid| gid + == 4242 + && euid == US)); + assert!(!nobody_else_can_create_with(&st, US, |gid, _| gid != 4242)); + } + + /// A group is the user's private one only when it is the user's primary group, lists + /// nobody else, and is nobody else's primary group. + #[test] + fn what_makes_a_group_private() { + let user = crate::user::User { + name: "us".into(), + uid: US, + gid: 500, + gecos: Default::default(), + dir: Default::default(), + shell: Default::default(), + }; + let names = |list: &[&str]| { + list.iter() + .map(std::ffi::OsString::from) + .collect::>() + }; + let only_ours = [(US, 500), (OTHER, 600), (0, 0)]; + assert!(group_is_private(500, &user, &[], only_ours)); + assert!(group_is_private(500, &user, &names(&["us"]), only_ours)); + // Not the user's primary group. + assert!(!group_is_private(600, &user, &[], only_ours)); + // Another member listed. + assert!(!group_is_private( + 500, + &user, + &names(&["us", "them"]), + only_ours + )); + assert!(!group_is_private(500, &user, &names(&["them"]), only_ours)); + // Another user's primary group too. + assert!(!group_is_private( + 500, + &user, + &[], + [(US, 500), (OTHER, 500)] + )); + // A second entry for the user's own uid is the user. + assert!(group_is_private(500, &user, &[], [(US, 500), (US, 500)])); + } + + /// The test user's own primary group, read from the real databases, agrees with the rule + /// applied to what those databases list -- and an unknown user or group is never private. + #[test] + fn private_group_lookup_follows_the_databases() { + let euid = unsafe { libc::geteuid() }; + assert!(!is_private_group(u32::MAX - 1, euid)); + assert!(!is_private_group(0, u32::MAX - 1)); + let Some(user) = crate::user::get_by_uid(euid) else { + return; + }; + let (Some(group), Ok(users)) = (crate::group::get_by_gid(user.gid), crate::user::load()) + else { + assert!(!is_private_group(user.gid, euid)); + return; + }; + let primaries = users.iter().map(|u| (u.uid, u.gid)); + let expected = group_is_private(user.gid, &user, &group.members, primaries); + assert_eq!(is_private_group(user.gid, euid), expected); + // Asked again, the answer is the one read. + assert_eq!(is_private_group(user.gid, euid), expected); } /// A found directory gets its times only unless mode or owner was asked for; then what was @@ -576,9 +734,9 @@ mod tests { fn a_found_directory_gets_what_was_asked_only_down_a_trusted_chain() { let tmp = crate::tmp::TempDir::new().unwrap(); let root = tmp.path(); - // anchor 0755 / g 0775 (someone else can create here) / x 0755 / d + // anchor 0755 / g 0777 (someone else can create here, whatever its group) / x 0755 / d std::fs::create_dir_all(root.join("g/x/d")).unwrap(); - for (dir, mode) in [("", 0o755), ("g", 0o775), ("g/x", 0o755)] { + for (dir, mode) in [("", 0o755), ("g", 0o777), ("g/x", 0o755)] { let path = root.join(dir); std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); } diff --git a/plib/src/testing.rs b/plib/src/testing.rs index 7a78ef36c..3ea52aa09 100644 --- a/plib/src/testing.rs +++ b/plib/src/testing.rs @@ -741,6 +741,33 @@ impl AsRef for TempFile { } } +/// The effective user's private group (`plib::madefs::is_private_group`), when its primary +/// group is one and is also the process's effective group, so that the directories a test +/// makes belong to it. `None` on a host without user private groups. +#[cfg(unix)] +pub fn user_private_group() -> Option { + let euid = unsafe { libc::geteuid() }; + let gid = crate::user::get_by_uid(euid)?.gid; + let ours = gid == unsafe { libc::getegid() }; + (ours && crate::madefs::is_private_group(gid, euid)).then_some(gid) +} + +/// A group the effective user belongs to that is not the user's private group, which a test can +/// give a directory (`chown`) to make its group write permission someone else's too. `None` +/// when the user belongs to no such group. +#[cfg(unix)] +pub fn shared_group() -> Option { + let euid = unsafe { libc::geteuid() }; + let count = unsafe { libc::getgroups(0, std::ptr::null_mut()) }; + let mut groups = vec![0 as libc::gid_t; usize::try_from(count).ok()?]; + let count = unsafe { libc::getgroups(count, groups.as_mut_ptr()) }; + groups.truncate(usize::try_from(count).ok()?); + groups.push(unsafe { libc::getegid() }); + groups + .into_iter() + .find(|&gid| !crate::madefs::is_private_group(gid, euid)) +} + #[cfg(test)] mod tests { use super::{read_until_full, TempFile}; diff --git a/plib/src/user.rs b/plib/src/user.rs index 78f67d5e7..845c9ff9a 100644 --- a/plib/src/user.rs +++ b/plib/src/user.rs @@ -24,6 +24,7 @@ use std::ffi::{CString, OsStr, OsString}; use std::io; use std::os::unix::ffi::OsStrExt; use std::path::PathBuf; +use std::sync::Mutex; /// A user account from the system user database. #[derive(Clone, Debug, PartialEq, Eq)] @@ -69,6 +70,36 @@ impl User { } } +/// Every user in the password database, through `setpwent`/`getpwent`/`endpwent`. +/// +/// An error while reading is an error, never a shorter list: `getpwent` returns NULL both at +/// the end and on a failure, which only `errno` tells apart (ENOENT, which glibc's NSS leaves +/// at the end, counts as the end). The enumeration is the process's one, so callers here take +/// turns. +pub fn load() -> io::Result> { + static ENUMERATING: Mutex<()> = Mutex::new(()); + let _turn = ENUMERATING.lock().unwrap_or_else(|e| e.into_inner()); + let mut users = Vec::new(); + let mut result = Ok(()); + unsafe { + libc::setpwent(); + loop { + errno::set_errno(errno::Errno(0)); + let passwd = libc::getpwent(); + if passwd.is_null() { + let e = errno::errno().0; + if e != 0 && e != libc::ENOENT { + result = Err(io::Error::from_raw_os_error(e)); + } + break; + } + users.push(User::from_passwd(&*passwd)); + } + libc::endpwent(); + } + result.map(|()| users) +} + /// Look up a user by name. /// /// `Ok(None)` means the database has no such user. `Err` means the lookup diff --git a/tree/tests/cp/debhelper.rs b/tree/tests/cp/debhelper.rs index cd7156b6e..25856efb3 100644 --- a/tree/tests/cp/debhelper.rs +++ b/tree/tests/cp/debhelper.rs @@ -101,9 +101,6 @@ fn cp_n_recursive_merges_without_overwriting() { fs::write(d.join("src/b"), "new b\n").unwrap(); fs::create_dir_all(d.join("dst/src")).unwrap(); fs::write(d.join("dst/src/a"), "old a\n").unwrap(); - // Only the user can create entries in `dst`, whatever the umask, so the `dst/src` found - // there takes the source's attributes (tests/cp/existing_dir.rs has the other case). - fs::set_permissions(d.join("dst"), fs::Permissions::from_mode(0o755)).unwrap(); let (err, code) = cp_in(&d, &["-an", "src", "dst/"]); assert_eq!((err.as_str(), code), ("", 0)); assert_eq!(fs::read_to_string(d.join("dst/src/a")).unwrap(), "old a\n"); diff --git a/tree/tests/cp/existing_dir.rs b/tree/tests/cp/existing_dir.rs index 2691e460f..1d176f2ee 100644 --- a/tree/tests/cp/existing_dir.rs +++ b/tree/tests/cp/existing_dir.rs @@ -88,14 +88,28 @@ fn secrets_scenario(dest_mode: u32) -> TempDir { temp } +/// Give `dir` a group the user shares with others (`plib::testing::shared_group`), so that +/// its group write permission is theirs too; `false`, with a note, when the user has none. +fn share_group(dir: &Path) -> bool { + let Some(gid) = plib::testing::shared_group() else { + eprintln!("note: the user belongs to no group shared with others; case skipped"); + return false; + }; + std::os::unix::fs::chown(dir, None, Some(gid)).unwrap(); + true +} + /// The private directory renamed to the source's name keeps its mode and its own times under -/// -p wherever others can create entries in the destination: group or other writable, or -/// sticky and world writable. That is diagnosed, naming it, and the exit status is 1; its -/// contents are still copied. +/// -p wherever others can create entries in the destination: group (a group others are in) or +/// other writable, or sticky and world writable. That is diagnosed, naming it, and the exit +/// status is 1; its contents are still copied. #[test] fn cp_pr_leaves_a_found_directory_alone_below_an_open_destination() { for dest_mode in [0o777, 0o1777, 0o775] { let temp = secrets_scenario(dest_mode); + if dest_mode == 0o775 && !share_group(&temp.path().join("dest")) { + continue; + } let out = cp(temp.path(), &["-pR", "src/d", "dest"]); let stderr = String::from_utf8_lossy(&out.stderr); let d = temp.path().join("dest/d"); @@ -128,6 +142,24 @@ fn cp_pr_leaves_a_found_directory_alone_below_an_open_destination() { } } +/// Under a umask of 002 the destination is group-writable; when its group is the user's +/// private group -- nobody else in it, nobody else's primary group -- that write permission is +/// the user's own, and -p stamps the found directory as in a destination of mode 0755. +#[test] +fn cp_pr_stamps_a_found_directory_in_a_destination_of_the_users_private_group() { + if plib::testing::user_private_group().is_none() { + eprintln!("note: this host gives the user no private group; test skipped"); + return; + } + let temp = secrets_scenario(0o775); + let out = cp(temp.path(), &["-pR", "src/d", "dest"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(0), "stderr: {stderr}"); + let d = temp.path().join("dest/d"); + assert_eq!(mode_of(&d), 0o755); + assert_eq!(mtime_of(&d), OLD_MTIME); +} + /// Without -p nothing is asked for: the found directory keeps its mode, as always, and nothing /// is said. #[test] @@ -219,7 +251,7 @@ fn cp_pr_trusts_a_directory_it_made_when_found_again() { #[test] fn cp_pr_trust_holds_along_the_chain() { // (destination mode, `p`'s mode): `p` is found in the destination, `p/secret` in `p`. - for (dest_mode, p_mode) in [(0o755, 0o777), (0o775, 0o755)] { + for (dest_mode, p_mode) in [(0o755, 0o777), (0o777, 0o755)] { let temp = tempdir().unwrap(); source_dir(&temp.path().join("src/p/secret"), 0o755); set_mode(&temp.path().join("src/p"), 0o755); @@ -258,7 +290,7 @@ fn cp_parents_pr_trust_holds_along_the_chain() { let dest = temp.path().join("dest"); fs::create_dir(&dest).unwrap(); with_private_dir(&dest.join("p"), 0o755, "secret"); - set_mode(&dest, 0o775); + set_mode(&dest, 0o777); let out = cp(temp.path(), &["-pR", "--parents", "p/secret", "dest"]); let stderr = String::from_utf8_lossy(&out.stderr); From d637c681ebddb1c36c99971370fade93d5a4087b Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 19:12:21 +0000 Subject: [PATCH 23/71] plib: compare a private group's members to the user by uid, from their raw names is_private_group compared each member name the group lists with the user's name, both through to_string_lossy: two different non-UTF-8 names could compare equal, and a second passwd name for the user's own uid counted as someone else. Each member is now resolved with getpwnam on the name's bytes as the group database holds them and compared by uid; a name that resolves to nobody means not private. Co-Authored-By: Claude Opus 5.5 --- plib/src/madefs.rs | 101 ++++++++++++++++++++++++++++----------------- 1 file changed, 62 insertions(+), 39 deletions(-) diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index f503e13cd..fc7279d08 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -324,7 +324,7 @@ fn nobody_else_can_create_with( /// Whether the group `gid` is the private group of the user `euid`: nobody else is in it, so /// its write permission is the user's own. All of these must hold: /// - it is the user's primary group; -/// - it lists no members but, perhaps, the user; +/// - every member it lists resolves (`getpwnam`, on the name's bytes) to the user's uid; /// - no other user has it for a primary group. /// /// What the user and group databases say is read through NSS (`getpwuid`, `getgrgid`, and the @@ -355,29 +355,62 @@ fn read_private_group(gid: u32, euid: u32) -> bool { let Some(user) = crate::user::get_by_uid(euid) else { return false; }; - let Some(group) = crate::group::get_by_gid(gid) else { + let Some(members) = group_member_uids(gid) else { return false; }; let Ok(users) = crate::user::load() else { return false; }; let primaries = users.iter().map(|other| (other.uid, other.gid)); - group_is_private(gid, &user, &group.members, primaries) + group_is_private(gid, (euid, user.gid), members, primaries) } -/// The rule `is_private_group` follows, for the group `gid` and the user `user`, given the -/// names the group lists as members and every user's `(uid, primary gid)`. +/// The uid each member the group `gid` lists resolves to (`getpwnam` on the name's bytes as +/// the database holds them), `None` for a name that resolves to nobody; `None` altogether when +/// the group cannot be read. +fn group_member_uids(gid: u32) -> Option>> { + // Copied out first: `getgrgid` and `getpwnam` each return storage the next call reuses. + let names = unsafe { + let group = libc::getgrgid(gid); + if group.is_null() { + return None; + } + let mut names = Vec::new(); + let mut member = (*group).gr_mem; + // read_unaligned: macOS does not align the member array. + while !member.is_null() { + let name = std::ptr::read_unaligned(member as *const *const libc::c_char); + if name.is_null() { + break; + } + names.push(CStr::from_ptr(name).to_owned()); + member = member.add(1); + } + names + }; + let uid_of = |name: &CStr| { + let passwd = unsafe { libc::getpwnam(name.as_ptr()) }; + (!passwd.is_null()).then(|| unsafe { (*passwd).pw_uid }) + }; + Some(names.iter().map(|name| uid_of(name)).collect()) +} + +/// The rule `is_private_group` follows, for the group `gid` and the user `(uid, primary gid)`, +/// given the uid each member the group lists resolves to (`None` for one that resolves to +/// nobody) and every user's `(uid, primary gid)`. Members are compared by uid, so a second +/// name for the user is the user. pub fn group_is_private( gid: u32, - user: &crate::user::User, - members: &[std::ffi::OsString], + user: (u32, u32), + member_uids: impl IntoIterator>, primaries: impl IntoIterator, ) -> bool { - user.gid == gid - && members.iter().all(|member| *member == user.name) + let (uid, user_gid) = user; + user_gid == gid + && member_uids.into_iter().all(|member| member == Some(uid)) && primaries .into_iter() - .all(|(uid, primary)| primary != gid || uid == user.uid) + .all(|(other, primary)| primary != gid || other == uid) } /// Check a directory the caller has just made with `mkdirat` in `parent_fd` and then opened as @@ -614,7 +647,7 @@ pub fn utimens_link_if_still( #[cfg(test)] mod tests { use super::{ - empty_lending_read, group_is_private, is_private_group, made_by_us, + empty_lending_read, group_is_private, group_member_uids, is_private_group, made_by_us, nobody_else_can_create_with, others_can_rename, utimens_link_if_still, verify_made_dir, ChainTrust, FoundDir, FsOwners, MadeObject, MadeTrust, Preserve, }; @@ -667,41 +700,32 @@ mod tests { /// nobody else, and is nobody else's primary group. #[test] fn what_makes_a_group_private() { - let user = crate::user::User { - name: "us".into(), - uid: US, - gid: 500, - gecos: Default::default(), - dir: Default::default(), - shell: Default::default(), - }; - let names = |list: &[&str]| { - list.iter() - .map(std::ffi::OsString::from) - .collect::>() - }; + let user = (US, 500); let only_ours = [(US, 500), (OTHER, 600), (0, 0)]; - assert!(group_is_private(500, &user, &[], only_ours)); - assert!(group_is_private(500, &user, &names(&["us"]), only_ours)); + // Members are given as the uid each name resolves to, `None` for one that does not. + assert!(group_is_private(500, user, [], only_ours)); + assert!(group_is_private(500, user, [Some(US)], only_ours)); // Not the user's primary group. - assert!(!group_is_private(600, &user, &[], only_ours)); + assert!(!group_is_private(600, user, [], only_ours)); // Another member listed. assert!(!group_is_private( 500, - &user, - &names(&["us", "them"]), + user, + [Some(US), Some(OTHER)], only_ours )); - assert!(!group_is_private(500, &user, &names(&["them"]), only_ours)); + assert!(!group_is_private(500, user, [Some(OTHER)], only_ours)); + // A member whose name resolves to nobody cannot be shown to be the user. + assert!(!group_is_private(500, user, [None], only_ours)); // Another user's primary group too. - assert!(!group_is_private( + assert!(!group_is_private(500, user, [], [(US, 500), (OTHER, 500)])); + // A second entry for the user's own uid is the user, under any name. + assert!(group_is_private( 500, - &user, - &[], - [(US, 500), (OTHER, 500)] + user, + [Some(US), Some(US)], + [(US, 500), (US, 500)] )); - // A second entry for the user's own uid is the user. - assert!(group_is_private(500, &user, &[], [(US, 500), (US, 500)])); } /// The test user's own primary group, read from the real databases, agrees with the rule @@ -714,13 +738,12 @@ mod tests { let Some(user) = crate::user::get_by_uid(euid) else { return; }; - let (Some(group), Ok(users)) = (crate::group::get_by_gid(user.gid), crate::user::load()) - else { + let (Some(members), Ok(users)) = (group_member_uids(user.gid), crate::user::load()) else { assert!(!is_private_group(user.gid, euid)); return; }; let primaries = users.iter().map(|u| (u.uid, u.gid)); - let expected = group_is_private(user.gid, &user, &group.members, primaries); + let expected = group_is_private(user.gid, (euid, user.gid), members, primaries); assert_eq!(is_private_group(user.gid, euid), expected); // Asked again, the answer is the one read. assert_eq!(is_private_group(user.gid, euid), expected); From da41f46131141511e2a44378e67ba24526ef2256 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 19:15:47 +0000 Subject: [PATCH 24/71] plib: apply the private-group rule only where the user databases are local files is_private_group decides that nobody else is in a group from what getpwent and gr_mem list. A directory service that does not enumerate (sssd or LDAP with enumerate=false, the usual setting) hands getpwent only its local users and lists a domain group's members nowhere, so a group shared across a domain could pass for private: the test failed open. Now, each failing closed: - the passwd and group lines of /etc/nsswitch.conf must name nothing but files and systemd, or compat while /etc/passwd and /etc/group hold no '+'/'-' lines. A missing line takes glibc's default, which is files, or "compat [NOTFOUND=return] files" where glibc is built with the obsolete NSL, so it is held to compat's condition. A missing file is all defaults; an unreadable one, or a line with no source, a second line for the database, an unclosed [action] or no colon, means not private; - the user's own (uid, primary gid) must be in the enumeration, which is otherwise not the whole database; - the rule applies only on Linux with glibc. On macOS, the BSDs and musl no group is private; - getpwent returning NULL with any errno set, ENOENT included, is a failure, not the end of the list: glibc leaves errno 0 at the end. The doc comment adds the residuals no database shows: groups granted by pam_group or SupplementaryGroups=, and processes of a former member that still hold the gid. Co-Authored-By: Claude Opus 5.5 --- plib/src/madefs.rs | 168 +++++++++++++++++++++++++++++++++++++++++---- plib/src/user.rs | 8 +-- 2 files changed, 158 insertions(+), 18 deletions(-) diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index fc7279d08..6068e3245 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -325,19 +325,25 @@ fn nobody_else_can_create_with( /// its write permission is the user's own. All of these must hold: /// - it is the user's primary group; /// - every member it lists resolves (`getpwnam`, on the name's bytes) to the user's uid; -/// - no other user has it for a primary group. +/// - no other user has it for a primary group; +/// - the passwd and group databases come from local files alone (`nss_is_local`), and the +/// enumeration of passwd lists the user too: a directory service that does not enumerate +/// (sssd or LDAP with enumeration off, as is usual) hands `getpwent` only some of its users, +/// and lists a domain group's members nowhere, so the rule cannot be checked against it. /// -/// What the user and group databases say is read through NSS (`getpwuid`, `getgrgid`, and the +/// It applies only on Linux with glibc, whose `/etc/nsswitch.conf` is read here. Elsewhere -- +/// macOS, the BSDs, musl -- no group is private. +/// +/// What the databases say is read through NSS (`getpwuid`, `getgrgid`, `getpwnam`, and the /// whole passwd database through `getpwent`), once per group in a process. Anything that cannot -/// be read -- the user, the group, an error while enumerating -- counts as not private. +/// be read -- the user, the group, `/etc/nsswitch.conf`, an error while enumerating -- counts as +/// not private. /// /// Residuals: -/// - An NSS source that does not enumerate (sssd or LDAP with enumeration off, as is usual) -/// hands `getpwent` only the users it does list, without an error: a user it holds who has -/// the group for a primary group goes unseen. Nothing distinguishes that from a source with -/// no such user, so it cannot fail closed. -/// - Members known only to `getgrouplist` (an NSS source that adds supplementary groups it -/// does not list in `gr_mem`) are not seen either. +/// - Groups granted outside every database: `pam_group` (`/etc/security/group.conf`) and +/// systemd's `SupplementaryGroups=` hand processes a gid no database records as theirs. +/// - Processes of someone who was a member, or had the group as primary, before it was +/// changed keep the gid for as long as they run. /// - A group password lets anyone who knows it `newgrp` into the group; with shadow groups /// it is out of the user's reach to read, and is not considered. /// - Users and members added after the answer is read, for the rest of the process. @@ -352,6 +358,9 @@ pub fn is_private_group(gid: u32, euid: u32) -> bool { /// `is_private_group`, read from the databases. fn read_private_group(gid: u32, euid: u32) -> bool { + if !cfg!(all(target_os = "linux", target_env = "gnu")) || !nss_config_is_local() { + return false; + } let Some(user) = crate::user::get_by_uid(euid) else { return false; }; @@ -406,11 +415,79 @@ pub fn group_is_private( primaries: impl IntoIterator, ) -> bool { let (uid, user_gid) = user; + let mut listed = false; + let no_other = primaries.into_iter().all(|(other, primary)| { + listed |= (other, primary) == user; + primary != gid || other == uid + }); user_gid == gid && member_uids.into_iter().all(|member| member == Some(uid)) - && primaries + && no_other + && listed +} + +/// Whether the passwd and group databases come from local files alone, and so are enumerated +/// whole by `getpwent` (`is_private_group`), going by the text of `/etc/nsswitch.conf`: each is +/// served by the `files` and `systemd` sources only, or `compat` where `compat_plain(db)` says +/// the database's file holds no `+`/`-` lines (which pull in NIS). A database the file does not +/// mention takes glibc's default: `files`, or `compat [NOTFOUND=return] files` where glibc is +/// built with the obsolete NSL, so compat's condition applies. A line that cannot be read for +/// certain -- no source, a second line for the database, an unclosed `[action]`, no colon -- +/// means not. +fn nss_is_local(conf: &str, compat_plain: impl Fn(&str) -> bool) -> bool { + let mut sources: HashMap<&str, Vec<&str>> = HashMap::new(); + for line in conf.lines() { + let line = line.split('#').next().unwrap_or("").trim(); + let (name, rest) = line.split_once(':').unwrap_or((line, "")); + let Some(db) = ["passwd", "group"] .into_iter() - .all(|(other, primary)| primary != gid || other == uid) + .find(|db| name.split_whitespace().next() == Some(db)) + else { + continue; + }; + if name.trim() != db || !line.contains(':') { + return false; + } + let mut listed = Vec::new(); + let mut in_action = false; + for word in rest.split_whitespace() { + if in_action || word.starts_with('[') { + in_action = !word.ends_with(']'); + continue; + } + listed.push(word); + } + if in_action || listed.is_empty() || sources.insert(db, listed).is_some() { + return false; + } + } + ["passwd", "group"].into_iter().all(|db| { + let compat = || compat_plain(db); + match sources.get(db) { + None => compat(), + Some(listed) => listed.iter().all(|source| match *source { + "files" | "systemd" => true, + "compat" => compat(), + _ => false, + }), + } + }) +} + +/// `nss_is_local` for this system's `/etc/nsswitch.conf` (none at all: every database takes +/// the default). +fn nss_config_is_local() -> bool { + let conf = match std::fs::read_to_string("/etc/nsswitch.conf") { + Ok(conf) => conf, + Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(), + Err(_) => return false, + }; + nss_is_local(&conf, |db| { + std::fs::read(format!("/etc/{db}")).is_ok_and(|text| { + text.split(|&b| b == b'\n') + .all(|line| !line.starts_with(b"+") && !line.starts_with(b"-")) + }) + }) } /// Check a directory the caller has just made with `mkdirat` in `parent_fd` and then opened as @@ -648,8 +725,9 @@ pub fn utimens_link_if_still( mod tests { use super::{ empty_lending_read, group_is_private, group_member_uids, is_private_group, made_by_us, - nobody_else_can_create_with, others_can_rename, utimens_link_if_still, verify_made_dir, - ChainTrust, FoundDir, FsOwners, MadeObject, MadeTrust, Preserve, + nobody_else_can_create_with, nss_config_is_local, nss_is_local, others_can_rename, + utimens_link_if_still, verify_made_dir, ChainTrust, FoundDir, FsOwners, MadeObject, + MadeTrust, Preserve, }; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -726,6 +804,66 @@ mod tests { [Some(US), Some(US)], [(US, 500), (US, 500)] )); + // An enumeration that does not list the user is not the whole database: it may be + // missing others with the group as theirs too. + assert!(!group_is_private(500, user, [], [(OTHER, 600), (0, 0)])); + assert!(!group_is_private(500, user, [], [(US, 501)])); + } + + /// The passwd and group databases are trusted to be enumerable only from local files: the + /// `files` and `systemd` sources, and `compat` while the files hold no `+`/`-` lines. A + /// database the file does not mention takes glibc's default, which is `files`, or + /// `compat [NOTFOUND=return] files` where glibc is built with the obsolete NSL. + #[test] + fn only_local_nss_sources_enumerate_everyone() { + let plain = |_: &str| true; + let plus = |_: &str| false; + let debian = "# comment\npasswd: files systemd\ngroup: files systemd\n\ + shadow: files\nhosts: files dns\nnetgroup: nis\n"; + assert!(nss_is_local(debian, plain)); + assert!(nss_is_local("passwd: files\ngroup: files\n", plus)); + assert!(nss_is_local( + "passwd: files [SUCCESS=merge] systemd\ngroup: files [ !UNAVAIL=return ] systemd\n", + plain + )); + assert!(nss_is_local("passwd: compat\ngroup: compat\n", plain)); + assert!(!nss_is_local("passwd: compat\ngroup: compat\n", plus)); + // Directory services. + assert!(!nss_is_local( + "passwd: files sss\ngroup: files sss\n", + plain + )); + assert!(!nss_is_local("passwd: files\ngroup: files ldap\n", plain)); + assert!(!nss_is_local( + "passwd: files winbind\ngroup: files\n", + plain + )); + assert!(!nss_is_local("passwd: files nis\ngroup: files\n", plain)); + // Not mentioned: glibc's default, compat at worst. + assert!(nss_is_local("hosts: files\n", plain)); + assert!(!nss_is_local("hosts: files\n", plus)); + assert!(nss_is_local("", plain)); + // Unclear: no source, a second line, an unclosed action, no colon. + assert!(!nss_is_local("passwd:\ngroup: files\n", plain)); + assert!(!nss_is_local( + "passwd: files\npasswd: sss\ngroup: files\n", + plain + )); + assert!(!nss_is_local( + "passwd: files [NOTFOUND=return\ngroup: files\n", + plain + )); + assert!(!nss_is_local("passwd files\ngroup: files\n", plain)); + } + + /// Where the private-group rule cannot be backed by an ACL check and an NSS configuration + /// check, it never applies. + #[cfg(not(all(target_os = "linux", target_env = "gnu")))] + #[test] + fn no_group_is_private_without_the_checks() { + let euid = unsafe { libc::geteuid() }; + let gid = unsafe { libc::getegid() }; + assert!(!is_private_group(gid, euid)); } /// The test user's own primary group, read from the real databases, agrees with the rule @@ -743,7 +881,9 @@ mod tests { return; }; let primaries = users.iter().map(|u| (u.uid, u.gid)); - let expected = group_is_private(user.gid, (euid, user.gid), members, primaries); + let expected = cfg!(all(target_os = "linux", target_env = "gnu")) + && nss_config_is_local() + && group_is_private(user.gid, (euid, user.gid), members, primaries); assert_eq!(is_private_group(user.gid, euid), expected); // Asked again, the answer is the one read. assert_eq!(is_private_group(user.gid, euid), expected); diff --git a/plib/src/user.rs b/plib/src/user.rs index 845c9ff9a..3bafdad52 100644 --- a/plib/src/user.rs +++ b/plib/src/user.rs @@ -73,9 +73,9 @@ impl User { /// Every user in the password database, through `setpwent`/`getpwent`/`endpwent`. /// /// An error while reading is an error, never a shorter list: `getpwent` returns NULL both at -/// the end and on a failure, which only `errno` tells apart (ENOENT, which glibc's NSS leaves -/// at the end, counts as the end). The enumeration is the process's one, so callers here take -/// turns. +/// the end and on a failure, which only `errno` tells apart -- glibc leaves it 0 at the end, +/// and any value at all, ENOENT included, is taken for a failure. The enumeration is the +/// process's one, so callers here take turns. pub fn load() -> io::Result> { static ENUMERATING: Mutex<()> = Mutex::new(()); let _turn = ENUMERATING.lock().unwrap_or_else(|e| e.into_inner()); @@ -88,7 +88,7 @@ pub fn load() -> io::Result> { let passwd = libc::getpwent(); if passwd.is_null() { let e = errno::errno().0; - if e != 0 && e != libc::ENOENT { + if e != 0 { result = Err(io::Error::from_raw_os_error(e)); } break; From 17a5e1602a50d55041fa3a4f0f9777d6a590f523 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 19:21:11 +0000 Subject: [PATCH 25/71] plib: count group write permission as the user's own only when no ACL names anyone else With an extended POSIX ACL the group bits of st_mode are the ACL's mask. A directory of the user's private group, mode 0755, given `setfacl -m u:nobody:rwx`, shows 0775, and the private-group rule took that group write permission for the user's own: `cp -pR src D` stamped the existing D/src 0777 where it had been left alone, and pax -p e did the same. nobody_else_can_create now takes the directory's descriptor and trusts group write permission through the private-group rule only when the access ACL (system.posix_acl_access) names nobody but the owner, the owning group, the mask and others. An O_PATH descriptor takes no fgetxattr, so the directory is then reopened for reading through self/fd/N under the verified procfs. A directory the user may not read, an ACL of a version or shape not known, or any error other than "no ACL" (ENODATA) or "no ACLs here" (EOPNOTSUPP), counts as naming others. The stat-only form, which could not see an ACL, is gone. cp and pax tests with setfacl skip, with a note, where setfacl is missing, the filesystem takes no ACLs, or the user has no private group (plib::testing::grant_named_acl). Co-Authored-By: Claude Opus 5.5 --- pax/tests/security/existing_dir.rs | 32 +++++++ plib/src/madefs.rs | 145 ++++++++++++++++++++++++----- plib/src/testing.rs | 17 ++++ tree/tests/cp/existing_dir.rs | 26 ++++++ 4 files changed, 195 insertions(+), 25 deletions(-) diff --git a/pax/tests/security/existing_dir.rs b/pax/tests/security/existing_dir.rs index 4dd712d73..65cb7aa63 100644 --- a/pax/tests/security/existing_dir.rs +++ b/pax/tests/security/existing_dir.rs @@ -179,6 +179,38 @@ fn test_pe_stamps_an_existing_directory_in_a_destination_of_the_users_private_gr } } +/// An ACL entry naming another user widens the group bits of the mode to the +/// ACL's mask: a destination of the user's private group, 0755 but for an +/// ACL granting someone else write, shows 0775 -- and others can create +/// entries in it. Under -p e the existing directory keeps its mode, in both +/// modes. +#[test] +fn test_pe_leaves_an_existing_directory_alone_where_an_acl_lets_others_write() { + if plib::testing::user_private_group().is_none() { + eprintln!("note: this host gives the user no private group; test skipped"); + return; + } + for copy in [false, true] { + let temp = TempDir::new().unwrap(); + let archive = archive_with_open_directory(&temp); + let src = temp.path().join("src"); + let dest = dest_with_private_d(&temp, 0o755); + if !plib::testing::grant_named_acl(&dest) { + return; + } + assert_eq!(mode_of(&dest), 0o775, "the mask shows in the group bits"); + let out = if copy { + pax(&src, &["-rw", "-p", "e", "d", dest.to_str().unwrap()]) + } else { + pax(&dest, &["-r", "-p", "e", "-f", archive.to_str().unwrap()]) + }; + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(mode_of(&dest.join("d")), 0o700, "copy={copy}: opened up"); + assert_eq!(out.status.code(), Some(1), "copy={copy}: stderr: {stderr}"); + assert!(stderr.contains(DIAGNOSTIC), "copy={copy}: stderr: {stderr}"); + } +} + /// In a destination only the user can create entries in, -p e gives an /// existing directory the member's mode, as POSIX describes. #[test] diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index 6068e3245..50589ff78 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -240,7 +240,7 @@ impl ChainTrust { /// entries. pub fn anchor(anchor_fd: RawFd) -> io::Result { Ok(ChainTrust { - entries_safe: nobody_else_can_create_in(anchor_fd)?, + entries_safe: nobody_else_can_create(anchor_fd)?, }) } @@ -256,7 +256,7 @@ impl ChainTrust { /// `self`, hands its own entries. pub fn found(self, dir_fd: RawFd) -> io::Result { Ok(ChainTrust { - entries_safe: self.entries_safe && nobody_else_can_create_in(dir_fd)?, + entries_safe: self.entries_safe && nobody_else_can_create(dir_fd)?, }) } @@ -264,7 +264,7 @@ impl ChainTrust { /// `dir_fd`, hands its entries, wherever it is. pub fn made(dir_fd: RawFd) -> io::Result { Ok(ChainTrust { - entries_safe: nobody_else_can_create_in(dir_fd)?, + entries_safe: nobody_else_can_create(dir_fd)?, }) } @@ -281,28 +281,93 @@ impl ChainTrust { } } -/// `nobody_else_can_create` for the directory open on `fd`. -fn nobody_else_can_create_in(fd: RawFd) -> io::Result { +/// Whether nobody but the effective user can create entries in the directory open on `fd`: it +/// is owned by that user and grants no other write permission, and no group write permission +/// either unless its group is the user's private group (`is_private_group`) and no ACL names +/// anyone else (`acl_may_name_others`) -- the user's alone, so the directories a umask of 002 +/// leaves group-writable, as Debian-style user private groups intend, count as the user's. A +/// sticky directory others may write counts as one they can create entries in. +/// +/// Only what `st_mode` shows is seen otherwise. Write permission a POSIX ACL grants to named +/// users or groups shows there (in the group bits, the ACL mask), and so counts as others' +/// unless the ACL is read and names nobody; write permission a macOS or NFSv4 ACL grants does +/// not show, and is not taken into account. That is a residual: below a directory such an ACL +/// lets others write, a directory found existing -- possibly one of theirs renamed there -- is +/// given the mode or owner asked for. +pub fn nobody_else_can_create(fd: RawFd) -> io::Result { let euid = unsafe { libc::geteuid() }; - Ok(nobody_else_can_create(&fstat(fd)?, euid)) + let private = |gid, euid| is_private_group(gid, euid) && !acl_may_name_others(fd); + Ok(nobody_else_can_create_with(&fstat(fd)?, euid, private)) } -/// Whether nobody but `euid` can create entries in the directory `parent`: it is owned by -/// `euid` and grants no other write permission, and no group write permission either unless -/// its group is the user's private group (`is_private_group`) -- the user's alone, so the -/// directories a umask of 002 leaves group-writable, as Debian-style user private groups -/// intend, count as the user's. A sticky directory others may write counts as one they can -/// create entries in. +/// Whether the access ACL of the directory open on `fd` may grant anyone but its owner and its +/// owning group: it has a named user or named group entry, or it cannot be read for certain. +/// No ACL, or a filesystem without ACLs, grants nobody else. /// -/// Only what `st_mode` shows is seen. Write permission a POSIX ACL grants to named users or -/// groups shows there (in the group bits, the ACL mask); write permission a macOS or NFSv4 ACL -/// grants does not, and is not taken into account. That is a residual: below a directory such -/// an ACL lets others write, a directory found existing -- possibly one of theirs renamed -/// there -- is given the mode or owner asked for. (A POSIX ACL's named entries widen the mask, -/// and so the group bits, and the group then still has to be private: only its owning group's -/// members are counted, not the ACL's named users. That is a residual too.) -pub fn nobody_else_can_create(parent: &libc::stat, euid: u32) -> bool { - nobody_else_can_create_with(parent, euid, is_private_group) +/// Read as the `system.posix_acl_access` attribute. An `O_PATH` descriptor takes no `fgetxattr` +/// (EBADF); the directory is then reopened for reading through `self/fd/N` under a `/proc` +/// verified to be procfs (`procfs_dir`), which names the same inode -- and one the user may +/// not read counts as one that may. +#[cfg(target_os = "linux")] +fn acl_may_name_others(fd: RawFd) -> bool { + const ACCESS_ACL: &CStr = c"system.posix_acl_access"; + let read = |fd: RawFd, buf: &mut [u8]| { + let n = + unsafe { libc::fgetxattr(fd, ACCESS_ACL.as_ptr(), buf.as_mut_ptr().cast(), buf.len()) }; + usize::try_from(n).map_err(|_| io::Error::last_os_error()) + }; + let mut buf = [0u8; 1024]; + let mut result = read(fd, &mut buf); + if result + .as_ref() + .is_err_and(|e| e.raw_os_error() == Some(libc::EBADF)) + { + let reopened = procfs_dir().and_then(|proc_dir| { + let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC; + let name = proc_fd_name(fd); + let fd = unsafe { libc::openat(proc_dir.as_raw_fd(), name.as_ptr(), flags) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + Ok(unsafe { File::from_raw_fd(fd) }) + }); + result = reopened.and_then(|dir| read(dir.as_raw_fd(), &mut buf)); + } + match result { + Ok(len) => acl_names_others(&buf[..len]), + Err(e) => !matches!( + e.raw_os_error(), + Some(libc::ENODATA) | Some(libc::EOPNOTSUPP) + ), + } +} + +/// No ACL can be read here: assume one may (the private-group rule does not apply here anyway). +#[cfg(not(target_os = "linux"))] +fn acl_may_name_others(_fd: RawFd) -> bool { + true +} + +/// Whether the `system.posix_acl_access` attribute `xattr` has an entry beyond the owner, the +/// owning group, the mask and others -- or is not one this reads for certain. Its format is +/// the kernel's: a little-endian 32-bit version (2), then 8-byte entries of a 16-bit tag, a +/// 16-bit permission set and a 32-bit id. +#[cfg(any(target_os = "linux", test))] +fn acl_names_others(xattr: &[u8]) -> bool { + const USER_OBJ: u16 = 0x01; + const GROUP_OBJ: u16 = 0x04; + const MASK: u16 = 0x10; + const OTHER: u16 = 0x20; + let Some((version, entries)) = xattr.split_first_chunk::<4>() else { + return true; + }; + if u32::from_le_bytes(*version) != 2 || entries.len() % 8 != 0 { + return true; + } + entries.as_chunks::<8>().0.iter().any(|entry| { + let tag = u16::from_le_bytes([entry[0], entry[1]]); + !matches!(tag, USER_OBJ | GROUP_OBJ | MASK | OTHER) + }) } /// `nobody_else_can_create`, asking `private(gid, euid)` whether a group is the user's alone. @@ -724,10 +789,10 @@ pub fn utimens_link_if_still( #[cfg(test)] mod tests { use super::{ - empty_lending_read, group_is_private, group_member_uids, is_private_group, made_by_us, - nobody_else_can_create_with, nss_config_is_local, nss_is_local, others_can_rename, - utimens_link_if_still, verify_made_dir, ChainTrust, FoundDir, FsOwners, MadeObject, - MadeTrust, Preserve, + acl_names_others, empty_lending_read, group_is_private, group_member_uids, + is_private_group, made_by_us, nobody_else_can_create_with, nss_config_is_local, + nss_is_local, others_can_rename, utimens_link_if_still, verify_made_dir, ChainTrust, + FoundDir, FsOwners, MadeObject, MadeTrust, Preserve, }; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -856,6 +921,36 @@ mod tests { assert!(!nss_is_local("passwd files\ngroup: files\n", plain)); } + /// An access ACL names someone else when it has any entry but the owner's, the owning + /// group's, the mask and others'; one that cannot be read for certain counts as naming. + #[test] + fn which_access_acls_name_others() { + let acl = |tags: &[u16]| { + let mut xattr = 2u32.to_le_bytes().to_vec(); + for &tag in tags { + xattr.extend(tag.to_le_bytes()); + xattr.extend(7u16.to_le_bytes()); + xattr.extend(u32::MAX.to_le_bytes()); + } + xattr + }; + // Owner, owning group, others; with a mask. + assert!(!acl_names_others(&acl(&[0x01, 0x04, 0x20]))); + assert!(!acl_names_others(&acl(&[0x01, 0x04, 0x10, 0x20]))); + // A named user, a named group, a tag not known. + assert!(acl_names_others(&acl(&[0x01, 0x02, 0x04, 0x10, 0x20]))); + assert!(acl_names_others(&acl(&[0x01, 0x04, 0x08, 0x10, 0x20]))); + assert!(acl_names_others(&acl(&[0x01, 0x04, 0x40, 0x20]))); + // Not the format read here. + let mut other_version = acl(&[0x01, 0x04, 0x20]); + other_version[0] = 3; + assert!(acl_names_others(&other_version)); + let mut torn = acl(&[0x01, 0x04, 0x20]); + torn.pop(); + assert!(acl_names_others(&torn)); + assert!(acl_names_others(&[2, 0])); + } + /// Where the private-group rule cannot be backed by an ACL check and an NSS configuration /// check, it never applies. #[cfg(not(all(target_os = "linux", target_env = "gnu")))] diff --git a/plib/src/testing.rs b/plib/src/testing.rs index 3ea52aa09..4082b6d60 100644 --- a/plib/src/testing.rs +++ b/plib/src/testing.rs @@ -768,6 +768,23 @@ pub fn shared_group() -> Option { .find(|&gid| !crate::madefs::is_private_group(gid, euid)) } +/// Give `path` a POSIX ACL entry granting uid 65534 (nobody) rwx, with `setfacl`; the group +/// bits of its mode then show the ACL mask. `false`, with a note, when `setfacl` is missing or +/// the filesystem takes no ACLs. +#[cfg(unix)] +pub fn grant_named_acl(path: &Path) -> bool { + let granted = Command::new("setfacl") + .args(["-m", "u:65534:rwx"]) + .arg(path) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + if !granted { + eprintln!("note: setfacl is missing or this filesystem takes no ACLs; case skipped"); + } + granted +} + #[cfg(test)] mod tests { use super::{read_until_full, TempFile}; diff --git a/tree/tests/cp/existing_dir.rs b/tree/tests/cp/existing_dir.rs index 1d176f2ee..93c25e35a 100644 --- a/tree/tests/cp/existing_dir.rs +++ b/tree/tests/cp/existing_dir.rs @@ -160,6 +160,32 @@ fn cp_pr_stamps_a_found_directory_in_a_destination_of_the_users_private_group() assert_eq!(mtime_of(&d), OLD_MTIME); } +/// An ACL entry naming another user widens the group bits of the mode to the ACL's mask: a +/// destination of the user's private group, 0755 but for an ACL granting someone else write, +/// shows 0775 -- and others can create entries in it. The found directory is left alone. +#[test] +fn cp_pr_leaves_a_found_directory_alone_where_an_acl_lets_others_write() { + if plib::testing::user_private_group().is_none() { + eprintln!("note: this host gives the user no private group; test skipped"); + return; + } + let temp = secrets_scenario(0o755); + let dest = temp.path().join("dest"); + if !plib::testing::grant_named_acl(&dest) { + return; + } + assert_eq!(mode_of(&dest), 0o775, "the mask shows in the group bits"); + let out = cp(temp.path(), &["-pR", "src/d", "dest"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + mode_of(&dest.join("d")), + 0o700, + "the private directory was opened up" + ); + assert_eq!(out.status.code(), Some(1), "stderr: {stderr}"); + assert!(stderr.contains(DIAGNOSTIC), "stderr: {stderr}"); +} + /// Without -p nothing is asked for: the found directory keeps its mode, as always, and nothing /// is said. #[test] From 781112dc52f8bbf4c85993329cb4b226faea4961 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 19:24:53 +0000 Subject: [PATCH 26/71] Revert "plib: apply the private-group rule only where the user databases are local files" This reverts commit 9eb9fb70, by the user's decision: parsing /etc/nsswitch.conf and walking the whole passwd database made the private-group check expensive (with LDAP enumeration on, a network sweep) and its parser could disagree with glibc's. The rule is to be replaced by the cheap user-private-group convention. The ACL check from 40da1eef, which came after it, is kept. Co-Authored-By: Claude Opus 5.5 --- plib/src/madefs.rs | 169 ++++----------------------------------------- plib/src/user.rs | 8 +-- 2 files changed, 19 insertions(+), 158 deletions(-) diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index 50589ff78..df6a2319d 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -390,25 +390,19 @@ fn nobody_else_can_create_with( /// its write permission is the user's own. All of these must hold: /// - it is the user's primary group; /// - every member it lists resolves (`getpwnam`, on the name's bytes) to the user's uid; -/// - no other user has it for a primary group; -/// - the passwd and group databases come from local files alone (`nss_is_local`), and the -/// enumeration of passwd lists the user too: a directory service that does not enumerate -/// (sssd or LDAP with enumeration off, as is usual) hands `getpwent` only some of its users, -/// and lists a domain group's members nowhere, so the rule cannot be checked against it. +/// - no other user has it for a primary group. /// -/// It applies only on Linux with glibc, whose `/etc/nsswitch.conf` is read here. Elsewhere -- -/// macOS, the BSDs, musl -- no group is private. -/// -/// What the databases say is read through NSS (`getpwuid`, `getgrgid`, `getpwnam`, and the +/// What the user and group databases say is read through NSS (`getpwuid`, `getgrgid`, and the /// whole passwd database through `getpwent`), once per group in a process. Anything that cannot -/// be read -- the user, the group, `/etc/nsswitch.conf`, an error while enumerating -- counts as -/// not private. +/// be read -- the user, the group, an error while enumerating -- counts as not private. /// /// Residuals: -/// - Groups granted outside every database: `pam_group` (`/etc/security/group.conf`) and -/// systemd's `SupplementaryGroups=` hand processes a gid no database records as theirs. -/// - Processes of someone who was a member, or had the group as primary, before it was -/// changed keep the gid for as long as they run. +/// - An NSS source that does not enumerate (sssd or LDAP with enumeration off, as is usual) +/// hands `getpwent` only the users it does list, without an error: a user it holds who has +/// the group for a primary group goes unseen. Nothing distinguishes that from a source with +/// no such user, so it cannot fail closed. +/// - Members known only to `getgrouplist` (an NSS source that adds supplementary groups it +/// does not list in `gr_mem`) are not seen either. /// - A group password lets anyone who knows it `newgrp` into the group; with shadow groups /// it is out of the user's reach to read, and is not considered. /// - Users and members added after the answer is read, for the rest of the process. @@ -423,9 +417,6 @@ pub fn is_private_group(gid: u32, euid: u32) -> bool { /// `is_private_group`, read from the databases. fn read_private_group(gid: u32, euid: u32) -> bool { - if !cfg!(all(target_os = "linux", target_env = "gnu")) || !nss_config_is_local() { - return false; - } let Some(user) = crate::user::get_by_uid(euid) else { return false; }; @@ -480,79 +471,11 @@ pub fn group_is_private( primaries: impl IntoIterator, ) -> bool { let (uid, user_gid) = user; - let mut listed = false; - let no_other = primaries.into_iter().all(|(other, primary)| { - listed |= (other, primary) == user; - primary != gid || other == uid - }); user_gid == gid && member_uids.into_iter().all(|member| member == Some(uid)) - && no_other - && listed -} - -/// Whether the passwd and group databases come from local files alone, and so are enumerated -/// whole by `getpwent` (`is_private_group`), going by the text of `/etc/nsswitch.conf`: each is -/// served by the `files` and `systemd` sources only, or `compat` where `compat_plain(db)` says -/// the database's file holds no `+`/`-` lines (which pull in NIS). A database the file does not -/// mention takes glibc's default: `files`, or `compat [NOTFOUND=return] files` where glibc is -/// built with the obsolete NSL, so compat's condition applies. A line that cannot be read for -/// certain -- no source, a second line for the database, an unclosed `[action]`, no colon -- -/// means not. -fn nss_is_local(conf: &str, compat_plain: impl Fn(&str) -> bool) -> bool { - let mut sources: HashMap<&str, Vec<&str>> = HashMap::new(); - for line in conf.lines() { - let line = line.split('#').next().unwrap_or("").trim(); - let (name, rest) = line.split_once(':').unwrap_or((line, "")); - let Some(db) = ["passwd", "group"] + && primaries .into_iter() - .find(|db| name.split_whitespace().next() == Some(db)) - else { - continue; - }; - if name.trim() != db || !line.contains(':') { - return false; - } - let mut listed = Vec::new(); - let mut in_action = false; - for word in rest.split_whitespace() { - if in_action || word.starts_with('[') { - in_action = !word.ends_with(']'); - continue; - } - listed.push(word); - } - if in_action || listed.is_empty() || sources.insert(db, listed).is_some() { - return false; - } - } - ["passwd", "group"].into_iter().all(|db| { - let compat = || compat_plain(db); - match sources.get(db) { - None => compat(), - Some(listed) => listed.iter().all(|source| match *source { - "files" | "systemd" => true, - "compat" => compat(), - _ => false, - }), - } - }) -} - -/// `nss_is_local` for this system's `/etc/nsswitch.conf` (none at all: every database takes -/// the default). -fn nss_config_is_local() -> bool { - let conf = match std::fs::read_to_string("/etc/nsswitch.conf") { - Ok(conf) => conf, - Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(), - Err(_) => return false, - }; - nss_is_local(&conf, |db| { - std::fs::read(format!("/etc/{db}")).is_ok_and(|text| { - text.split(|&b| b == b'\n') - .all(|line| !line.starts_with(b"+") && !line.starts_with(b"-")) - }) - }) + .all(|(other, primary)| primary != gid || other == uid) } /// Check a directory the caller has just made with `mkdirat` in `parent_fd` and then opened as @@ -790,9 +713,9 @@ pub fn utimens_link_if_still( mod tests { use super::{ acl_names_others, empty_lending_read, group_is_private, group_member_uids, - is_private_group, made_by_us, nobody_else_can_create_with, nss_config_is_local, - nss_is_local, others_can_rename, utimens_link_if_still, verify_made_dir, ChainTrust, - FoundDir, FsOwners, MadeObject, MadeTrust, Preserve, + is_private_group, made_by_us, nobody_else_can_create_with, others_can_rename, + utimens_link_if_still, verify_made_dir, ChainTrust, FoundDir, FsOwners, MadeObject, + MadeTrust, Preserve, }; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -869,56 +792,6 @@ mod tests { [Some(US), Some(US)], [(US, 500), (US, 500)] )); - // An enumeration that does not list the user is not the whole database: it may be - // missing others with the group as theirs too. - assert!(!group_is_private(500, user, [], [(OTHER, 600), (0, 0)])); - assert!(!group_is_private(500, user, [], [(US, 501)])); - } - - /// The passwd and group databases are trusted to be enumerable only from local files: the - /// `files` and `systemd` sources, and `compat` while the files hold no `+`/`-` lines. A - /// database the file does not mention takes glibc's default, which is `files`, or - /// `compat [NOTFOUND=return] files` where glibc is built with the obsolete NSL. - #[test] - fn only_local_nss_sources_enumerate_everyone() { - let plain = |_: &str| true; - let plus = |_: &str| false; - let debian = "# comment\npasswd: files systemd\ngroup: files systemd\n\ - shadow: files\nhosts: files dns\nnetgroup: nis\n"; - assert!(nss_is_local(debian, plain)); - assert!(nss_is_local("passwd: files\ngroup: files\n", plus)); - assert!(nss_is_local( - "passwd: files [SUCCESS=merge] systemd\ngroup: files [ !UNAVAIL=return ] systemd\n", - plain - )); - assert!(nss_is_local("passwd: compat\ngroup: compat\n", plain)); - assert!(!nss_is_local("passwd: compat\ngroup: compat\n", plus)); - // Directory services. - assert!(!nss_is_local( - "passwd: files sss\ngroup: files sss\n", - plain - )); - assert!(!nss_is_local("passwd: files\ngroup: files ldap\n", plain)); - assert!(!nss_is_local( - "passwd: files winbind\ngroup: files\n", - plain - )); - assert!(!nss_is_local("passwd: files nis\ngroup: files\n", plain)); - // Not mentioned: glibc's default, compat at worst. - assert!(nss_is_local("hosts: files\n", plain)); - assert!(!nss_is_local("hosts: files\n", plus)); - assert!(nss_is_local("", plain)); - // Unclear: no source, a second line, an unclosed action, no colon. - assert!(!nss_is_local("passwd:\ngroup: files\n", plain)); - assert!(!nss_is_local( - "passwd: files\npasswd: sss\ngroup: files\n", - plain - )); - assert!(!nss_is_local( - "passwd: files [NOTFOUND=return\ngroup: files\n", - plain - )); - assert!(!nss_is_local("passwd files\ngroup: files\n", plain)); } /// An access ACL names someone else when it has any entry but the owner's, the owning @@ -951,16 +824,6 @@ mod tests { assert!(acl_names_others(&[2, 0])); } - /// Where the private-group rule cannot be backed by an ACL check and an NSS configuration - /// check, it never applies. - #[cfg(not(all(target_os = "linux", target_env = "gnu")))] - #[test] - fn no_group_is_private_without_the_checks() { - let euid = unsafe { libc::geteuid() }; - let gid = unsafe { libc::getegid() }; - assert!(!is_private_group(gid, euid)); - } - /// The test user's own primary group, read from the real databases, agrees with the rule /// applied to what those databases list -- and an unknown user or group is never private. #[test] @@ -976,9 +839,7 @@ mod tests { return; }; let primaries = users.iter().map(|u| (u.uid, u.gid)); - let expected = cfg!(all(target_os = "linux", target_env = "gnu")) - && nss_config_is_local() - && group_is_private(user.gid, (euid, user.gid), members, primaries); + let expected = group_is_private(user.gid, (euid, user.gid), members, primaries); assert_eq!(is_private_group(user.gid, euid), expected); // Asked again, the answer is the one read. assert_eq!(is_private_group(user.gid, euid), expected); diff --git a/plib/src/user.rs b/plib/src/user.rs index 3bafdad52..845c9ff9a 100644 --- a/plib/src/user.rs +++ b/plib/src/user.rs @@ -73,9 +73,9 @@ impl User { /// Every user in the password database, through `setpwent`/`getpwent`/`endpwent`. /// /// An error while reading is an error, never a shorter list: `getpwent` returns NULL both at -/// the end and on a failure, which only `errno` tells apart -- glibc leaves it 0 at the end, -/// and any value at all, ENOENT included, is taken for a failure. The enumeration is the -/// process's one, so callers here take turns. +/// the end and on a failure, which only `errno` tells apart (ENOENT, which glibc's NSS leaves +/// at the end, counts as the end). The enumeration is the process's one, so callers here take +/// turns. pub fn load() -> io::Result> { static ENUMERATING: Mutex<()> = Mutex::new(()); let _turn = ENUMERATING.lock().unwrap_or_else(|e| e.into_inner()); @@ -88,7 +88,7 @@ pub fn load() -> io::Result> { let passwd = libc::getpwent(); if passwd.is_null() { let e = errno::errno().0; - if e != 0 { + if e != 0 && e != libc::ENOENT { result = Err(io::Error::from_raw_os_error(e)); } break; From b9021587fea9820fa616772e63988cbd4b478a92 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 19:28:14 +0000 Subject: [PATCH 27/71] plib: judge a private group by the user-private-group convention, without enumerating Replaces the private-group test that the previous commit (the revert of 9eb9fb70) restored, by the user's decision. It walked the whole passwd database with getpwent to check that no other account had the group for its primary group: expensive (with LDAP enumeration on, a sweep of the directory) and failing open where a directory service does not enumerate. This commit and the revert before it go together. A group-writable directory now counts as one nobody else can create entries in only when, besides being the user's and not other-writable: - its gid is the user's primary gid (getpwuid); - the group's name (getgrgid) is the user's name, compared as bytes; - every member the group lists resolves (getpwnam) to the user's uid; - no access ACL names anyone else (as before, checked last and per directory). Three lookups by key, cached per (gid, euid); any failure means not private. The rule applies only on Linux, the one system where the ACL check is implemented: on macOS and elsewhere no group is private. plib::user::load, used only by the old test, is removed. The doc comment lists the residuals: an account an administrator gave the same primary gid on purpose, groups granted outside the databases (pam_group, SupplementaryGroups=), a former member's running processes, and group passwords. Co-Authored-By: Claude Opus 5.5 --- plib/src/madefs.rs | 188 ++++++++++++++++++++++++++------------------- plib/src/user.rs | 31 -------- 2 files changed, 110 insertions(+), 109 deletions(-) diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index df6a2319d..7bb43c6f3 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -20,9 +20,7 @@ #[cfg(target_os = "linux")] use gettextrs::gettext; use std::collections::HashMap; -use std::ffi::CStr; -#[cfg(target_os = "linux")] -use std::ffi::CString; +use std::ffi::{CStr, CString}; #[cfg(target_os = "linux")] use std::fs::File; use std::io; @@ -386,27 +384,33 @@ fn nobody_else_can_create_with( mode & 0o020 == 0 || private(parent.st_gid, euid) } -/// Whether the group `gid` is the private group of the user `euid`: nobody else is in it, so -/// its write permission is the user's own. All of these must hold: -/// - it is the user's primary group; -/// - every member it lists resolves (`getpwnam`, on the name's bytes) to the user's uid; -/// - no other user has it for a primary group. +/// Whether the group `gid` is the private group of the user `euid`, by the user-private-group +/// convention (`useradd`, `adduser`): nobody else is in it, so its write permission is the +/// user's own. All of these must hold (`group_is_private`): +/// - it is the user's primary group (`getpwuid`); +/// - its name (`getgrgid`) is the user's name, byte for byte; +/// - every member it lists resolves (`getpwnam`, on the name's bytes) to the user's uid. /// -/// What the user and group databases say is read through NSS (`getpwuid`, `getgrgid`, and the -/// whole passwd database through `getpwent`), once per group in a process. Anything that cannot -/// be read -- the user, the group, an error while enumerating -- counts as not private. +/// Three lookups by key, once per group and user in a process; nothing is enumerated. Anything +/// that cannot be read counts as not private. It applies only on Linux, the one system where +/// an ACL that lets others write is seen too (`nobody_else_can_create`); elsewhere no group is +/// private. /// /// Residuals: -/// - An NSS source that does not enumerate (sssd or LDAP with enumeration off, as is usual) -/// hands `getpwent` only the users it does list, without an error: a user it holds who has -/// the group for a primary group goes unseen. Nothing distinguishes that from a source with -/// no such user, so it cannot fail closed. -/// - Members known only to `getgrouplist` (an NSS source that adds supplementary groups it -/// does not list in `gr_mem`) are not seen either. -/// - A group password lets anyone who knows it `newgrp` into the group; with shadow groups -/// it is out of the user's reach to read, and is not considered. -/// - Users and members added after the answer is read, for the rest of the process. +/// - An account an administrator gave the same primary gid on purpose: the convention says a +/// private group is nobody else's primary group, and this does not enumerate the accounts +/// to check (with a directory service that would be a sweep of it, and one that does not +/// enumerate would hide some anyway). +/// - Groups granted outside every database: `pam_group` (`/etc/security/group.conf`) and +/// systemd's `SupplementaryGroups=` hand processes a gid no database records as theirs. +/// - Processes of someone who was a member before the group was changed keep the gid for as +/// long as they run. +/// - A group password lets anyone who knows it `newgrp` into the group; with shadow groups it +/// is out of the user's reach to read, and is not considered. pub fn is_private_group(gid: u32, euid: u32) -> bool { + if !cfg!(target_os = "linux") { + return false; + } static ANSWERS: Mutex>> = Mutex::new(None); let mut answers = ANSWERS.lock().unwrap_or_else(|e| e.into_inner()); let answers = answers.get_or_insert_with(HashMap::new); @@ -415,31 +419,53 @@ pub fn is_private_group(gid: u32, euid: u32) -> bool { .or_insert_with(|| read_private_group(gid, euid)) } -/// `is_private_group`, read from the databases. +/// `is_private_group`, read from the databases (under its lock: the lookups return storage the +/// next one reuses). fn read_private_group(gid: u32, euid: u32) -> bool { - let Some(user) = crate::user::get_by_uid(euid) else { + let Some((user_name, user_gid)) = user_entry(euid) else { return false; }; - let Some(members) = group_member_uids(gid) else { + let Some((group_name, member_uids)) = group_entry(gid) else { return false; }; - let Ok(users) = crate::user::load() else { - return false; + let user = UserEntry { + uid: euid, + gid: user_gid, + name: user_name.as_bytes(), }; - let primaries = users.iter().map(|other| (other.uid, other.gid)); - group_is_private(gid, (euid, user.gid), members, primaries) + group_is_private(gid, &user, group_name.as_bytes(), member_uids) +} + +/// What the passwd database holds for a user: its uid, primary gid and name. +#[derive(Clone, Copy, Debug)] +pub struct UserEntry<'a> { + pub uid: u32, + pub gid: u32, + pub name: &'a [u8], } -/// The uid each member the group `gid` lists resolves to (`getpwnam` on the name's bytes as -/// the database holds them), `None` for a name that resolves to nobody; `None` altogether when -/// the group cannot be read. -fn group_member_uids(gid: u32) -> Option>> { +/// The name and primary gid of the user `uid` (`getpwuid`), the name as the database holds it. +fn user_entry(uid: u32) -> Option<(CString, u32)> { + let passwd = unsafe { libc::getpwuid(uid) }; + if passwd.is_null() { + return None; + } + let passwd = unsafe { &*passwd }; + let name = unsafe { CStr::from_ptr(passwd.pw_name) }.to_owned(); + Some((name, passwd.pw_gid)) +} + +/// The name of the group `gid` (`getgrgid`), as the database holds it, and the uid each member +/// it lists resolves to (`getpwnam` on the name's bytes), `None` for a name that resolves to +/// nobody; `None` altogether when the group cannot be read. +fn group_entry(gid: u32) -> Option<(CString, Vec>)> { // Copied out first: `getgrgid` and `getpwnam` each return storage the next call reuses. - let names = unsafe { + let (group_name, names) = unsafe { let group = libc::getgrgid(gid); if group.is_null() { return None; } + let group_name = CStr::from_ptr((*group).gr_name).to_owned(); let mut names = Vec::new(); let mut member = (*group).gr_mem; // read_unaligned: macOS does not align the member array. @@ -451,31 +477,31 @@ fn group_member_uids(gid: u32) -> Option>> { names.push(CStr::from_ptr(name).to_owned()); member = member.add(1); } - names + (group_name, names) }; let uid_of = |name: &CStr| { let passwd = unsafe { libc::getpwnam(name.as_ptr()) }; (!passwd.is_null()).then(|| unsafe { (*passwd).pw_uid }) }; - Some(names.iter().map(|name| uid_of(name)).collect()) + Some((group_name, names.iter().map(|name| uid_of(name)).collect())) } -/// The rule `is_private_group` follows, for the group `gid` and the user `(uid, primary gid)`, -/// given the uid each member the group lists resolves to (`None` for one that resolves to -/// nobody) and every user's `(uid, primary gid)`. Members are compared by uid, so a second -/// name for the user is the user. +/// The rule `is_private_group` follows, for the group `gid` named `group_name` and the user +/// `user`, given the uid each member the group lists resolves to (`None` for one that resolves +/// to nobody): `gid` is the user's primary gid, `group_name` is the user's name byte for byte, +/// and every member is the user. Members are compared by uid, so a second name for the user is +/// the user. pub fn group_is_private( gid: u32, - user: (u32, u32), + user: &UserEntry<'_>, + group_name: &[u8], member_uids: impl IntoIterator>, - primaries: impl IntoIterator, ) -> bool { - let (uid, user_gid) = user; - user_gid == gid - && member_uids.into_iter().all(|member| member == Some(uid)) - && primaries + user.gid == gid + && group_name == user.name + && member_uids .into_iter() - .all(|(other, primary)| primary != gid || other == uid) + .all(|member| member == Some(user.uid)) } /// Check a directory the caller has just made with `mkdirat` in `parent_fd` and then opened as @@ -712,10 +738,10 @@ pub fn utimens_link_if_still( #[cfg(test)] mod tests { use super::{ - acl_names_others, empty_lending_read, group_is_private, group_member_uids, - is_private_group, made_by_us, nobody_else_can_create_with, others_can_rename, + acl_names_others, empty_lending_read, group_entry, group_is_private, is_private_group, + made_by_us, nobody_else_can_create_with, others_can_rename, user_entry, utimens_link_if_still, verify_made_dir, ChainTrust, FoundDir, FsOwners, MadeObject, - MadeTrust, Preserve, + MadeTrust, Preserve, UserEntry, }; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -762,36 +788,37 @@ mod tests { assert!(!nobody_else_can_create_with(&st, US, |gid, _| gid != 4242)); } - /// A group is the user's private one only when it is the user's primary group, lists - /// nobody else, and is nobody else's primary group. + /// A group is the user's private one, by the user-private-group convention, only when it + /// is the user's primary group, bears the user's name, and lists nobody else. #[test] fn what_makes_a_group_private() { - let user = (US, 500); - let only_ours = [(US, 500), (OTHER, 600), (0, 0)]; + let us = UserEntry { + uid: US, + gid: 500, + name: b"us", + }; // Members are given as the uid each name resolves to, `None` for one that does not. - assert!(group_is_private(500, user, [], only_ours)); - assert!(group_is_private(500, user, [Some(US)], only_ours)); + assert!(group_is_private(500, &us, b"us", [])); + assert!(group_is_private(500, &us, b"us", [Some(US)])); + // A second name for the user's own uid is the user. + assert!(group_is_private(500, &us, b"us", [Some(US), Some(US)])); // Not the user's primary group. - assert!(!group_is_private(600, user, [], only_ours)); + assert!(!group_is_private(600, &us, b"us", [])); + // Not named after the user, byte for byte. + assert!(!group_is_private(500, &us, b"users", [])); + assert!(!group_is_private(500, &us, b"Us", [])); + assert!(!group_is_private(500, &us, b"us ", [])); + let lossy = UserEntry { + name: b"\xffus", + ..us + }; + assert!(!group_is_private(500, &lossy, b"\xfeus", [])); + assert!(group_is_private(500, &lossy, b"\xffus", [])); // Another member listed. - assert!(!group_is_private( - 500, - user, - [Some(US), Some(OTHER)], - only_ours - )); - assert!(!group_is_private(500, user, [Some(OTHER)], only_ours)); + assert!(!group_is_private(500, &us, b"us", [Some(US), Some(OTHER)])); + assert!(!group_is_private(500, &us, b"us", [Some(OTHER)])); // A member whose name resolves to nobody cannot be shown to be the user. - assert!(!group_is_private(500, user, [None], only_ours)); - // Another user's primary group too. - assert!(!group_is_private(500, user, [], [(US, 500), (OTHER, 500)])); - // A second entry for the user's own uid is the user, under any name. - assert!(group_is_private( - 500, - user, - [Some(US), Some(US)], - [(US, 500), (US, 500)] - )); + assert!(!group_is_private(500, &us, b"us", [None])); } /// An access ACL names someone else when it has any entry but the owner's, the owning @@ -831,16 +858,21 @@ mod tests { let euid = unsafe { libc::geteuid() }; assert!(!is_private_group(u32::MAX - 1, euid)); assert!(!is_private_group(0, u32::MAX - 1)); - let Some(user) = crate::user::get_by_uid(euid) else { + let Some((user_name, user_gid)) = user_entry(euid) else { return; }; - let (Some(members), Ok(users)) = (group_member_uids(user.gid), crate::user::load()) else { - assert!(!is_private_group(user.gid, euid)); + let Some((group_name, members)) = group_entry(user_gid) else { + assert!(!is_private_group(user_gid, euid)); return; }; - let primaries = users.iter().map(|u| (u.uid, u.gid)); - let expected = group_is_private(user.gid, (euid, user.gid), members, primaries); - assert_eq!(is_private_group(user.gid, euid), expected); + let user = UserEntry { + uid: euid, + gid: user_gid, + name: user_name.as_bytes(), + }; + let expected = cfg!(target_os = "linux") + && group_is_private(user_gid, &user, group_name.as_bytes(), members); + assert_eq!(is_private_group(user_gid, euid), expected); // Asked again, the answer is the one read. assert_eq!(is_private_group(user.gid, euid), expected); } diff --git a/plib/src/user.rs b/plib/src/user.rs index 845c9ff9a..78f67d5e7 100644 --- a/plib/src/user.rs +++ b/plib/src/user.rs @@ -24,7 +24,6 @@ use std::ffi::{CString, OsStr, OsString}; use std::io; use std::os::unix::ffi::OsStrExt; use std::path::PathBuf; -use std::sync::Mutex; /// A user account from the system user database. #[derive(Clone, Debug, PartialEq, Eq)] @@ -70,36 +69,6 @@ impl User { } } -/// Every user in the password database, through `setpwent`/`getpwent`/`endpwent`. -/// -/// An error while reading is an error, never a shorter list: `getpwent` returns NULL both at -/// the end and on a failure, which only `errno` tells apart (ENOENT, which glibc's NSS leaves -/// at the end, counts as the end). The enumeration is the process's one, so callers here take -/// turns. -pub fn load() -> io::Result> { - static ENUMERATING: Mutex<()> = Mutex::new(()); - let _turn = ENUMERATING.lock().unwrap_or_else(|e| e.into_inner()); - let mut users = Vec::new(); - let mut result = Ok(()); - unsafe { - libc::setpwent(); - loop { - errno::set_errno(errno::Errno(0)); - let passwd = libc::getpwent(); - if passwd.is_null() { - let e = errno::errno().0; - if e != 0 && e != libc::ENOENT { - result = Err(io::Error::from_raw_os_error(e)); - } - break; - } - users.push(User::from_passwd(&*passwd)); - } - libc::endpwent(); - } - result.map(|()| users) -} - /// Look up a user by name. /// /// `Ok(None)` means the database has no such user. `Err` means the lookup From 67358b530b91e1904992eff4a1b8823c7fbbe5c5 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 19:39:14 +0000 Subject: [PATCH 28/71] plib, cp, pax: work out a chain's trust only when a found directory is to be given a mode or owner ChainTrust was computed eagerly: every directory cp or pax entered, made or found, with -p or without, was checked with nobody_else_can_create, and a group-writable one -- every directory under a umask of 002 -- asked the user and group databases whether its group was the user's private one. A plain cp -R or pax -r did lookups whose answer nothing would use. A ChainTrust is now a chain of links, each made with the fstat of the directory's held descriptor (taken then, as before) and holding that descriptor weakly. Nothing more happens until found_dir is asked for a mode or owner: then the links not yet worked out are evaluated from the top down, stopping below the first directory others can write, and the answers are kept. Only a group-writable link asks is_private_group, and reads its ACL through its descriptor, at that point. A link whose descriptor its holder has since closed counts as one others may write: pax, which keeps a bounded number of levels open, therefore treats a group-writable directory deeper than that as untrusted. Reading the ACL later through the same held descriptor is reading it then: only the directory's owner -- the user, or the check would not get that far -- or root can change it in between. cp keeps every --parents directory open for the operand, and the anchor parent_anchor opens until the found operand has been judged; pax shares its anchor descriptor with the trust. New tests count is_private_group calls on the calling thread (plib::madefs::private_group_queries): cp -R, and cp -pR meeting no directory already there, into group-writable directories make none; cp -pR onto them does. pax's PendingDirs apply makes none without -p and some with -p p. Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 59 ++++++-- plib/src/madefs.rs | 329 ++++++++++++++++++++++++++++++++---------- tree/common/copy.rs | 127 ++++++++++++---- tree/parents.rs | 57 +++++--- 4 files changed, 434 insertions(+), 138 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 23e432665..1846dc9cf 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -164,7 +164,8 @@ const WALK_FLAGS: libc::c_int = /// filesystem namespace, where `create_dir_all` on `sub/file` was happy to /// follow `sub -> /elsewhere`. pub(crate) struct DirTree { - root: OwnedFd, + /// The anchor, shared with the trust that holds it weakly (`root_trust`). + root: Rc, /// The directories most recently walked through, one descriptor per /// level from the anchor down, so the next member reopens only the /// components its path does not share with the last one's. Consecutive @@ -236,9 +237,9 @@ impl DirTree { if fd < 0 { return Err(std::io::Error::last_os_error().into()); } - let root = unsafe { OwnedFd::from_raw_fd(fd) }; + let root = Rc::new(unsafe { OwnedFd::from_raw_fd(fd) }); Ok(DirTree { - root_trust: ChainTrust::anchor(root.as_raw_fd())?, + root_trust: ChainTrust::anchor(&root)?, root, chain: RefCell::new(Chain::default()), max_levels: cached_levels_budget(), @@ -276,7 +277,7 @@ impl DirTree { ) -> PaxResult<(Rc, ChainTrust)> { if let Some(last) = &*self.last_parent.borrow() { if last.dirs == member.dirs { - return Ok((Rc::clone(&last.fd), last.trust)); + return Ok((Rc::clone(&last.fd), last.trust.clone())); } } @@ -290,7 +291,7 @@ impl DirTree { Ok((fd, trust)) => Some(LastParent { dirs: member.dirs.clone(), fd: Rc::clone(fd), - trust: *trust, + trust: trust.clone(), }), Err(_) => None, }; @@ -314,7 +315,8 @@ impl DirTree { let mut trust = chain .levels .last() - .map_or(self.root_trust, |level| level.trust); + .map_or(&self.root_trust, |level| &level.trust) + .clone(); for (level, comp) in member.dirs().enumerate().skip(shared) { let at = cur.as_ref().map_or(self.root.as_fd(), |fd| fd.as_fd()); @@ -338,19 +340,19 @@ impl DirTree { .or_insert(mtime_of(&st)); } } + let next = Rc::new(next); trust = match self.standing(id, key) { - Standing::Implicit | Standing::Made => ChainTrust::made(next.as_raw_fd())?, - _ => trust.found(next.as_raw_fd())?, + Standing::Implicit | Standing::Made => ChainTrust::made(&next)?, + _ => trust.found(&next)?, }; - let next = Rc::new(next); if chain.levels.len() < self.max_levels { - chain.push(comp, Rc::clone(&next), trust); + chain.push(comp, Rc::clone(&next), trust.clone()); } cur = Some(next); } match cur { Some(fd) => Ok((fd, trust)), - None => Ok((Rc::new(self.root.try_clone()?), trust)), + None => Ok((Rc::clone(&self.root), trust)), } } @@ -2144,6 +2146,41 @@ mod tests { } } + /// Whether a group is private is looked up only when the answer is used: when -p asks for + /// mode or owner of a directory that was already there. Without it, a group-writable + /// destination with such a directory in it is walked and stamped without a lookup; with + /// `-p p` one is made. + #[test] + fn test_private_groups_are_looked_up_only_under_p() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let tmp = plib::tmp::TempDir::new().unwrap(); + let dest = tmp.path().join("dest"); + std::fs::create_dir_all(dest.join("d/x")).unwrap(); + std::fs::set_permissions(dest.join("d"), std::fs::Permissions::from_mode(0o775)).unwrap(); + std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o775)).unwrap(); + let x = std::fs::metadata(dest.join("d/x")).unwrap(); + let apply = |policy: &AttrPolicy| { + let tree = DirTree::open_path(&dest).unwrap(); + let mut pending = PendingDirs::default(); + pending.push(&member("d/x"), (x.dev(), x.ino()), attrs(0o755)); + pending.apply(&tree, policy); + }; + + let before = plib::madefs::private_group_queries(); + apply(&policy(false, false)); + assert_eq!( + plib::madefs::private_group_queries(), + before, + "looked up without -p" + ); + apply(&policy(false, true)); + assert!( + plib::madefs::private_group_queries() > before, + "-p p did not" + ); + std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + fn policy(preserve_owner: bool, preserve_perms: bool) -> AttrPolicy { AttrPolicy { preserve_owner, diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index 7bb43c6f3..fdf889dd3 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -19,14 +19,16 @@ #[cfg(target_os = "linux")] use gettextrs::gettext; +use std::cell::OnceCell; use std::collections::HashMap; use std::ffi::{CStr, CString}; #[cfg(target_os = "linux")] use std::fs::File; use std::io; -use std::os::fd::RawFd; #[cfg(target_os = "linux")] -use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::fd::FromRawFd; +use std::os::fd::{AsRawFd, RawFd}; +use std::rc::{Rc, Weak}; use std::sync::Mutex; /// Open flags for a directory that is only ever walked through, used as the `dirfd` of an `*at` @@ -227,56 +229,184 @@ pub enum FoundDir { /// directory found existing hands it on when it was handed it and nobody else can create /// entries in it either (`found`); a directory the caller made and verified is safe itself, and /// hands it on when nobody else can create entries in it (`made`). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub struct ChainTrust { - /// Whether directories found existing in this one may take what was asked for. - entries_safe: bool, +/// +/// It is worked out only when asked for: when a directory found existing is to be given a mode +/// or owner (`found_dir`), and then only as far up the chain as it has not been yet, from the +/// top down, stopping at the first directory others can create entries in. Each link is made +/// with the `fstat` of the directory's held descriptor, taken then -- what the eager check +/// took, at the moment the caller relies on the directory -- and the costly part, whether a +/// group-writable directory's group is the user's private one (`is_private_group`, and its ACL), +/// waits for the question. That part reads the ACL through the same held descriptor when it is +/// asked; only the directory's owner (the user, or it would not have got that far) or root can +/// change it in between, so reading it later is reading it then. A link holds the descriptor +/// weakly: one its holder has closed by then counts as one others may write -- which, in pax, +/// is a group-writable directory deeper than the levels it keeps open. +#[derive(Clone)] +pub struct ChainTrust(Rc); + +/// One directory of a `ChainTrust`. +struct Link { + /// What the directory this one is in hands it; `None` where a chain starts (`anchor`, + /// `made`, `unlocated`). + above: Option, + /// Who else can create entries in it, from its `fstat`. + writers: DirWriters, + /// The directory, for its ACL; `None` for `unlocated`. + dir: Option>, + /// Whether directories found existing in this one may take what was asked for, once + /// worked out. + entries_safe: OnceCell, +} + +impl Drop for Link { + /// Unlinks the chain above one link at a time, so that dropping a deep one does not recurse + /// as deep. + fn drop(&mut self) { + let mut above = self.above.take(); + while let Some(ChainTrust(link)) = above { + above = match Rc::try_unwrap(link) { + Ok(mut link) => link.above.take(), + Err(_) => None, + }; + } + } +} + +impl std::fmt::Debug for ChainTrust { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ChainTrust") + .field("writers", &self.0.writers) + .field("entries_safe", &self.0.entries_safe.get()) + .finish_non_exhaustive() + } } impl ChainTrust { - /// The trust the anchor -- the directory the user named, open on `anchor_fd` -- hands its + /// The link for the directory held as `dir`, below `above`. + fn link(above: Option, dir: &Rc) -> io::Result { + let euid = unsafe { libc::geteuid() }; + let writers = dir_writers(&fstat(dir.as_raw_fd())?, euid); + let dir: Weak = Rc::downgrade(dir); + let dir: Weak = dir; + Ok(ChainTrust(Rc::new(Link { + above, + writers, + dir: Some(dir), + entries_safe: OnceCell::new(), + }))) + } + + /// The trust the anchor -- the directory the user named, held as `dir` -- hands its /// entries. - pub fn anchor(anchor_fd: RawFd) -> io::Result { - Ok(ChainTrust { - entries_safe: nobody_else_can_create(anchor_fd)?, - }) + pub fn anchor(dir: &Rc) -> io::Result { + Self::link(None, dir) } /// The trust handed where the caller cannot tell which directory a directory found is in -- /// reached through a symbolic link, say, and so in none it can judge: none. pub fn unlocated() -> Self { - ChainTrust { - entries_safe: false, - } + ChainTrust(Rc::new(Link { + above: None, + writers: DirWriters::Others, + dir: None, + entries_safe: OnceCell::from(false), + })) } - /// The trust a directory found existing, open on `dir_fd`, in a directory that handed it + /// The trust a directory found existing, held as `dir`, in a directory that handed it /// `self`, hands its own entries. - pub fn found(self, dir_fd: RawFd) -> io::Result { - Ok(ChainTrust { - entries_safe: self.entries_safe && nobody_else_can_create(dir_fd)?, - }) + pub fn found(&self, dir: &Rc) -> io::Result { + Self::link(Some(self.clone()), dir) } - /// The trust a directory the caller made and verified (`verify_made_dir`), open on - /// `dir_fd`, hands its entries, wherever it is. - pub fn made(dir_fd: RawFd) -> io::Result { - Ok(ChainTrust { - entries_safe: nobody_else_can_create(dir_fd)?, - }) + /// The trust a directory the caller made and verified (`verify_made_dir`), held as `dir`, + /// hands its entries, wherever it is. + pub fn made(dir: &Rc) -> io::Result { + Self::link(None, dir) } /// What a directory found existing in a directory of this trust may be given, `requested` - /// being which of mode and owner the user asked to preserve. - pub fn found_dir(self, requested: Preserve) -> FoundDir { + /// being which of mode and owner the user asked to preserve. Only when one of them was + /// asked for is the trust worked out. + pub fn found_dir(&self, requested: Preserve) -> FoundDir { if !requested.mode && !requested.owner { FoundDir::TimesOnly - } else if self.entries_safe { + } else if self.entries_safe() { FoundDir::AsRequested } else { FoundDir::LeaveAlone } } + + /// Whether directories found existing in this one may take what was asked for: the links + /// not yet worked out, from the top down, each safe only below a safe one. + fn entries_safe(&self) -> bool { + let mut unknown = Vec::new(); + let mut known = true; + let mut link = Some(self); + while let Some(ChainTrust(this)) = link { + if let Some(&safe) = this.entries_safe.get() { + known = safe; + break; + } + unknown.push(this); + link = this.above.as_ref(); + } + for this in unknown.into_iter().rev() { + known = known && this.own_entries_safe(); + let _ = this.entries_safe.set(known); + } + known + } +} + +impl Link { + /// Whether nobody but the user can create entries in this directory itself. + fn own_entries_safe(&self) -> bool { + match self.writers { + DirWriters::User => true, + DirWriters::Others => false, + DirWriters::UserAndGroup { gid, euid } => { + is_private_group(gid, euid) + && self + .dir + .as_ref() + .and_then(Weak::upgrade) + .is_some_and(|dir| !acl_may_name_others(dir.as_raw_fd())) + } + } + } +} + +/// Who can create entries in a directory, as far as its `fstat` shows. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum DirWriters { + /// The effective user alone: it is theirs and grants no group or other write permission. + User, + /// Someone else too: it is someone else's, or grants other write permission. + Others, + /// The user, and members of its group `gid`: it is the user's (`euid`), grants group write + /// permission and no other. Nobody else when that group is the user's private one and no + /// ACL names anyone else (`nobody_else_can_create`). + UserAndGroup { gid: u32, euid: u32 }, +} + +/// `DirWriters` for the directory `st`, the effective user being `euid`. +fn dir_writers(st: &libc::stat, euid: u32) -> DirWriters { + // Cast needed: `mode_t` is u16 on macOS and u32 on Linux. S_IWGRP is 0o020 and S_IWOTH + // 0o002 (fixed by POSIX). + #[allow(clippy::unnecessary_cast)] + let mode = st.st_mode as u32; + if st.st_uid != euid || mode & 0o002 != 0 { + DirWriters::Others + } else if mode & 0o020 == 0 { + DirWriters::User + } else { + DirWriters::UserAndGroup { + gid: st.st_gid, + euid, + } + } } /// Whether nobody but the effective user can create entries in the directory open on `fd`: it @@ -294,8 +424,13 @@ impl ChainTrust { /// given the mode or owner asked for. pub fn nobody_else_can_create(fd: RawFd) -> io::Result { let euid = unsafe { libc::geteuid() }; - let private = |gid, euid| is_private_group(gid, euid) && !acl_may_name_others(fd); - Ok(nobody_else_can_create_with(&fstat(fd)?, euid, private)) + Ok(match dir_writers(&fstat(fd)?, euid) { + DirWriters::User => true, + DirWriters::Others => false, + DirWriters::UserAndGroup { gid, euid } => { + is_private_group(gid, euid) && !acl_may_name_others(fd) + } + }) } /// Whether the access ACL of the directory open on `fd` may grant anyone but its owner and its @@ -368,22 +503,6 @@ fn acl_names_others(xattr: &[u8]) -> bool { }) } -/// `nobody_else_can_create`, asking `private(gid, euid)` whether a group is the user's alone. -fn nobody_else_can_create_with( - parent: &libc::stat, - euid: u32, - private: impl FnOnce(u32, u32) -> bool, -) -> bool { - // Cast needed: `mode_t` is u16 on macOS and u32 on Linux. S_IWGRP is 0o020 and S_IWOTH - // 0o002 (fixed by POSIX). - #[allow(clippy::unnecessary_cast)] - let mode = parent.st_mode as u32; - if parent.st_uid != euid || mode & 0o002 != 0 { - return false; - } - mode & 0o020 == 0 || private(parent.st_gid, euid) -} - /// Whether the group `gid` is the private group of the user `euid`, by the user-private-group /// convention (`useradd`, `adduser`): nobody else is in it, so its write permission is the /// user's own. All of these must hold (`group_is_private`): @@ -408,6 +527,7 @@ fn nobody_else_can_create_with( /// - A group password lets anyone who knows it `newgrp` into the group; with shadow groups it /// is out of the user's reach to read, and is not considered. pub fn is_private_group(gid: u32, euid: u32) -> bool { + PRIVATE_GROUP_QUERIES.with(|queries| queries.set(queries.get() + 1)); if !cfg!(target_os = "linux") { return false; } @@ -419,6 +539,18 @@ pub fn is_private_group(gid: u32, euid: u32) -> bool { .or_insert_with(|| read_private_group(gid, euid)) } +thread_local! { + /// How many times this thread has asked `is_private_group`, cached or not. + static PRIVATE_GROUP_QUERIES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times this thread has asked whether a group is private (`is_private_group`): for +/// tests that a run which asks nothing that needs it -- no mode or owner to preserve -- looks +/// nothing up. +pub fn private_group_queries() -> usize { + PRIVATE_GROUP_QUERIES.with(|queries| queries.get()) +} + /// `is_private_group`, read from the databases (under its lock: the lookups return storage the /// next one reuses). fn read_private_group(gid: u32, euid: u32) -> bool { @@ -738,13 +870,14 @@ pub fn utimens_link_if_still( #[cfg(test)] mod tests { use super::{ - acl_names_others, empty_lending_read, group_entry, group_is_private, is_private_group, - made_by_us, nobody_else_can_create_with, others_can_rename, user_entry, - utimens_link_if_still, verify_made_dir, ChainTrust, FoundDir, FsOwners, MadeObject, - MadeTrust, Preserve, UserEntry, + acl_names_others, dir_writers, empty_lending_read, group_entry, group_is_private, + is_private_group, made_by_us, others_can_rename, user_entry, utimens_link_if_still, + verify_made_dir, ChainTrust, DirWriters, FoundDir, FsOwners, MadeObject, MadeTrust, + Preserve, UserEntry, }; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; + use std::rc::Rc; /// A parent directory of `uid` with permission bits `mode`. fn parent(uid: u32, mode: libc::mode_t) -> libc::stat { @@ -758,34 +891,27 @@ mod tests { /// entries beside it. #[test] fn who_can_create_in_a_parent() { - let shared = |st: &libc::stat, euid| nobody_else_can_create_with(st, euid, |_, _| false); - let private = |st: &libc::stat, euid| nobody_else_can_create_with(st, euid, |_, _| true); + use DirWriters::{Others, User, UserAndGroup}; + let group = |gid, euid| UserAndGroup { gid, euid }; // Nobody but the user can. - assert!(shared(&parent(US, 0o755), US)); - assert!(shared(&parent(0, 0o755), 0)); + assert_eq!(dir_writers(&parent(US, 0o755), US), User); + assert_eq!(dir_writers(&parent(0, 0o755), 0), User); // A sticky directory others may write -- /tmp, root extracting into it too. - assert!(!shared(&parent(US, 0o1777), US)); - assert!(!shared(&parent(0, 0o1777), 0)); - assert!(!private(&parent(US, 0o1777), US)); - // Group or other write permission. - assert!(!shared(&parent(US, 0o775), US)); - assert!(!shared(&parent(US, 0o757), US)); - // Group write permission for the user's private group is the user's own; other - // write permission never is. - assert!(private(&parent(US, 0o775), US)); - assert!(private(&parent(US, 0o2775), US)); - assert!(!private(&parent(US, 0o777), US)); - assert!(!private(&parent(US, 0o757), US)); - // Someone else's directory: its owner can. - assert!(!shared(&parent(OTHER, 0o755), US)); - assert!(!private(&parent(OTHER, 0o775), US)); - // The group asked about is the directory's. + assert_eq!(dir_writers(&parent(US, 0o1777), US), Others); + assert_eq!(dir_writers(&parent(0, 0o1777), 0), Others); + // Other write permission, whatever the group's. + assert_eq!(dir_writers(&parent(US, 0o757), US), Others); + assert_eq!(dir_writers(&parent(US, 0o777), US), Others); + // Group write permission alone: the group's members, unless it is the user's private + // group -- asked only later, of the directory's own group. let mut st = parent(US, 0o775); st.st_gid = 4242; - assert!(nobody_else_can_create_with(&st, US, |gid, euid| gid - == 4242 - && euid == US)); - assert!(!nobody_else_can_create_with(&st, US, |gid, _| gid != 4242)); + assert_eq!(dir_writers(&st, US), group(4242, US)); + st.st_mode = libc::S_IFDIR | 0o2775; + assert_eq!(dir_writers(&st, US), group(4242, US)); + // Someone else's directory: its owner can. + assert_eq!(dir_writers(&parent(OTHER, 0o755), US), Others); + assert_eq!(dir_writers(&parent(OTHER, 0o775), US), Others); } /// A group is the user's private one, by the user-private-group convention, only when it @@ -903,10 +1029,15 @@ mod tests { mode: false, owner: true, }; - let fd = |path: &str| std::fs::File::open(root.join(path)).unwrap(); - let anchor = ChainTrust::anchor(fd("").as_raw_fd()).unwrap(); - let g = anchor.found(fd("g").as_raw_fd()).unwrap(); - let x = g.found(fd("g/x").as_raw_fd()).unwrap(); + let fd = |path: &str| Rc::new(std::fs::File::open(root.join(path)).unwrap()); + let (root_fd, g_fd, x_fd) = (fd(""), fd("g"), fd("g/x")); + let anchor = ChainTrust::anchor(&root_fd).unwrap(); + let g = anchor.found(&g_fd).unwrap(); + let x = g.found(&x_fd).unwrap(); + + // Nothing is worked out until a mode or owner is asked for. + assert_eq!(x.found_dir(none), FoundDir::TimesOnly); + assert!(x.0.entries_safe.get().is_none() && anchor.0.entries_safe.get().is_none()); // `g` itself: found in the anchor, which only the user can write. assert_eq!(anchor.found_dir(mode), FoundDir::AsRequested); @@ -918,7 +1049,7 @@ mod tests { assert_eq!(x.found_dir(owner), FoundDir::LeaveAlone); assert_eq!(x.found_dir(none), FoundDir::TimesOnly); // Had the caller made and verified `x`, what it finds in it is safe. - let made_x = ChainTrust::made(fd("g/x").as_raw_fd()).unwrap(); + let made_x = ChainTrust::made(&x_fd).unwrap(); assert_eq!(made_x.found_dir(mode), FoundDir::AsRequested); assert_eq!(made_x.found_dir(owner), FoundDir::AsRequested); // A directory found in no directory the caller can locate takes nothing asked for, and @@ -926,10 +1057,48 @@ mod tests { let unlocated = ChainTrust::unlocated(); assert_eq!(unlocated.found_dir(mode), FoundDir::LeaveAlone); assert_eq!(unlocated.found_dir(none), FoundDir::TimesOnly); - let below = unlocated.found(fd("").as_raw_fd()).unwrap(); + let below = unlocated.found(&root_fd).unwrap(); assert_eq!(below.found_dir(mode), FoundDir::LeaveAlone); } + /// A link holds its directory's descriptor weakly. A group-writable one, whose group needs + /// asking about, counts as one others may write once its holder has closed it; one the + /// mode alone settles does not need it. + #[test] + fn a_closed_group_writable_directory_is_not_trusted() { + let tmp = crate::tmp::TempDir::new().unwrap(); + let mode = Preserve { + mode: true, + owner: true, + }; + for (perm, open, closed) in [(0o755, true, true), (0o775, private_here(), false)] { + std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(perm)).unwrap(); + let held = Rc::new(std::fs::File::open(tmp.path()).unwrap()); + let trust = ChainTrust::anchor(&held).unwrap(); + let again = ChainTrust::anchor(&held).unwrap(); + assert_eq!( + trust.found_dir(mode) == FoundDir::AsRequested, + open, + "{perm:o}" + ); + drop(held); + assert_eq!( + again.found_dir(mode) == FoundDir::AsRequested, + closed, + "{perm:o}" + ); + } + std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + } + + /// Whether a directory this test makes is of the user's private group, with no ACL. + fn private_here() -> bool { + let tmp = crate::tmp::TempDir::new().unwrap(); + std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o775)).unwrap(); + let dir = std::fs::File::open(tmp.path()).unwrap(); + super::nobody_else_can_create(dir.as_raw_fd()).unwrap() + } + /// Only the parent's owner, or anyone allowed to write a parent that is not sticky, can /// rename entries in it. #[test] diff --git a/tree/common/copy.rs b/tree/common/copy.rs index dce70cc47..a275f345f 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -226,7 +226,7 @@ enum DirOrigin { /// found existing there: whether, as a directory, it may take the source's mode and owner under /// -p (`ChainTrust`). The anchor the trust is carried down from is the destination directory /// the user named. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub enum OperandTrust { /// The operand is that directory itself, copied into (`cp -R src/. dest`): the anchor, /// which takes what -p asks wherever it is, as GNU cp gives it. @@ -355,46 +355,55 @@ fn as_asked(requested: Preserve) -> FoundDir { /// the link's owner chose, and judging it by the directory the link is in, or by its own `..`, /// would hand it trust that directory never gave. Anything not located hands none /// (`ChainTrust::unlocated`). +/// +/// The directory opened here comes back with the trust, for the caller to hold while it asks +/// for that trust: the trust holds it weakly. fn parent_anchor( - parent: &ftw::FileDescriptor, + parent: &Rc, id: (u64, u64), target: &Path, -) -> io::Result { +) -> io::Result<(ChainTrust, Option>)> { + let unlocated = || Ok((ChainTrust::unlocated(), None)); let Some(name) = target.file_name() else { - return Ok(ChainTrust::unlocated()); + return unlocated(); }; let Ok(name) = CString::new(name.as_bytes()) else { - return Ok(ChainTrust::unlocated()); + return unlocated(); }; - let opened; - let dir = if parent.as_raw_fd() == libc::AT_FDCWD { + let opened = if parent.as_raw_fd() == libc::AT_FDCWD { let path = match target.parent() { Some(path) if !path.as_os_str().is_empty() => path, _ => Path::new("."), }; let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { - return Ok(ChainTrust::unlocated()); + return unlocated(); }; let flags = SEARCH_ONLY | libc::O_DIRECTORY | libc::O_CLOEXEC; match open_fd_at(libc::AT_FDCWD, &path, flags) { - Ok(fd) => opened = fd, - Err(_) => return Ok(ChainTrust::unlocated()), + Ok(fd) => Some(Rc::new(fd)), + Err(_) => return unlocated(), } - opened.as_raw_fd() } else { - parent.as_raw_fd() + None }; + let dir = opened + .as_ref() + .map_or(parent.as_raw_fd(), |fd| fd.as_raw_fd()); let mut st: libc::stat = unsafe { std::mem::zeroed() }; let flags = libc::AT_SYMLINK_NOFOLLOW; if unsafe { libc::fstatat(dir, name.as_ptr(), &mut st, flags) } != 0 { - return Ok(ChainTrust::unlocated()); + return unlocated(); } // Cast needed: `dev_t` is i32 on macOS and u64 on Linux. #[allow(clippy::unnecessary_cast)] if (st.st_dev as u64, st.st_ino as u64) != id { - return Ok(ChainTrust::unlocated()); + return unlocated(); } - ChainTrust::anchor(dir) + let anchor = match &opened { + Some(opened) => ChainTrust::anchor(opened)?, + None => ChainTrust::anchor(parent)?, + }; + Ok((anchor, opened)) } /// For a directory found existing at the destination path `target`, open on `fd` with identity @@ -406,34 +415,35 @@ fn parent_anchor( /// that is reported (`ChainTrust::found_dir`, which pax follows too). (One this run made at that /// path is its own, and not judged at all: `own_dir_trust`.) fn found_dir_trust( - hands: OperandTrust, - parent: &ftw::FileDescriptor, - fd: &ftw::FileDescriptor, + hands: &OperandTrust, + parent: &Rc, + fd: &Rc, id: (u64, u64), target: &Path, requested: Preserve, ) -> io::Result<(DirFinish, ChainTrust)> { - let chain = match hands { + let (chain, _held) = match hands { OperandTrust::Named => { let finish = DirFinish::Found(as_asked(requested)); - return Ok((finish, ChainTrust::anchor(fd.as_raw_fd())?)); + return Ok((finish, ChainTrust::anchor(fd)?)); } OperandTrust::Parent => parent_anchor(parent, id, target)?, - OperandTrust::Chain(chain) => chain, + OperandTrust::Chain(chain) => (chain.clone(), None), }; + // Asked while the anchor `parent_anchor` opened is still held. let finish = DirFinish::Found(chain.found_dir(requested)); - Ok((finish, chain.found(fd.as_raw_fd())?)) + Ok((finish, chain.found(fd)?)) } /// For a directory found existing, open on `fd`, that this run made at the same path /// (`MadeDirs::made_at`): what it is given once its contents are copied -- what -p asks, as /// for any directory the run made -- and the trust it hands, afresh. fn own_dir_trust( - fd: &ftw::FileDescriptor, + fd: &Rc, requested: Preserve, ) -> io::Result<(DirFinish, ChainTrust)> { let finish = DirFinish::Found(as_asked(requested)); - Ok((finish, ChainTrust::made(fd.as_raw_fd())?)) + Ok((finish, ChainTrust::made(fd)?)) } /// The -p failure reported for a directory found existing where others could have created its @@ -2184,7 +2194,7 @@ where let at_top_level = target_dirfd_stack_borrowed.len() == 1; let target_level = target_dirfd_stack_borrowed.last().unwrap(); let target_dirfd = &target_level.fd; - let hands = target_level.hands; + let hands = &target_level.hands; let target_filename = if at_top_level { top_name @@ -2339,6 +2349,7 @@ where ) .map_err(cannot_open) .and_then(|fd| { + let fd = Rc::new(fd); let md = fd_metadata(fd.as_raw_fd())?; if dev != md.dev() || ino != md.ino() { return Err(io::Error::other(gettext!( @@ -2368,12 +2379,12 @@ where ) .map_err(|e| made_dir_open_error(&target, e)) .and_then(|(fd, made)| { - let fd = ftw::FileDescriptor::from(fd); + let fd = Rc::new(ftw::FileDescriptor::from(fd)); let md = fd_metadata(fd.as_raw_fd())?; let own = made == MadeTrust::Full; let trust = if own { made_by_run.borrow_mut().record(&md, &target); - ChainTrust::made(fd.as_raw_fd())? + ChainTrust::made(&fd)? } else { // Owned like its parent only, it may be someone else's: it // hands on what a directory found would. @@ -2416,7 +2427,7 @@ where } target_dirfd_stack_borrowed.push(DestDir { - fd: Rc::new(new_target_dirfd), + fd: new_target_dirfd, hands: OperandTrust::Chain(trust), finish: Some(finish), own, @@ -2867,6 +2878,66 @@ mod tests { } } + /// Whether a group is private is looked up only when the answer is used: when -p asks for + /// mode and owner and a directory found existing is met. A plain `cp -R` into a + /// group-writable destination, onto a directory already there, looks nothing up; `cp -pR` + /// does. + #[test] + fn private_groups_are_looked_up_only_under_p() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let tmp = plib::tmp::tempdir().unwrap(); + let dir = tmp.path(); + let source = dir.join("src"); + fs::create_dir_all(source.join("sub")).unwrap(); + fs::write(source.join("f"), b"f").unwrap(); + // Every directory group-writable, as under a umask of 002, and found again below. + let dest = dir.join("dest"); + fs::create_dir_all(dest.join("src/sub")).unwrap(); + for found in ["src/sub", "src", ""] { + fs::set_permissions(dest.join(found), fs::Permissions::from_mode(0o775)).unwrap(); + } + let copy = |cfg: &super::CopyConfig, name: &str| { + let _ = super::copy_file( + cfg, + &source, + &dest.join(name), + super::OperandTrust::Parent, + &mut super::CopyRun::default(), + None, + |_| false, + ); + }; + + let before = plib::madefs::private_group_queries(); + let plain = super::CopyConfig { + preserve: false, + ..cp_pr_config() + }; + copy(&plain, "src"); + assert_eq!( + plib::madefs::private_group_queries(), + before, + "cp -R looked up" + ); + assert_eq!(fs::read(dest.join("src/f")).unwrap(), b"f"); + // Under -p, but meeting no directory that was already there. + copy(&cp_pr_config(), "new"); + assert_eq!( + plib::madefs::private_group_queries(), + before, + "nothing found, yet looked up" + ); + assert_eq!(fs::read(dest.join("new/f")).unwrap(), b"f"); + copy(&cp_pr_config(), "src"); + assert!( + plib::madefs::private_group_queries() > before, + "cp -pR did not" + ); + fs::set_permissions(&dest, fs::Permissions::from_mode(0o755)).unwrap(); + } + /// `cp -pR src open/new/` where, after cp found no `open/new`, someone who can write `open` /// planted `new` as a symbolic link to the user's private directory in a parent only the /// user can write. The trailing slash follows the link, as GNU cp's copy does; but the diff --git a/tree/parents.rs b/tree/parents.rs index 29f36bf7c..885f3bf73 100644 --- a/tree/parents.rs +++ b/tree/parents.rs @@ -28,10 +28,11 @@ use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::MetadataExt; use std::path::{Component, Path, PathBuf}; +use std::rc::Rc; /// A destination directory `--parents` created, with the source directory it stands for. struct MadeDir { - dest: File, + dest: Rc, source: std::fs::Metadata, /// Where it is, for diagnostics only. path: PathBuf, @@ -72,7 +73,9 @@ fn cstring(bytes: &[u8]) -> io::Result { /// The target is the anchor, and the trust it hands is carried down the walk with the /// descriptors (`ChainTrust`): a directory found hands on what it was handed and its own, one /// this run made and verified in full -- now, or earlier at the same path (`MadeDirs`) -- -/// starts afresh. The last one's is returned, for the directories the copy finds in it. +/// starts afresh. The last one's is returned, for the directories the copy finds in it, with +/// every directory on the way held open (`Walked::held`): the trust is worked out only when +/// the copy asks for it, through those descriptors. fn make_parents( source: &Path, target: &Path, @@ -82,13 +85,15 @@ fn make_parents( ) -> io::Result { let mut made = Vec::new(); let mut own = Vec::new(); - let mut dest_dir = open_dir_at(libc::AT_FDCWD, &cstring(target.as_os_str().as_bytes())?, 0)?; - let mut trust = ChainTrust::anchor(dest_dir.as_raw_fd())?; + let target_c = cstring(target.as_os_str().as_bytes())?; + let mut dest_dir = Rc::new(open_dir_at(libc::AT_FDCWD, &target_c, 0)?); + let mut trust = ChainTrust::anchor(&dest_dir)?; + let mut held = vec![Rc::clone(&dest_dir)]; let Some(parent) = source.parent() else { return Ok(Walked { made, own, - dest: dest_dir, + held, trust, }); }; @@ -144,18 +149,18 @@ fn make_parents( // (`open_made_dir`). let (opened, made_trust) = open_made_dir(dest_dir.as_raw_fd(), &name, &dest_path) .map_err(|e| made_dir_open_error(&dest_path, e))?; - let next_dest = File::from(opened); + let next_dest = Rc::new(File::from(opened)); trust = if made_trust == MadeTrust::Full { made_dirs.record(&next_dest.metadata()?, &dest_path); - own.push((next_dest.try_clone()?, dest_path.clone())); - ChainTrust::made(next_dest.as_raw_fd())? + own.push((Rc::clone(&next_dest), dest_path.clone())); + ChainTrust::made(&next_dest)? } else { // Owned like its parent only, it may be someone else's: it hands on what a // directory found would. - trust.found(next_dest.as_raw_fd())? + trust.found(&next_dest)? }; made.push(MadeDir { - dest: next_dest.try_clone()?, + dest: Rc::clone(&next_dest), source: src_md, path: dest_path.clone(), trust: made_trust, @@ -173,21 +178,23 @@ fn make_parents( error_string(&e) )) })?; + let found = Rc::new(found); trust = if made_dirs.made_at(&found.metadata()?, &dest_path) { - own.push((found.try_clone()?, dest_path.clone())); - ChainTrust::made(found.as_raw_fd())? + own.push((Rc::clone(&found), dest_path.clone())); + ChainTrust::made(&found)? } else { - trust.found(found.as_raw_fd())? + trust.found(&found)? }; found }; + held.push(Rc::clone(&next_dest)); src_dir = next_src; dest_dir = next_dest; } Ok(Walked { made, own, - dest: dest_dir, + held, trust, }) } @@ -198,10 +205,11 @@ struct Walked { made: Vec, /// The directories on the way this run made, now or earlier (`MadeDirs`), and where: the /// copy below them changes them, and that is noted once it is done (`MadeDirs::refresh`). - own: Vec<(File, PathBuf)>, - /// The last directory, where the copy itself goes. - dest: File, - /// The trust that directory hands the directories the copy finds in it. + own: Vec<(Rc, PathBuf)>, + /// Every directory on the way, the target first and last the one the copy itself goes in, + /// held while the copy may ask for `trust`. + held: Vec>, + /// The trust the last directory hands the directories the copy finds in it. trust: ChainTrust, } @@ -302,7 +310,7 @@ where let Walked { made, own, - dest: dest_dir, + held, trust, } = match walked { Ok(walked) => walked, @@ -312,6 +320,17 @@ where continue; } }; + // The copy takes a descriptor of its own for the last directory; `held` stays open + // under the trust until the operand is done. + let dest_dir = match held.last().map(|dir| dir.try_clone()) { + Some(Ok(dir)) => dir, + Some(Err(e)) => { + eprintln!("cp: {}", error_string(&e)); + ok = false; + continue; + } + None => unreachable!("make_parents holds the target at least"), + }; let relative: PathBuf = source .components() .filter(|c| !matches!(c, Component::RootDir | Component::Prefix(_))) From da32966c8eab3eaa8c217bee33a4ea2e97c1719f Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 19:46:55 +0000 Subject: [PATCH 29/71] plib, cp, pax: trust nothing reached through a destination link others could have planted By the user's decision: under -p, when the last component of the destination the user named is a symbolic link in a directory others can create entries in, whoever planted the link chose the directory it leads to. With `open` 0777 holding `d -> ../home`, `cp -pR src open/d/` stamped the user's private home/src 0777, and `pax -rw -p e d open/l/` did the same to its existing directory. ChainTrust::named anchors a directory named by a path. The path's last component is read in its parent, opened for search only, without following it. A link makes the named directory one found in the link's directory: it hands its entries only what that directory hands and its own (anchor(holder).found(dir)), and may itself be given only what a directory found there may (NamedAnchor::named_dir). Where it is no link it must be the very directory opened; anything unreadable, or another directory, trusts nothing. Like the rest of the trust it is worked out only when a found directory is to be given a mode or owner. cp uses it for the directory the operand is named in (cp -R src dir/), for the destination copied into itself (src/.), and for the --parents target; pax for its copy-mode destination. pax -r anchors at ".", which is no link. The copy itself still follows the link, as GNU cp's does. Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 10 ++- pax/tests/security/existing_dir.rs | 28 ++++++ plib/src/madefs.rs | 138 ++++++++++++++++++++++++++++- tree/common/copy.rs | 50 +++++++---- tree/parents.rs | 9 +- tree/tests/cp/existing_dir.rs | 57 ++++++++++++ 6 files changed, 271 insertions(+), 21 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 1846dc9cf..5794e4ea1 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -22,7 +22,7 @@ use crate::error::{PaxError, PaxResult}; use crate::modes::made::{self, cvt, verify_made_dir, MadeNode, MadeTrust}; -use plib::madefs::{ChainTrust, FoundDir, Preserve, SEARCH_ONLY}; +use plib::madefs::{ChainTrust, FoundDir, NamedAnchor, Preserve, SEARCH_ONLY}; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::ffi::{CStr, CString, OsStr}; @@ -188,6 +188,8 @@ pub(crate) struct DirTree { /// The trust the anchor hands the directories found in it: the root of /// the trust every walk carries down (`ChainTrust`). root_trust: ChainTrust, + /// The anchor as the caller named it, held while `root_trust` may be asked. + _named: NamedAnchor, /// `(st_dev, st_ino)` of the directories this run created only to hold a /// member below them, each with the member path (`MemberPath::key`) it /// was made at. Such a directory is not a pre-existing file: a member @@ -238,8 +240,12 @@ impl DirTree { return Err(std::io::Error::last_os_error().into()); } let root = Rc::new(unsafe { OwnedFd::from_raw_fd(fd) }); + // Named through a symbolic link in a directory others can write, the anchor is + // wherever the link's owner chose, and trusts nothing (`ChainTrust::named`). + let named = ChainTrust::named(path, &root)?; Ok(DirTree { - root_trust: ChainTrust::anchor(&root)?, + root_trust: named.hands.clone(), + _named: named, root, chain: RefCell::new(Chain::default()), max_levels: cached_levels_budget(), diff --git a/pax/tests/security/existing_dir.rs b/pax/tests/security/existing_dir.rs index 65cb7aa63..427cea0d8 100644 --- a/pax/tests/security/existing_dir.rs +++ b/pax/tests/security/existing_dir.rs @@ -378,3 +378,31 @@ fn test_a_private_chain_still_stamps_under_pe() { assert_eq!(mode_of(&dest.join("p/secret")), 0o777, "copy={copy}"); } } + +/// A copy-mode destination named through a symbolic link that sits in a +/// directory others can write: whoever planted the link chose the directory +/// it leads to, so nothing found there is trusted, and under -p e the +/// existing directory keeps its mode. In a directory only the user can write, +/// the link is the user's own, and the directory is stamped. +#[test] +fn test_copy_trusts_no_destination_reached_through_a_link_others_could_plant() { + for (open_mode, code, mode) in [(0o777, 1, 0o700), (0o755, 0, 0o755)] { + let temp = TempDir::new().unwrap(); + let src = source_tree(&temp); + let home = dest_with_private_d(&temp, 0o755); + let open = temp.path().join("open"); + fs::create_dir(&open).unwrap(); + std::os::unix::fs::symlink(&home, open.join("l")).unwrap(); + fs::set_permissions(&open, fs::Permissions::from_mode(open_mode)).unwrap(); + let out = pax(&src, &["-rw", "-p", "e", "d", "../open/l/"]); + let stderr = String::from_utf8_lossy(&out.stderr); + fs::set_permissions(&open, fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + out.status.code(), + Some(code), + "open {open_mode:o}: {stderr}" + ); + assert_eq!(mode_of(&home.join("d")), mode, "open {open_mode:o}"); + assert!(home.join("d/f").exists(), "open {open_mode:o}"); + } +} diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index fdf889dd3..a6bf0e559 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -25,9 +25,9 @@ use std::ffi::{CStr, CString}; #[cfg(target_os = "linux")] use std::fs::File; use std::io; -#[cfg(target_os = "linux")] -use std::os::fd::FromRawFd; -use std::os::fd::{AsRawFd, RawFd}; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::os::unix::ffi::OsStrExt; +use std::path::{Component, Path}; use std::rc::{Rc, Weak}; use std::sync::Mutex; @@ -244,6 +244,31 @@ pub enum FoundDir { #[derive(Clone)] pub struct ChainTrust(Rc); +/// A directory the user named by a path, as the anchor of a chain (`ChainTrust::named`). +pub struct NamedAnchor { + /// The trust it hands its entries. + pub hands: ChainTrust, + /// Where its name's last component is a symbolic link, the trust of the directory holding + /// the link, which the named directory is found in; `None` where the user named the + /// directory itself. + through_link: Option, + /// The directory holding the link, held while either trust may be asked. + _holder: Option>, +} + +impl NamedAnchor { + /// What the named directory itself may be given, existing, `requested` being which of + /// mode and owner the user asked to preserve: what was asked, as the user named it; or, + /// reached through a link, what a directory found in the link's directory may be. + pub fn named_dir(&self, requested: Preserve) -> FoundDir { + match &self.through_link { + Some(trust) => trust.found_dir(requested), + None if requested.mode || requested.owner => FoundDir::AsRequested, + None => FoundDir::TimesOnly, + } + } +} + /// One directory of a `ChainTrust`. struct Link { /// What the directory this one is in hands it; `None` where a chain starts (`anchor`, @@ -325,6 +350,69 @@ impl ChainTrust { Self::link(None, dir) } + /// The anchor for a directory the user named as `path`, opened (following links, as named) + /// and held as `dir`. + /// + /// The user named the directory, and it is trusted as `anchor` -- unless the last component + /// of `path` is a symbolic link. Then the directory is wherever the link's owner chose, and + /// it is as if found in the directory holding the link: it may itself be given what a + /// directory found there may (`NamedAnchor::named_dir`), and hands its entries only what it + /// was handed and its own (`found`). A link planted in a directory others can write leads + /// nowhere the user vouched for. Its last component is read in its parent (opened for + /// search only, as named) without following it; where it is no link, it must be the very + /// directory opened. Anything that cannot be read so, or is not, trusts nothing. + pub fn named(path: &Path, dir: &Rc) -> io::Result { + let unlocated = || NamedAnchor { + hands: Self::unlocated(), + through_link: Some(Self::unlocated()), + _holder: None, + }; + let Some(Component::Normal(name)) = path.components().next_back() else { + // `/`, `.` or `..`: none can be a link. + return Ok(NamedAnchor { + hands: Self::anchor(dir)?, + through_link: None, + _holder: None, + }); + }; + let parent = match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + }; + let (Ok(name), Ok(parent)) = ( + CString::new(name.as_bytes()), + CString::new(parent.as_os_str().as_bytes()), + ) else { + return Ok(unlocated()); + }; + let flags = SEARCH_ONLY | libc::O_DIRECTORY | libc::O_CLOEXEC; + let holder = unsafe { libc::openat(libc::AT_FDCWD, parent.as_ptr(), flags) }; + if holder < 0 { + return Ok(unlocated()); + } + let holder = Rc::new(unsafe { OwnedFd::from_raw_fd(holder) }); + let Ok(st) = lstat_at(holder.as_raw_fd(), &name) else { + return Ok(unlocated()); + }; + if st.st_mode & libc::S_IFMT == libc::S_IFLNK { + let in_holder = Self::anchor(&holder)?; + return Ok(NamedAnchor { + hands: in_holder.found(dir)?, + through_link: Some(in_holder), + _holder: Some(holder), + }); + } + let opened = fstat(dir.as_raw_fd())?; + if (st.st_dev, st.st_ino) != (opened.st_dev, opened.st_ino) { + return Ok(unlocated()); + } + Ok(NamedAnchor { + hands: Self::anchor(dir)?, + through_link: None, + _holder: None, + }) + } + /// What a directory found existing in a directory of this trust may be given, `requested` /// being which of mode and owner the user asked to preserve. Only when one of them was /// asked for is the trust worked out. @@ -1061,6 +1149,50 @@ mod tests { assert_eq!(below.found_dir(mode), FoundDir::LeaveAlone); } + /// A directory the user names is trusted as named -- unless its name's last component is a + /// symbolic link in a directory others can write, or its name no longer holds it. + #[test] + fn a_named_directory_reached_through_a_link_is_found_in_the_links_directory() { + let tmp = crate::tmp::TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let open = tmp.path().join("open"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir(&open).unwrap(); + std::os::unix::fs::symlink(&home, open.join("l")).unwrap(); + let mode = Preserve { + mode: true, + owner: false, + }; + let held = Rc::new(std::fs::File::open(&home).unwrap()); + for (open_mode, through) in [ + (0o777, FoundDir::LeaveAlone), + (0o755, FoundDir::AsRequested), + ] { + std::fs::set_permissions(&open, std::fs::Permissions::from_mode(open_mode)).unwrap(); + for path in [open.join("l"), open.join("l/"), open.join("l/.").join("")] { + // `l/.` too: a trailing `.` is no component of its own. + let named = ChainTrust::named(&path, &held).unwrap(); + assert_eq!(named.named_dir(mode), through, "{path:?} in {open_mode:o}"); + assert_eq!( + named.hands.found_dir(mode), + through, + "{path:?} in {open_mode:o}" + ); + } + } + std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o755)).unwrap(); + // Named directly. + let named = ChainTrust::named(&home, &held).unwrap(); + assert_eq!(named.named_dir(mode), FoundDir::AsRequested); + assert_eq!(named.hands.found_dir(mode), FoundDir::AsRequested); + // A name that holds another directory than the one opened. + std::fs::rename(&home, tmp.path().join("moved")).unwrap(); + std::fs::create_dir(&home).unwrap(); + let named = ChainTrust::named(&home, &held).unwrap(); + assert_eq!(named.named_dir(mode), FoundDir::LeaveAlone); + assert_eq!(named.hands.found_dir(mode), FoundDir::LeaveAlone); + } + /// A link holds its directory's descriptor weakly. A group-writable one, whose group needs /// asking about, counts as one others may write once its holder has closed it; one the /// mode alone settles does not need it. diff --git a/tree/common/copy.rs b/tree/common/copy.rs index a275f345f..eaa1a84c8 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -15,7 +15,9 @@ pub use plib::madefs::ChainTrust; pub use plib::madefs::MadeTrust; #[cfg(target_os = "linux")] use plib::madefs::{chmod_pinned, proc_fd_name, procfs_dir, utimens_link_if_still}; -use plib::madefs::{fs_owners, made_by_us, FoundDir, FsOwners, MadeObject, Preserve, SEARCH_ONLY}; +use plib::madefs::{ + fs_owners, made_by_us, FoundDir, FsOwners, MadeObject, NamedAnchor, Preserve, SEARCH_ONLY, +}; use std::{ cell::RefCell, collections::{HashMap, HashSet}, @@ -356,13 +358,17 @@ fn as_asked(requested: Preserve) -> FoundDir { /// would hand it trust that directory never gave. Anything not located hands none /// (`ChainTrust::unlocated`). /// -/// The directory opened here comes back with the trust, for the caller to hold while it asks -/// for that trust: the trust holds it weakly. +/// The directory a path names is itself the directory the user named, and anchors as one +/// (`ChainTrust::named`): reached through a symbolic link in a directory others can write, it +/// trusts nothing. +/// +/// The directories opened here come back with the trust, for the caller to hold while it asks +/// for that trust: the trust holds them weakly. fn parent_anchor( parent: &Rc, id: (u64, u64), target: &Path, -) -> io::Result<(ChainTrust, Option>)> { +) -> io::Result<(ChainTrust, HeldAnchor)> { let unlocated = || Ok((ChainTrust::unlocated(), None)); let Some(name) = target.file_name() else { return unlocated(); @@ -370,11 +376,11 @@ fn parent_anchor( let Ok(name) = CString::new(name.as_bytes()) else { return unlocated(); }; + let path = match target.parent() { + Some(path) if !path.as_os_str().is_empty() => path, + _ => Path::new("."), + }; let opened = if parent.as_raw_fd() == libc::AT_FDCWD { - let path = match target.parent() { - Some(path) if !path.as_os_str().is_empty() => path, - _ => Path::new("."), - }; let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { return unlocated(); }; @@ -399,13 +405,18 @@ fn parent_anchor( if (st.st_dev as u64, st.st_ino as u64) != id { return unlocated(); } - let anchor = match &opened { - Some(opened) => ChainTrust::anchor(opened)?, - None => ChainTrust::anchor(parent)?, - }; - Ok((anchor, opened)) + match opened { + Some(opened) => { + let named = ChainTrust::named(path, &opened)?; + Ok((named.hands.clone(), Some((opened, named)))) + } + None => Ok((ChainTrust::anchor(parent)?, None)), + } } +/// What `parent_anchor` opened, held while its trust may be asked. +type HeldAnchor = Option<(Rc, NamedAnchor)>; + /// For a directory found existing at the destination path `target`, open on `fd` with identity /// `id`, in the directory `parent` handing it `hands`: what it is given once its contents are /// copied, and what it hands the directories found in it. @@ -423,9 +434,18 @@ fn found_dir_trust( requested: Preserve, ) -> io::Result<(DirFinish, ChainTrust)> { let (chain, _held) = match hands { + // Without a mode or owner asked for, the named directory is given nothing, and its + // name need not be read. + OperandTrust::Named if !requested.mode && !requested.owner => { + return Ok(( + DirFinish::Found(FoundDir::TimesOnly), + ChainTrust::anchor(fd)?, + )); + } OperandTrust::Named => { - let finish = DirFinish::Found(as_asked(requested)); - return Ok((finish, ChainTrust::anchor(fd)?)); + let named = ChainTrust::named(target, fd)?; + let finish = DirFinish::Found(named.named_dir(requested)); + return Ok((finish, named.hands.clone())); } OperandTrust::Parent => parent_anchor(parent, id, target)?, OperandTrust::Chain(chain) => (chain.clone(), None), diff --git a/tree/parents.rs b/tree/parents.rs index 885f3bf73..40787ec84 100644 --- a/tree/parents.rs +++ b/tree/parents.rs @@ -21,6 +21,7 @@ use crate::common::{ InodeMap, MadeDirs, MadeTrust, OperandTrust, }; use gettextrs::gettext; +use plib::madefs::NamedAnchor; use std::ffi::CString; use std::fs::File; use std::io; @@ -87,13 +88,15 @@ fn make_parents( let mut own = Vec::new(); let target_c = cstring(target.as_os_str().as_bytes())?; let mut dest_dir = Rc::new(open_dir_at(libc::AT_FDCWD, &target_c, 0)?); - let mut trust = ChainTrust::anchor(&dest_dir)?; + let anchor = ChainTrust::named(target, &dest_dir)?; + let mut trust = anchor.hands.clone(); let mut held = vec![Rc::clone(&dest_dir)]; let Some(parent) = source.parent() else { return Ok(Walked { made, own, held, + anchor, trust, }); }; @@ -195,6 +198,7 @@ fn make_parents( made, own, held, + anchor, trust, }) } @@ -209,6 +213,8 @@ struct Walked { /// Every directory on the way, the target first and last the one the copy itself goes in, /// held while the copy may ask for `trust`. held: Vec>, + /// The target as the user named it (`ChainTrust::named`), held likewise. + anchor: NamedAnchor, /// The trust the last directory hands the directories the copy finds in it. trust: ChainTrust, } @@ -311,6 +317,7 @@ where made, own, held, + anchor: _anchor, trust, } = match walked { Ok(walked) => walked, diff --git a/tree/tests/cp/existing_dir.rs b/tree/tests/cp/existing_dir.rs index 93c25e35a..e800d9720 100644 --- a/tree/tests/cp/existing_dir.rs +++ b/tree/tests/cp/existing_dir.rs @@ -404,3 +404,60 @@ fn cp_parents_refuses_a_source_ending_in_dot_dot() { assert_eq!(mode_of(&c.join("g")), 0o755); assert!(!c.join("g/f").exists()); } + +/// `open` (mode `open_mode`) holding `d -> ../home`, and `home` (mode `home_mode`) holding the +/// user's private `src` (0700); and a source `src` (0755, modified at `OLD_MTIME`). +fn link_scenario(open_mode: u32, home_mode: u32) -> TempDir { + let temp = tempdir().unwrap(); + source_dir(&temp.path().join("src"), 0o755); + with_private_dir(&temp.path().join("home"), home_mode, "src"); + let open = temp.path().join("open"); + fs::create_dir(&open).unwrap(); + std::os::unix::fs::symlink("../home", open.join("d")).unwrap(); + set_mode(&open, open_mode); + temp +} + +/// A destination named through a symbolic link that sits in a directory others can write: +/// whoever planted the link chose the directory it leads to, so nothing found there is +/// trusted. Under -p the found directory keeps everything, and that is reported; the copy +/// itself still follows the link, as GNU cp's does. In a directory only the user can write, +/// the link is the user's own, and the found directory is stamped. +#[test] +fn cp_pr_trusts_no_directory_reached_through_a_link_others_could_plant() { + for (open_mode, code, mode) in [(0o777, 1, 0o700), (0o755, 0, 0o755)] { + let temp = link_scenario(open_mode, 0o755); + let out = cp(temp.path(), &["-pR", "src", "open/d/"]); + let stderr = String::from_utf8_lossy(&out.stderr); + set_mode(&temp.path().join("open"), 0o755); + let found = temp.path().join("home/src"); + assert_eq!( + out.status.code(), + Some(code), + "open {open_mode:o}: {stderr}" + ); + assert_eq!(mode_of(&found), mode, "open {open_mode:o}"); + assert_eq!( + mtime_of(&found) == OLD_MTIME, + code == 0, + "open {open_mode:o}" + ); + assert_eq!(stderr.contains("'open/d/src'"), code == 1, "{stderr}"); + assert_eq!(fs::read_to_string(found.join("f")).unwrap(), "data\n"); + } +} + +/// The same for the destination itself, copied into (`src/.`): named through a link in a +/// directory others can write, it is the directory found, and keeps everything under -p. +#[test] +fn cp_pr_leaves_alone_a_named_destination_reached_through_a_link_others_could_plant() { + let temp = link_scenario(0o777, 0o700); + let out = cp(temp.path(), &["-pR", "src/.", "open/d/"]); + let stderr = String::from_utf8_lossy(&out.stderr); + set_mode(&temp.path().join("open"), 0o755); + let home = temp.path().join("home"); + assert_eq!(out.status.code(), Some(1), "{stderr}"); + assert!(stderr.contains(DIAGNOSTIC), "{stderr}"); + assert_eq!(mode_of(&home), 0o700, "the private directory was opened up"); + assert_eq!(fs::read_to_string(home.join("f")).unwrap(), "data\n"); +} From 1592a59ba9c74203abef25989344980313a5a45a Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 19:49:54 +0000 Subject: [PATCH 30/71] cp: know a directory the run made by the owner, group and mode cp left it with too A directory cp made counts as the run's own when found again at its path only with the status-change time cp left it with. That time has the grain of the clock tick the kernel stamps it with -- a whole second on ext4 with 128-byte inodes, HFS+ and NFSv3 -- and within the tick a directory removed and made again at its path under the same inode number passed for the made one: as root, cp would chown and chmod someone else's new directory. The record now also holds the owner, group and mode cp left the directory with, and a directory found must have all of them unchanged. Someone else's mkdir gives the directory their own uid, which cp did not leave there -- unless cp gave the one it made that very owner, and then the directory was theirs already. refresh notes all four after cp changes them itself. Co-Authored-By: Claude Opus 5.5 --- tree/common/copy.rs | 160 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 138 insertions(+), 22 deletions(-) diff --git a/tree/common/copy.rs b/tree/common/copy.rs index eaa1a84c8..d7f552ae6 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -252,54 +252,72 @@ pub enum OperandTrust { /// the name of a source directory it does not stand for, and it would take that one's mode. /// /// Nor is it the run's own once anything but cp has changed it since cp last did: its -/// status-change time must be the one cp noted then. An inode number is reused -- ext4 hands a -/// freed one straight back -- so whoever could remove the directory (an empty one, from a -/// parent they can write that is not sticky) could make one of their own at its path under its -/// number; that one's status-change time is its making, after cp's last change. cp notes its -/// own changes from the descriptor it has held throughout -- a directory held open keeps its -/// number from being reused -- once it has filled and finished one (`refresh`), so an honest -/// later operand still finds it unchanged. Residual: a status-change time has the grain of the -/// clock tick the kernel stamps it with (a few milliseconds on Linux without fine-grained -/// timestamps), and a directory removed and remade within the tick of cp's last change, under -/// the same number, passes for the one made. The birth time (statx, `st_birthtime`) would not -/// narrow that: it is stamped from the same clock. Recording a directory replaces whatever its -/// number stood for. +/// status-change time, owner, group and mode must be the ones cp left it with. An inode number +/// is reused -- ext4 hands a freed one straight back -- so whoever could remove the directory +/// (an empty one, from a parent they can write that is not sticky) could make one of their own +/// at its path under its number. That one's status-change time is its making, after cp's last +/// change; but the time has the grain of the clock tick the kernel stamps it with (a whole +/// second on ext4 with 128-byte inodes, HFS+ and NFSv3), so within that tick it is the owner +/// that tells them apart: someone else's `mkdir` gives the directory their own uid, which cp +/// did not leave there -- unless cp gave the one it made that very owner (-p, as root, from a +/// source of theirs), and then the directory was theirs already, and passing one of theirs for +/// it hands them nothing they did not have. That is the residual, with root, who can make a +/// directory with any owner. cp notes its own changes from the descriptor it has held +/// throughout -- a directory held open keeps its number from being reused -- once it has filled +/// and finished one (`refresh`), so an honest later operand still finds it unchanged. +/// Recording a directory replaces whatever its number stood for. #[derive(Default)] pub struct MadeDirs(HashMap<(u64, u64), MadeAt>); -/// Where a directory this run made is, and its status-change time when cp last changed it. +/// Where a directory this run made is, and its status as cp last left it. struct MadeAt { path: PathBuf, + left: LeftAs, +} + +/// What of a directory's status cp checks it left unchanged: its status-change time, owner, +/// group and mode. +#[derive(Clone, Copy, PartialEq, Eq)] +struct LeftAs { ctime: (i64, i64), + uid: u32, + gid: u32, + mode: u32, } -/// `(st_dev, st_ino)` and `st_ctim` of `md`. -fn id_and_ctime(md: &impl MetadataExt) -> ((u64, u64), (i64, i64)) { - ((md.dev(), md.ino()), (md.ctime(), md.ctime_nsec())) +/// `(st_dev, st_ino)` of `md`, and what `LeftAs` keeps of it. +fn id_and_status(md: &impl MetadataExt) -> ((u64, u64), LeftAs) { + let left = LeftAs { + ctime: (md.ctime(), md.ctime_nsec()), + uid: md.uid(), + gid: md.gid(), + mode: md.mode(), + }; + ((md.dev(), md.ino()), left) } impl MadeDirs { /// Record the directory `md` describes, just made at `path` and verified in full. pub fn record(&mut self, md: &impl MetadataExt, path: &Path) { - let (id, ctime) = id_and_ctime(md); + let (id, left) = id_and_status(md); let path = path.to_path_buf(); - self.0.insert(id, MadeAt { path, ctime }); + self.0.insert(id, MadeAt { path, left }); } /// Whether the directory `md` describes, found at `path`, is one this run made there. pub fn made_at(&self, md: &impl MetadataExt, path: &Path) -> bool { - let (id, ctime) = id_and_ctime(md); + let (id, left) = id_and_status(md); self.0 .get(&id) - .is_some_and(|at| at.path == path && at.ctime == ctime) + .is_some_and(|at| at.path == path && at.left == left) } /// Note what cp has just changed on the directory `md` describes, which this run made at /// `path` and has held since it found it to be so (`made_at`). pub fn refresh(&mut self, md: &impl MetadataExt, path: &Path) { - let (id, ctime) = id_and_ctime(md); + let (id, left) = id_and_status(md); if let Some(at) = self.0.get_mut(&id).filter(|at| at.path == path) { - at.ctime = ctime; + at.left = left; } } } @@ -2810,6 +2828,104 @@ mod tests { assert_eq!(after.mtime(), 978_307_200, "the times were not applied"); } + /// What `fstat` might report of a directory: only what `MadeDirs` reads is set. + #[derive(Clone, Copy)] + struct Status { + ino: u64, + uid: u32, + gid: u32, + mode: u32, + ctime: i64, + } + + impl std::os::unix::fs::MetadataExt for Status { + fn dev(&self) -> u64 { + 1 + } + fn ino(&self) -> u64 { + self.ino + } + fn mode(&self) -> u32 { + self.mode + } + fn nlink(&self) -> u64 { + 2 + } + fn uid(&self) -> u32 { + self.uid + } + fn gid(&self) -> u32 { + self.gid + } + fn rdev(&self) -> u64 { + 0 + } + fn size(&self) -> u64 { + 0 + } + fn atime(&self) -> i64 { + 0 + } + fn atime_nsec(&self) -> i64 { + 0 + } + fn mtime(&self) -> i64 { + 0 + } + fn mtime_nsec(&self) -> i64 { + 0 + } + fn ctime(&self) -> i64 { + self.ctime + } + fn ctime_nsec(&self) -> i64 { + 0 + } + fn blksize(&self) -> u64 { + 4096 + } + fn blocks(&self) -> u64 { + 0 + } + } + + /// Within one tick of the clock that stamps ctime, a directory removed and made again at + /// its path under the same inode number has the made one's ctime. It still does not pass + /// for the made one unless it also has the owner, group and mode cp left that one with -- + /// and someone else's `mkdir` gives it their own uid. + #[test] + fn a_made_directory_is_known_by_its_owner_group_and_mode_too() { + use super::MadeDirs; + // S_IFDIR, whose type is u16 on macOS and u32 on Linux. + const DIR: u32 = 0o040000; + let path = std::path::Path::new("dest/x"); + let made = Status { + ino: 7, + uid: 1000, + gid: 1000, + mode: DIR | 0o755, + ctime: 1_000_000_000, + }; + let mut dirs = MadeDirs::default(); + dirs.record(&made, path); + assert!(dirs.made_at(&made, path)); + for other in [ + Status { uid: 1001, ..made }, + Status { gid: 1001, ..made }, + Status { + mode: DIR | 0o777, + ..made + }, + ] { + assert!(!dirs.made_at(&other, path), "passed for the made one"); + } + // What cp itself changes, it notes. + let chowned = Status { uid: 0, ..made }; + dirs.refresh(&chowned, path); + assert!(dirs.made_at(&chowned, path)); + assert!(!dirs.made_at(&made, path)); + } + /// A directory this run made stops counting as made once anything but cp changes it -- /// above all once it is removed and another made at its path, which may take its inode /// number. What cp changes itself, it notes (`MadeDirs::refresh`). From 49a7fc9dcbb8c40fb1fe3c22569a538f18fc91e7 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 19:55:32 +0000 Subject: [PATCH 31/71] plib, cp, pax: resolve a named destination component by component, judging every link it follows ChainTrust::named, as 9d31e953 wrote it, checked only the last component of the path the user named for a symbolic link, and trusted a path ending in `.` or `..` with no check at all. A link earlier in the path was followed by the open of its parent -- `cp -pR src open/d/x`, with `d -> ../home` planted in a world-writable `open`, opened home and found x really there -- and `open/d/..` climbed out of the link's target into the victim's tree, both trusted as named. named now resolves the path itself, one component at a time, from the working directory (or `/`): each directory is opened O_NOFOLLOW in the one before it and held, `..` goes back to the directory the walk came from, and a symbolic link is followed by resolving its target the same way from the directory holding it, at most 40 deep (Linux's limit). The directories holding the links followed are recorded, and the walk must end at the very directory opened. With no link the anchor is trusted as named; with links, the named directory counts as found in all of their directories -- trusted only when every one is the user's alone, and worked out lazily like the rest. A missing component, an unreadable link, a loop, or ending elsewhere trusts nothing. The walk runs only when a mode or owner is preserved: cp's --parents target uses it under -p only, and pax copy mode through DirTree::open_dest when -p asks for mode or owner. Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 27 +++- pax/modes/copy.rs | 3 +- pax/tests/security/existing_dir.rs | 37 ++--- plib/src/madefs.rs | 245 +++++++++++++++++++++++------ tree/parents.rs | 16 +- tree/tests/cp/existing_dir.rs | 20 +++ 6 files changed, 267 insertions(+), 81 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 5794e4ea1..929bb1de4 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -188,8 +188,8 @@ pub(crate) struct DirTree { /// The trust the anchor hands the directories found in it: the root of /// the trust every walk carries down (`ChainTrust`). root_trust: ChainTrust, - /// The anchor as the caller named it, held while `root_trust` may be asked. - _named: NamedAnchor, + /// The anchor as the user named it, held while `root_trust` may be asked (`open_dest`). + _named: Option, /// `(st_dev, st_ino)` of the directories this run created only to hold a /// member below them, each with the member path (`MemberPath::key`) it /// was made at. Such a directory is not a pre-existing file: a member @@ -225,8 +225,16 @@ impl DirTree { Self::open_path(Path::new(".")) } - /// Anchor at a directory named by the caller, for copy mode's destination. + /// Anchor at a directory named by the caller, trusted as named. pub(crate) fn open_path(path: &Path) -> PaxResult { + Self::open_dest(path, false) + } + + /// Anchor at copy mode's destination directory, named by the user as `path`. When a mode + /// or owner is to be preserved (`preserving`), how `path` reaches it matters: through a + /// symbolic link in a directory others can write, it is wherever the link's owner chose, + /// and trusts nothing (`ChainTrust::named`). + pub(crate) fn open_dest(path: &Path, preserving: bool) -> PaxResult { let c = CString::new(path.as_os_str().as_bytes()) .map_err(|_| PaxError::InvalidHeader("path contains null".to_string()))?; let fd = unsafe { @@ -240,11 +248,16 @@ impl DirTree { return Err(std::io::Error::last_os_error().into()); } let root = Rc::new(unsafe { OwnedFd::from_raw_fd(fd) }); - // Named through a symbolic link in a directory others can write, the anchor is - // wherever the link's owner chose, and trusts nothing (`ChainTrust::named`). - let named = ChainTrust::named(path, &root)?; + let named = if preserving { + Some(ChainTrust::named(path, &root)?) + } else { + None + }; Ok(DirTree { - root_trust: named.hands.clone(), + root_trust: match &named { + Some(named) => named.hands.clone(), + None => ChainTrust::anchor(&root)?, + }, _named: named, root, chain: RefCell::new(Chain::default()), diff --git a/pax/modes/copy.rs b/pax/modes/copy.rs index 39b15572b..671380fe4 100644 --- a/pax/modes/copy.rs +++ b/pax/modes/copy.rs @@ -100,7 +100,8 @@ pub fn copy_files( // copy as an archive round-trip, so the destination gets the same treatment // extraction gives it: each component of a member name is opened with // O_NOFOLLOW, and the leaf is created fresh rather than written through. - let tree = DirTree::open_path(dest_dir)?; + let preserving = options.preserve_perms || options.preserve_owner; + let tree = DirTree::open_dest(dest_dir, preserving)?; let walk = CopyWalk { tree: &tree, diff --git a/pax/tests/security/existing_dir.rs b/pax/tests/security/existing_dir.rs index 427cea0d8..c495325ef 100644 --- a/pax/tests/security/existing_dir.rs +++ b/pax/tests/security/existing_dir.rs @@ -386,23 +386,24 @@ fn test_a_private_chain_still_stamps_under_pe() { /// the link is the user's own, and the directory is stamped. #[test] fn test_copy_trusts_no_destination_reached_through_a_link_others_could_plant() { - for (open_mode, code, mode) in [(0o777, 1, 0o700), (0o755, 0, 0o755)] { - let temp = TempDir::new().unwrap(); - let src = source_tree(&temp); - let home = dest_with_private_d(&temp, 0o755); - let open = temp.path().join("open"); - fs::create_dir(&open).unwrap(); - std::os::unix::fs::symlink(&home, open.join("l")).unwrap(); - fs::set_permissions(&open, fs::Permissions::from_mode(open_mode)).unwrap(); - let out = pax(&src, &["-rw", "-p", "e", "d", "../open/l/"]); - let stderr = String::from_utf8_lossy(&out.stderr); - fs::set_permissions(&open, fs::Permissions::from_mode(0o755)).unwrap(); - assert_eq!( - out.status.code(), - Some(code), - "open {open_mode:o}: {stderr}" - ); - assert_eq!(mode_of(&home.join("d")), mode, "open {open_mode:o}"); - assert!(home.join("d/f").exists(), "open {open_mode:o}"); + // The link as the last component, or in the middle (`m -> ..`, then `dest`). + for dest in ["../open/l/", "../open/m/dest"] { + for (open_mode, code, mode) in [(0o777, 1, 0o700), (0o755, 0, 0o755)] { + let temp = TempDir::new().unwrap(); + let src = source_tree(&temp); + let home = dest_with_private_d(&temp, 0o755); + let open = temp.path().join("open"); + fs::create_dir(&open).unwrap(); + std::os::unix::fs::symlink(&home, open.join("l")).unwrap(); + std::os::unix::fs::symlink("..", open.join("m")).unwrap(); + fs::set_permissions(&open, fs::Permissions::from_mode(open_mode)).unwrap(); + let out = pax(&src, &["-rw", "-p", "e", "d", dest]); + let stderr = String::from_utf8_lossy(&out.stderr); + fs::set_permissions(&open, fs::Permissions::from_mode(0o755)).unwrap(); + let case = format!("{dest} in {open_mode:o}"); + assert_eq!(out.status.code(), Some(code), "{case}: {stderr}"); + assert_eq!(mode_of(&home.join("d")), mode, "{case}"); + assert!(home.join("d/f").exists(), "{case}"); + } } } diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index a6bf0e559..ea6617ab9 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -21,7 +21,7 @@ use gettextrs::gettext; use std::cell::OnceCell; use std::collections::HashMap; -use std::ffi::{CStr, CString}; +use std::ffi::{CStr, CString, OsStr}; #[cfg(target_os = "linux")] use std::fs::File; use std::io; @@ -252,8 +252,93 @@ pub struct NamedAnchor { /// the link, which the named directory is found in; `None` where the user named the /// directory itself. through_link: Option, - /// The directory holding the link, held while either trust may be asked. - _holder: Option>, + /// The directories holding the links followed, held while either trust may be asked. + _holders: Vec>, +} + +/// The most symbolic links one named path may follow: Linux's own limit before ELOOP. +const MAX_LINKS: usize = 40; + +/// One step of resolving a path (`link_holders`). +enum Step { + /// Back to `/` (an absolute path, or link target). + Root, + /// `..`: back to the directory the walk came from. + Up, + /// An entry of the directory the walk is in. + Name(CString), +} + +/// The steps of `path`, last first, to be popped. +fn steps_of(path: &Path) -> Option> { + let mut steps = Vec::new(); + for component in path.components() { + steps.push(match component { + Component::RootDir => Step::Root, + Component::ParentDir => Step::Up, + Component::Normal(name) => Step::Name(CString::new(name.as_bytes()).ok()?), + Component::CurDir | Component::Prefix(_) => continue, + }); + } + steps.reverse(); + Some(steps) +} + +/// Resolve `path` as the kernel would, but one component at a time from held descriptors -- +/// each directory opened `O_NOFOLLOW` in the one before it, so the walk holds every directory +/// it passes through and `..` goes back to the one it came from -- and say which directories +/// held the symbolic links it followed, in order: a link's target is resolved the same way, +/// from the directory holding it. `None` when `path` cannot be resolved so (a missing +/// component, more than `MAX_LINKS` links, an unreadable link), or does not reach the directory +/// open on `dir`. +fn link_holders(path: &Path, dir: RawFd) -> Option>> { + let flags = SEARCH_ONLY | libc::O_DIRECTORY | libc::O_CLOEXEC; + let open = |at: RawFd, name: &CStr, extra: libc::c_int| { + let fd = unsafe { libc::openat(at, name.as_ptr(), flags | extra) }; + (fd >= 0).then(|| Rc::new(unsafe { OwnedFd::from_raw_fd(fd) })) + }; + let mut steps = steps_of(path)?; + let mut walked = vec![open(libc::AT_FDCWD, c".", 0)?]; + let mut holders = Vec::new(); + while let Some(step) = steps.pop() { + let here = walked.last()?.as_raw_fd(); + match step { + Step::Root => walked = vec![open(libc::AT_FDCWD, c"/", 0)?], + Step::Up if walked.len() > 1 => { + walked.pop(); + } + Step::Up => walked = vec![open(here, c"..", 0)?], + Step::Name(name) => { + let st = lstat_at(here, &name).ok()?; + if st.st_mode & libc::S_IFMT != libc::S_IFLNK { + walked.push(open(here, &name, libc::O_NOFOLLOW)?); + continue; + } + if holders.len() == MAX_LINKS { + return None; + } + holders.push(Rc::clone(walked.last()?)); + let target = read_link_at(here, &name)?; + // Popped first: the target's steps, then the rest of the path. + steps.extend(steps_of(Path::new(OsStr::from_bytes(&target)))?); + } + } + } + let reached = fstat(walked.last()?.as_raw_fd()).ok()?; + let opened = fstat(dir).ok()?; + ((reached.st_dev, reached.st_ino) == (opened.st_dev, opened.st_ino)).then_some(holders) +} + +/// The target of the symbolic link `name` in `dirfd`; `None` when it cannot be read whole. +fn read_link_at(dirfd: RawFd, name: &CStr) -> Option> { + let mut buf = vec![0u8; libc::PATH_MAX as usize]; + let n = unsafe { libc::readlinkat(dirfd, name.as_ptr(), buf.as_mut_ptr().cast(), buf.len()) }; + let n = usize::try_from(n).ok()?; + if n >= buf.len() { + return None; + } + buf.truncate(n); + Some(buf) } impl NamedAnchor { @@ -353,63 +438,39 @@ impl ChainTrust { /// The anchor for a directory the user named as `path`, opened (following links, as named) /// and held as `dir`. /// - /// The user named the directory, and it is trusted as `anchor` -- unless the last component - /// of `path` is a symbolic link. Then the directory is wherever the link's owner chose, and - /// it is as if found in the directory holding the link: it may itself be given what a - /// directory found there may (`NamedAnchor::named_dir`), and hands its entries only what it - /// was handed and its own (`found`). A link planted in a directory others can write leads - /// nowhere the user vouched for. Its last component is read in its parent (opened for - /// search only, as named) without following it; where it is no link, it must be the very - /// directory opened. Anything that cannot be read so, or is not, trusts nothing. + /// The user named the directory, and where `path` reaches it through no symbolic link it is + /// trusted as `anchor`. A link, anywhere on the way, leads wherever its owner chose: the + /// directory is then as if found in the directories holding the links followed, and may + /// itself be given only what a directory found in all of them may + /// (`NamedAnchor::named_dir`), handing its entries no more than that and its own (`found`). + /// A link planted in a directory others can write leads nowhere the user vouched for. + /// + /// `path` is resolved here one component at a time from held descriptors + /// (`link_holders`), and must reach the very directory opened; anything that cannot be + /// resolved so, or is not, trusts nothing. pub fn named(path: &Path, dir: &Rc) -> io::Result { - let unlocated = || NamedAnchor { - hands: Self::unlocated(), - through_link: Some(Self::unlocated()), - _holder: None, + let Some(holders) = link_holders(path, dir.as_raw_fd()) else { + return Ok(NamedAnchor { + hands: Self::unlocated(), + through_link: Some(Self::unlocated()), + _holders: Vec::new(), + }); }; - let Some(Component::Normal(name)) = path.components().next_back() else { - // `/`, `.` or `..`: none can be a link. + let Some((first, rest)) = holders.split_first() else { return Ok(NamedAnchor { hands: Self::anchor(dir)?, through_link: None, - _holder: None, + _holders: holders, }); }; - let parent = match path.parent() { - Some(parent) if !parent.as_os_str().is_empty() => parent, - _ => Path::new("."), - }; - let (Ok(name), Ok(parent)) = ( - CString::new(name.as_bytes()), - CString::new(parent.as_os_str().as_bytes()), - ) else { - return Ok(unlocated()); - }; - let flags = SEARCH_ONLY | libc::O_DIRECTORY | libc::O_CLOEXEC; - let holder = unsafe { libc::openat(libc::AT_FDCWD, parent.as_ptr(), flags) }; - if holder < 0 { - return Ok(unlocated()); - } - let holder = Rc::new(unsafe { OwnedFd::from_raw_fd(holder) }); - let Ok(st) = lstat_at(holder.as_raw_fd(), &name) else { - return Ok(unlocated()); - }; - if st.st_mode & libc::S_IFMT == libc::S_IFLNK { - let in_holder = Self::anchor(&holder)?; - return Ok(NamedAnchor { - hands: in_holder.found(dir)?, - through_link: Some(in_holder), - _holder: Some(holder), - }); - } - let opened = fstat(dir.as_raw_fd())?; - if (st.st_dev, st.st_ino) != (opened.st_dev, opened.st_ino) { - return Ok(unlocated()); + let mut in_holders = Self::anchor(first)?; + for holder in rest { + in_holders = in_holders.found(holder)?; } Ok(NamedAnchor { - hands: Self::anchor(dir)?, - through_link: None, - _holder: None, + hands: in_holders.found(dir)?, + through_link: Some(in_holders), + _holders: holders, }) } @@ -965,6 +1026,7 @@ mod tests { }; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; + use std::path::Path; use std::rc::Rc; /// A parent directory of `uid` with permission bits `mode`. @@ -1193,6 +1255,87 @@ mod tests { assert_eq!(named.hands.found_dir(mode), FoundDir::LeaveAlone); } + /// A link anywhere in a named path -- in its last component, in one before it, met again + /// inside another link's target, or left by a `..` -- leads wherever its owner chose: the + /// directory reached is trusted only where every directory holding a link the path follows + /// is the user's alone. A path through no link is trusted as named. + #[test] + fn a_named_path_is_trusted_only_through_links_the_user_vouches_for() { + let tmp = crate::tmp::TempDir::new().unwrap(); + let root = tmp.path(); + // home (0755) / sub, x; open (0777) / d -> ../home/sub, m -> ../home; + // safe (0755) / l -> ../home, e -> ../open/m; loop (0755) / a -> b, b -> a. + for dir in ["home/sub", "home/x", "open", "safe", "loop"] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + let link = |target: &str, at: &str| std::os::unix::fs::symlink(target, root.join(at)); + link("../home/sub", "open/d").unwrap(); + link("../home", "open/m").unwrap(); + link("../home", "safe/l").unwrap(); + link("../open/m", "safe/e").unwrap(); + link("b", "loop/a").unwrap(); + link("a", "loop/b").unwrap(); + for (dir, mode) in [ + ("home", 0o755), + ("open", 0o777), + ("safe", 0o755), + ("loop", 0o755), + ] { + std::fs::set_permissions(root.join(dir), std::fs::Permissions::from_mode(mode)) + .unwrap(); + } + let mode = Preserve { + mode: true, + owner: false, + }; + let held = |path: &Path| Rc::new(std::fs::File::open(path).unwrap()); + let judge = |named: &Path, opened: &Path| { + let dir = held(opened); + let named = ChainTrust::named(named, &dir).unwrap(); + (named.named_dir(mode), named.hands.found_dir(mode)) + }; + let trusted = (FoundDir::AsRequested, FoundDir::AsRequested); + let untrusted = (FoundDir::LeaveAlone, FoundDir::LeaveAlone); + let home = root.join("home"); + let cases = [ + // No link: as named. + (root.join("home"), home.clone(), trusted), + (root.join("home/sub/.."), home.clone(), trusted), + (root.join("home/./x"), home.join("x"), trusted), + // A link planted in the last component, a middle one, or left by `..`. + (root.join("open/m"), home.clone(), untrusted), + (root.join("open/m/"), home.clone(), untrusted), + (root.join("open/m/x"), home.join("x"), untrusted), + (root.join("open/d/.."), home.clone(), untrusted), + // A link the user's own directory holds, alone or leading to one planted. + (root.join("safe/l"), home.clone(), trusted), + (root.join("safe/l/x"), home.join("x"), trusted), + (root.join("safe/e"), home.clone(), untrusted), + (root.join("safe/e/x"), home.join("x"), untrusted), + // A loop of links, and a path that is not the directory opened. + (root.join("loop/a"), home.clone(), untrusted), + (root.join("home/x"), home.clone(), untrusted), + ]; + for (named, opened, expected) in cases { + assert_eq!(judge(&named, &opened), expected, "{named:?}"); + } + // `/`, `.` and a relative name with no slash (this crate's `src`): no link, as named. + assert_eq!( + judge(Path::new("/"), Path::new("/")).0, + FoundDir::AsRequested + ); + assert_eq!( + judge(Path::new("."), Path::new(".")).0, + FoundDir::AsRequested + ); + assert_eq!( + judge(Path::new("src"), Path::new("src")).0, + FoundDir::AsRequested + ); + std::fs::set_permissions(root.join("open"), std::fs::Permissions::from_mode(0o755)) + .unwrap(); + } + /// A link holds its directory's descriptor weakly. A group-writable one, whose group needs /// asking about, counts as one others may write once its holder has closed it; one the /// mode alone settles does not need it. diff --git a/tree/parents.rs b/tree/parents.rs index 40787ec84..889e8c224 100644 --- a/tree/parents.rs +++ b/tree/parents.rs @@ -88,8 +88,16 @@ fn make_parents( let mut own = Vec::new(); let target_c = cstring(target.as_os_str().as_bytes())?; let mut dest_dir = Rc::new(open_dir_at(libc::AT_FDCWD, &target_c, 0)?); - let anchor = ChainTrust::named(target, &dest_dir)?; - let mut trust = anchor.hands.clone(); + // How the target was reached matters only to a mode or owner preserved (-p). + let anchor = if preserve { + Some(ChainTrust::named(target, &dest_dir)?) + } else { + None + }; + let mut trust = match &anchor { + Some(anchor) => anchor.hands.clone(), + None => ChainTrust::anchor(&dest_dir)?, + }; let mut held = vec![Rc::clone(&dest_dir)]; let Some(parent) = source.parent() else { return Ok(Walked { @@ -213,8 +221,8 @@ struct Walked { /// Every directory on the way, the target first and last the one the copy itself goes in, /// held while the copy may ask for `trust`. held: Vec>, - /// The target as the user named it (`ChainTrust::named`), held likewise. - anchor: NamedAnchor, + /// The target as the user named it (`ChainTrust::named`), held likewise; under -p only. + anchor: Option, /// The trust the last directory hands the directories the copy finds in it. trust: ChainTrust, } diff --git a/tree/tests/cp/existing_dir.rs b/tree/tests/cp/existing_dir.rs index e800d9720..9f543ac52 100644 --- a/tree/tests/cp/existing_dir.rs +++ b/tree/tests/cp/existing_dir.rs @@ -447,6 +447,26 @@ fn cp_pr_trusts_no_directory_reached_through_a_link_others_could_plant() { } } +/// The link need not be the last component: one in the middle of the destination's path, or +/// one a `..` climbs back out of, leads just as far from where the user pointed. +#[test] +fn cp_pr_trusts_no_directory_reached_through_a_link_anywhere_in_the_destination() { + let temp = link_scenario(0o777, 0o755); + let home = temp.path().join("home"); + with_private_dir(&home.join("sub"), 0o755, "src"); + std::os::unix::fs::symlink("../home/sub", temp.path().join("open/e")).unwrap(); + for (destination, found) in [("open/d/sub", "home/sub/src"), ("open/e/..", "home/src")] { + let out = cp(temp.path(), &["-pR", "src", destination]); + let stderr = String::from_utf8_lossy(&out.stderr); + let found = temp.path().join(found); + assert_eq!(out.status.code(), Some(1), "{destination}: {stderr}"); + assert!(stderr.contains(DIAGNOSTIC), "{destination}: {stderr}"); + assert_eq!(mode_of(&found), 0o700, "{destination}: opened up"); + assert_eq!(fs::read_to_string(found.join("f")).unwrap(), "data\n"); + } + set_mode(&temp.path().join("open"), 0o755); +} + /// The same for the destination itself, copied into (`src/.`): named through a link in a /// directory others can write, it is the directory found, and keeps everything under -p. #[test] From ae3f2fa605b0dbbc5c7ab258e9eda4c2d7aaf8cd Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 19:57:29 +0000 Subject: [PATCH 32/71] cp: test refresh across a ctime tick, parent_anchor's identity check, and debhelper's merge in a 0755 dst - a_made_directory_changed_by_cp_itself_is_still_the_runs_own merges two operands into one directory of a world-writable destination, with a test-only hook moving the made directory's ctime into a later clock tick before cp finishes it, as a slow copy would. It fails with MadeDirs::refresh disabled; the integration test cp_pr_trusts_a_directory_it_made_when_found_again could not, since it usually finishes within one tick and a binary run takes no hook. - parent_anchor_requires_the_name_to_be_the_directory_found: a held parent, the same name, another (dev, ino): unlocated. - cp_n_recursive_merges_without_overwriting sets dst to 0755 again, so it does not depend on the host's umask and group; the private-group and shared-group cases have tests of their own. Co-Authored-By: Claude Opus 5.5 --- tree/common/copy.rs | 108 +++++++++++++++++++++++++++++++++++++ tree/tests/cp/debhelper.rs | 4 ++ 2 files changed, 112 insertions(+) diff --git a/tree/common/copy.rs b/tree/common/copy.rs index d7f552ae6..db9f99840 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -322,6 +322,14 @@ impl MadeDirs { } } +#[cfg(test)] +thread_local! { + /// Called with the descriptor of each directory of the run's own (`MadeDirs`) just before + /// cp finishes it: lets a test change it the way a slow copy would. + static BEFORE_FINISHING_OWN: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + /// What a cp run carries from one operand to the next. #[derive(Default)] pub struct CopyRun { @@ -2525,6 +2533,10 @@ where .. }) = target_dir { + #[cfg(test)] + if own { + BEFORE_FINISHING_OWN.with(|hook| hook.get().map(|hook| hook(fd.as_raw_fd()))); + } let finished = finish_dir( fd.as_raw_fd(), &source, @@ -2828,6 +2840,102 @@ mod tests { assert_eq!(after.mtime(), 978_307_200, "the times were not applied"); } + /// Move the status-change time of the directory open on `fd` into a later clock tick, as + /// a copy taking longer than a tick does: `fchmod` to the mode it has, until the time moves. + fn tick_ctime(fd: libc::c_int) { + let ctime = |fd| { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + assert_eq!(unsafe { libc::fstat(fd, &mut st) }, 0); + (st.st_ctime, st.st_ctime_nsec, st.st_mode) + }; + let (sec, nsec, mode) = ctime(fd); + loop { + std::thread::sleep(std::time::Duration::from_millis(2)); + assert_eq!(unsafe { libc::fchmod(fd, mode & 0o7777) }, 0); + let (now_sec, now_nsec, _) = ctime(fd); + if (now_sec, now_nsec) != (sec, nsec) { + return; + } + } + } + + /// Two operands merging into one directory of a destination others can write: the first + /// makes it, the second finds it the run's own and stamps it. Between the two, cp itself + /// changed it -- filled and finished it -- past a tick of the clock that stamps its ctime; + /// cp notes what it changed (`MadeDirs::refresh`), or the second operand would take it for + /// someone else's. + #[test] + fn a_made_directory_changed_by_cp_itself_is_still_the_runs_own() { + use std::fs; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let tmp = plib::tmp::tempdir().unwrap(); + let dir = tmp.path(); + for (source, mode, file) in [("s1/x", 0o700, "f"), ("s2/x", 0o750, "g")] { + fs::create_dir_all(dir.join(source)).unwrap(); + fs::write(dir.join(source).join(file), b"data").unwrap(); + fs::set_permissions(dir.join(source), fs::Permissions::from_mode(mode)).unwrap(); + } + let dest = dir.join("dest"); + fs::create_dir(&dest).unwrap(); + fs::set_permissions(&dest, fs::Permissions::from_mode(0o777)).unwrap(); + + super::BEFORE_FINISHING_OWN.with(|hook| hook.set(Some(tick_ctime))); + let mut run = super::CopyRun::default(); + for source in ["s1/x", "s2/x"] { + let copied = super::copy_file( + &cp_pr_config(), + &dir.join(source), + &dest.join("x"), + super::OperandTrust::Parent, + &mut run, + None, + |_| false, + ); + assert!(copied.is_ok(), "{source}: {copied:?}"); + } + super::BEFORE_FINISHING_OWN.with(|hook| hook.set(None)); + fs::set_permissions(&dest, fs::Permissions::from_mode(0o755)).unwrap(); + let x = fs::metadata(dest.join("x")).unwrap(); + assert_eq!(x.mode() & 0o7777, 0o750); + assert!(dest.join("x/f").exists() && dest.join("x/g").exists()); + } + + /// The anchor of a found operand is the directory its name is in only while that name is + /// the very directory found: another directory under the name -- the found one renamed + /// away and another put there -- leaves the operand in no directory it can judge. + #[test] + fn parent_anchor_requires_the_name_to_be_the_directory_found() { + use std::fs; + use std::os::unix::fs::MetadataExt; + use std::rc::Rc; + + let tmp = plib::tmp::tempdir().unwrap(); + let dir = tmp.path(); + fs::create_dir(dir.join("x")).unwrap(); + let parent_c = std::ffi::CString::new(dir.as_os_str().as_encoded_bytes()).unwrap(); + let parent = Rc::new( + ftw::FileDescriptor::open_at( + &ftw::FileDescriptor::cwd(), + &parent_c, + libc::O_RDONLY | libc::O_DIRECTORY, + ) + .unwrap(), + ); + let target = std::path::Path::new("x"); + let mode = plib::madefs::Preserve { + mode: true, + owner: true, + }; + let x = fs::metadata(dir.join("x")).unwrap(); + let (trust, _) = super::parent_anchor(&parent, (x.dev(), x.ino()), target).unwrap(); + assert_eq!(trust.found_dir(mode), super::FoundDir::AsRequested); + + let other = fs::metadata(dir).unwrap(); + let (trust, _) = super::parent_anchor(&parent, (other.dev(), other.ino()), target).unwrap(); + assert_eq!(trust.found_dir(mode), super::FoundDir::LeaveAlone); + } + /// What `fstat` might report of a directory: only what `MadeDirs` reads is set. #[derive(Clone, Copy)] struct Status { diff --git a/tree/tests/cp/debhelper.rs b/tree/tests/cp/debhelper.rs index 25856efb3..e20aa0ca8 100644 --- a/tree/tests/cp/debhelper.rs +++ b/tree/tests/cp/debhelper.rs @@ -101,6 +101,10 @@ fn cp_n_recursive_merges_without_overwriting() { fs::write(d.join("src/b"), "new b\n").unwrap(); fs::create_dir_all(d.join("dst/src")).unwrap(); fs::write(d.join("dst/src/a"), "old a\n").unwrap(); + // Only the user can create entries in `dst`, whatever the umask and the group, so the + // `dst/src` found there takes the source's attributes (tests/cp/existing_dir.rs has the + // group-writable cases). + fs::set_permissions(d.join("dst"), fs::Permissions::from_mode(0o755)).unwrap(); let (err, code) = cp_in(&d, &["-an", "src", "dst/"]); assert_eq!((err.as_str(), code), ("", 0)); assert_eq!(fs::read_to_string(d.join("dst/src/a")).unwrap(), "old a\n"); From 4705dc81b96c63f24a0c187f38deb5938d368dd9 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 19:59:09 +0000 Subject: [PATCH 33/71] plib: open search-only directories O_RDONLY on NetBSD SEARCH_ONLY used O_SEARCH on NetBSD, carried over from pax's copy of the constant. The libc crate defines it there (0x800000), but nothing here confirms that NetBSD's open(2) opens a directory for search alone with it rather than ignoring or refusing it, and open_made_dir, cp's found directory checks and pax's walk all depend on it. NetBSD falls back to O_RDONLY, as every system without a confirmed flag does: a directory the user may search but not read (0300) is then refused there, as it was before O_SEARCH was used. Co-Authored-By: Claude Opus 5.5 --- plib/src/madefs.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index ea6617ab9..052b5040a 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -36,18 +36,14 @@ use std::sync::Mutex; /// /// Reaching a name below a directory takes search permission only, so opening it for reading /// refuses a directory the user may search and write but not list (mode 0300) where `mkdir` or -/// `open` by name would have succeeded. `O_PATH` (Linux) and `O_SEARCH` (macOS, the BSDs) open -/// it for exactly that. Elsewhere `O_RDONLY` is the only option there is. +/// `open` by name would have succeeded. `O_PATH` (Linux) and `O_SEARCH` (macOS, FreeBSD) open +/// it for exactly that. Elsewhere `O_RDONLY` is the only option there is -- on NetBSD too, +/// whose `O_SEARCH` is defined but not known here to open a directory for search alone. #[cfg(target_os = "linux")] pub const SEARCH_ONLY: libc::c_int = libc::O_PATH; -#[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "netbsd"))] +#[cfg(any(target_os = "macos", target_os = "freebsd"))] pub const SEARCH_ONLY: libc::c_int = libc::O_SEARCH; -#[cfg(not(any( - target_os = "linux", - target_os = "macos", - target_os = "freebsd", - target_os = "netbsd" -)))] +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))] pub const SEARCH_ONLY: libc::c_int = libc::O_RDONLY; /// How a filesystem keeps file owners, as far as its type says. From eb790e1a4422186e472e615829e8bb6688243d3a Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 20:12:32 +0000 Subject: [PATCH 34/71] cp: let a --parents `..` return to the trust of the directory it returns to make_parents treated a `..` in the source path like any other component: mkdirat failed with EEXIST, the directory it named was "found", and judged by what the directory the walk had just come from hands -- a directory the run had made, trusted afresh. With a target reached through `tl -> /home/A/H` planted in a 0777 directory, `cp -pR --parents a/../d/x /tmp/open/tl` made H/a, came back to H as "found in a made directory", and stamped H/d/x 0777 with exit 0, where `cp -pR --parents d/x /tmp/open/tl` refused. The walk now keeps each directory it is in with the trust it hands, from the target down, and a `..` pops back to the one before, trust and all; it is never worked out from the directory below. A `..` above the target leads into a directory nothing vouched for, and hands no trust (ChainTrust::unlocated) -- a difference from GNU cp, which stamps what it finds there. Co-Authored-By: Claude Opus 5.5 --- tree/parents.rs | 20 ++++++++++++ tree/tests/cp/existing_dir.rs | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/tree/parents.rs b/tree/parents.rs index 889e8c224..9005111ab 100644 --- a/tree/parents.rs +++ b/tree/parents.rs @@ -113,6 +113,9 @@ fn make_parents( let mut dest_path = target.to_path_buf(); // The source's components so far, for -v. let mut src_path = PathBuf::from(if source.is_absolute() { "/" } else { "" }); + // Each directory the walk is in, from the target down, with the trust it hands: a `..` + // goes back to the one before. + let mut levels = vec![(Rc::clone(&dest_dir), trust.clone())]; for comp in parent.components() { // `..` is followed as written, as GNU cp does. @@ -130,6 +133,22 @@ fn make_parents( error_string(&e) )) })?; + if comp == Component::ParentDir { + // Back to the directory the walk was in before, with the trust it had then: never + // what a directory made or found below it hands. Above the target, the walk is in + // a directory nothing has vouched for. + src_dir = next_src; + if levels.len() > 1 { + levels.pop(); + (dest_dir, trust) = levels.last().cloned().expect("levels keeps the target"); + } else { + dest_dir = Rc::new(open_dir_at(dest_dir.as_raw_fd(), &name, 0)?); + trust = ChainTrust::unlocated(); + levels = vec![(Rc::clone(&dest_dir), trust.clone())]; + held.push(Rc::clone(&dest_dir)); + } + continue; + } let src_md = next_src.metadata()?; // Owner search and write are needed to fill the directory; without -p the umask @@ -199,6 +218,7 @@ fn make_parents( found }; held.push(Rc::clone(&next_dest)); + levels.push((Rc::clone(&next_dest), trust.clone())); src_dir = next_src; dest_dir = next_dest; } diff --git a/tree/tests/cp/existing_dir.rs b/tree/tests/cp/existing_dir.rs index 9f543ac52..72b033028 100644 --- a/tree/tests/cp/existing_dir.rs +++ b/tree/tests/cp/existing_dir.rs @@ -481,3 +481,62 @@ fn cp_pr_leaves_alone_a_named_destination_reached_through_a_link_others_could_pl assert_eq!(mode_of(&home), 0o700, "the private directory was opened up"); assert_eq!(fs::read_to_string(home.join("f")).unwrap(), "data\n"); } + +/// `--parents ../d/x`: the `..` climbs above the target, into a directory nothing vouched for +/// -- whatever its own mode -- and `d/x` found there keeps everything under -p. +#[test] +fn cp_parents_dot_dot_above_the_target_trusts_nothing() { + let temp = tempdir().unwrap(); + // Run in `c`: the source `../s/d/x` is `s/d/x`, its copy `w/t/../s/d/x`, `w/s/d/x`, + // where the user's own `w` already holds `s/d` and a private `x`. + fs::create_dir(temp.path().join("c")).unwrap(); + source_dir(&temp.path().join("s/d/x"), 0o755); + fs::create_dir_all(temp.path().join("w/t")).unwrap(); + fs::create_dir_all(temp.path().join("w/s/d")).unwrap(); + with_private_dir(&temp.path().join("w/s/d"), 0o755, "x"); + let out = cp( + &temp.path().join("c"), + &["-pR", "--parents", "../s/d/x", "../w/t"], + ); + let stderr = String::from_utf8_lossy(&out.stderr); + let found = temp.path().join("w/s/d/x"); + assert_eq!(mode_of(&found), 0o700, "{stderr}"); + assert_eq!(out.status.code(), Some(1), "{stderr}"); + assert!(stderr.contains(DIAGNOSTIC), "{stderr}"); + assert_eq!(fs::read_to_string(found.join("f")).unwrap(), "data\n"); +} + +/// `--parents a/../d/x`: the `..` returns from `a`, which the run makes, to the directory the +/// walk was in before -- which hands what it handed then, not what a directory made below it +/// hands. Under a target reached through a link planted in a directory others can write, +/// `d/x` keeps everything, with the `..` as without it; under a target the user's own, it is +/// stamped either way. +#[test] +fn cp_parents_dot_dot_returns_to_the_trust_it_left() { + for (target, code, mode) in [("../open/tl", 1, 0o700), ("../home/H", 0, 0o755)] { + for source in ["a/../d/x", "d/x"] { + let temp = tempdir().unwrap(); + let src = temp.path().join("srcroot"); + fs::create_dir_all(src.join("a")).unwrap(); + source_dir(&src.join("d/x"), 0o755); + set_mode(&src.join("d"), 0o755); + let home = temp.path().join("home/H"); + fs::create_dir_all(home.join("d")).unwrap(); + set_mode(&home.join("d"), 0o755); + with_private_dir(&home.join("d"), 0o755, "x"); + let open = temp.path().join("open"); + fs::create_dir(&open).unwrap(); + std::os::unix::fs::symlink("../home/H", open.join("tl")).unwrap(); + set_mode(&open, 0o777); + + let out = cp(&src, &["-pR", "--parents", source, target]); + let stderr = String::from_utf8_lossy(&out.stderr); + set_mode(&open, 0o755); + let case = format!("{source} into {target}"); + let x = home.join("d/x"); + assert_eq!(mode_of(&x), mode, "{case}: {stderr}"); + assert_eq!(out.status.code(), Some(code), "{case}: {stderr}"); + assert_eq!(fs::read_to_string(x.join("f")).unwrap(), "data\n", "{case}"); + } + } +} From f1f6039b2549e96ffa9ab4bca698ae3f2a1acc00 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 20:16:48 +0000 Subject: [PATCH 35/71] plib: count a directory with an NFSv4 or CIFS ACL as one others may write By the user's decision. An NFSv4 or CIFS ACL can grant anyone write permission on a directory whatever its mode shows: a plain 0755 directory on NFSv4 with such an ACE counted as the user's alone, and cp -p and pax -p gave what they found below it the source's mode and owner. Only system.posix_acl_access was read, and only for a group-writable directory of the user's private group. A directory now counts as others-writable, with group write permission or without, when it carries system.nfs4_acl, system.nfs4_acl_xdr or system.cifs_acl -- the attribute being there is enough, its entries are not read -- or when one cannot be read for any reason but its absence or the filesystem not supporting it. The POSIX ACL check stays, for group-writable directories, where its mask shows. Mode-bit trust is kept everywhere else, NFS and CIFS included. Both checks are one function (acls_let_others_write), reached through one rule (only_the_user_writes) shared by nobody_else_can_create and ChainTrust, and run only when -p trust is worked out, last, through the held descriptor -- or, for an O_PATH one, through /proc/self/fd/N once /proc is verified, which needs no permission on the directory (the POSIX ACL used to be read through a reopen for reading, which failed closed on a directory the user may not read). A directory whose holder has closed its descriptor by then counts as one others may write: in pax, any directory deeper than the levels it keeps open. Residual, documented: an ACL a server applies that the client does not show as an attribute at all. Co-Authored-By: Claude Opus 5.5 --- plib/src/madefs.rs | 239 ++++++++++++++++++++++++++++++--------------- 1 file changed, 162 insertions(+), 77 deletions(-) diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index 052b5040a..cca4dc6e3 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -508,18 +508,8 @@ impl ChainTrust { impl Link { /// Whether nobody but the user can create entries in this directory itself. fn own_entries_safe(&self) -> bool { - match self.writers { - DirWriters::User => true, - DirWriters::Others => false, - DirWriters::UserAndGroup { gid, euid } => { - is_private_group(gid, euid) - && self - .dir - .as_ref() - .and_then(Weak::upgrade) - .is_some_and(|dir| !acl_may_name_others(dir.as_raw_fd())) - } - } + let dir = self.dir.as_ref().and_then(Weak::upgrade); + only_the_user_writes(self.writers, dir.as_ref().map(|dir| dir.as_raw_fd())) } } @@ -556,81 +546,122 @@ fn dir_writers(st: &libc::stat, euid: u32) -> DirWriters { /// Whether nobody but the effective user can create entries in the directory open on `fd`: it /// is owned by that user and grants no other write permission, and no group write permission -/// either unless its group is the user's private group (`is_private_group`) and no ACL names -/// anyone else (`acl_may_name_others`) -- the user's alone, so the directories a umask of 002 -/// leaves group-writable, as Debian-style user private groups intend, count as the user's. A -/// sticky directory others may write counts as one they can create entries in. +/// either unless its group is the user's private group (`is_private_group`) -- the user's +/// alone, so the directories a umask of 002 leaves group-writable, as Debian-style user private +/// groups intend, count as the user's; and no ACL it carries may let others write +/// (`acls_let_others_write`: POSIX, NFSv4 and CIFS ACLs on Linux). A sticky directory others +/// may write counts as one they can create entries in. /// -/// Only what `st_mode` shows is seen otherwise. Write permission a POSIX ACL grants to named -/// users or groups shows there (in the group bits, the ACL mask), and so counts as others' -/// unless the ACL is read and names nobody; write permission a macOS or NFSv4 ACL grants does -/// not show, and is not taken into account. That is a residual: below a directory such an ACL -/// lets others write, a directory found existing -- possibly one of theirs renamed there -- is -/// given the mode or owner asked for. +/// A macOS ACL grants write permission the mode does not show, and is not read: a residual -- +/// below a directory such an ACL lets others write, a directory found existing, possibly one +/// of theirs renamed there, is given the mode or owner asked for. pub fn nobody_else_can_create(fd: RawFd) -> io::Result { let euid = unsafe { libc::geteuid() }; - Ok(match dir_writers(&fstat(fd)?, euid) { - DirWriters::User => true, + Ok(only_the_user_writes( + dir_writers(&fstat(fd)?, euid), + Some(fd), + )) +} + +/// Whether nobody but the user can create entries in a directory whose mode shows `writers`, +/// held as `dir` (`None` once its holder has closed it, which settles nothing and so counts as +/// others'): the rule `nobody_else_can_create` and `ChainTrust` both follow. +/// +/// Beyond the mode: a directory group-writable for the user's private group (`is_private_group`) +/// counts as the user's alone; and either way, an ACL read from the directory may let others +/// write (`acls_let_others_write`). The ACL is read last, only when the rest says the user's. +fn only_the_user_writes(writers: DirWriters, dir: Option) -> bool { + let acls_allow = |group_writable| { + dir.is_some_and(|fd| !acls_let_others_write(|name| read_xattr(fd, name), group_writable)) + }; + match writers { DirWriters::Others => false, - DirWriters::UserAndGroup { gid, euid } => { - is_private_group(gid, euid) && !acl_may_name_others(fd) - } - }) + DirWriters::User => acls_allow(false), + DirWriters::UserAndGroup { gid, euid } => is_private_group(gid, euid) && acls_allow(true), + } } -/// Whether the access ACL of the directory open on `fd` may grant anyone but its owner and its -/// owning group: it has a named user or named group entry, or it cannot be read for certain. -/// No ACL, or a filesystem without ACLs, grants nobody else. +/// Whether an ACL of a directory may let others write it, `read` reading one of its extended +/// attributes, failing with ENODATA or EOPNOTSUPP where it has none or they are not supported: +/// - an NFSv4 or CIFS ACL (`system.nfs4_acl`, `system.nfs4_acl_xdr`, `system.cifs_acl`): its +/// attribute being there at all, its entries unread -- one may grant anyone write whatever +/// the mode shows; +/// - where the directory is group-writable, a POSIX access ACL (`system.posix_acl_access`) +/// with a named user or group entry (`acl_names_others`), which shows in the mode as group +/// write permission (the ACL mask) and nowhere else. /// -/// Read as the `system.posix_acl_access` attribute. An `O_PATH` descriptor takes no `fgetxattr` -/// (EBADF); the directory is then reopened for reading through `self/fd/N` under a `/proc` -/// verified to be procfs (`procfs_dir`), which names the same inode -- and one the user may -/// not read counts as one that may. -#[cfg(target_os = "linux")] -fn acl_may_name_others(fd: RawFd) -> bool { - const ACCESS_ACL: &CStr = c"system.posix_acl_access"; - let read = |fd: RawFd, buf: &mut [u8]| { - let n = - unsafe { libc::fgetxattr(fd, ACCESS_ACL.as_ptr(), buf.as_mut_ptr().cast(), buf.len()) }; - usize::try_from(n).map_err(|_| io::Error::last_os_error()) +/// Any other failure to read one counts as one that does. Residual: an ACL a server applies +/// that the client does not show as an attribute at all, which nothing here can see. +fn acls_let_others_write( + read: impl Fn(&CStr) -> io::Result>, + group_writable: bool, +) -> bool { + const NFS4_OR_CIFS: [&CStr; 3] = [ + c"system.nfs4_acl", + c"system.nfs4_acl_xdr", + c"system.cifs_acl", + ]; + let absent = |e: &io::Error| { + e.raw_os_error() + .is_some_and(|code| [libc::ENODATA, libc::EOPNOTSUPP, libc::ENOTSUP].contains(&code)) }; - let mut buf = [0u8; 1024]; - let mut result = read(fd, &mut buf); - if result - .as_ref() - .is_err_and(|e| e.raw_os_error() == Some(libc::EBADF)) - { - let reopened = procfs_dir().and_then(|proc_dir| { - let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC; - let name = proc_fd_name(fd); - let fd = unsafe { libc::openat(proc_dir.as_raw_fd(), name.as_ptr(), flags) }; - if fd < 0 { - return Err(io::Error::last_os_error()); - } - Ok(unsafe { File::from_raw_fd(fd) }) - }); - result = reopened.and_then(|dir| read(dir.as_raw_fd(), &mut buf)); + for name in NFS4_OR_CIFS { + match read(name) { + Err(e) if absent(&e) => {} + _ => return true, + } } - match result { - Ok(len) => acl_names_others(&buf[..len]), - Err(e) => !matches!( - e.raw_os_error(), - Some(libc::ENODATA) | Some(libc::EOPNOTSUPP) - ), + if !group_writable { + return false; + } + match read(c"system.posix_acl_access") { + Ok(xattr) => acl_names_others(&xattr), + Err(e) => !absent(&e), } } -/// No ACL can be read here: assume one may (the private-group rule does not apply here anyway). +/// The extended attribute `name` of the directory open on `fd`. +/// +/// An `O_PATH` descriptor takes no `fgetxattr` (EBADF); the attribute is then read through +/// `/proc/self/fd/N`, once `/proc` is verified to be procfs (`procfs_dir`), which names the +/// same inode and needs no permission on it to read a `system.` attribute. (The path is +/// resolved again after the check: only root can mount something else over `/proc`.) +#[cfg(target_os = "linux")] +fn read_xattr(fd: RawFd, name: &CStr) -> io::Result> { + let mut buf = vec![0u8; 4096]; + let read = |n: isize, mut buf: Vec| { + let len = usize::try_from(n).map_err(|_| io::Error::last_os_error())?; + buf.truncate(len); + Ok(buf) + }; + let n = unsafe { libc::fgetxattr(fd, name.as_ptr(), buf.as_mut_ptr().cast(), buf.len()) }; + if n >= 0 || io::Error::last_os_error().raw_os_error() != Some(libc::EBADF) { + return read(n, buf); + } + procfs_dir()?; + let path = CString::new(format!("/proc/{}", proc_fd_name(fd).to_string_lossy())) + .expect("a formatted number has no NUL"); + let n = unsafe { + libc::getxattr( + path.as_ptr(), + name.as_ptr(), + buf.as_mut_ptr().cast(), + buf.len(), + ) + }; + read(n, buf) +} + +/// No extended attribute is read here: none is supported (EOPNOTSUPP), and only the mode tells. #[cfg(not(target_os = "linux"))] -fn acl_may_name_others(_fd: RawFd) -> bool { - true +fn read_xattr(_fd: RawFd, _name: &CStr) -> io::Result> { + Err(io::Error::from_raw_os_error(libc::EOPNOTSUPP)) } /// Whether the `system.posix_acl_access` attribute `xattr` has an entry beyond the owner, the /// owning group, the mask and others -- or is not one this reads for certain. Its format is /// the kernel's: a little-endian 32-bit version (2), then 8-byte entries of a 16-bit tag, a /// 16-bit permission set and a 32-bit id. -#[cfg(any(target_os = "linux", test))] fn acl_names_others(xattr: &[u8]) -> bool { const USER_OBJ: u16 = 0x01; const GROUP_OBJ: u16 = 0x04; @@ -1015,10 +1046,10 @@ pub fn utimens_link_if_still( #[cfg(test)] mod tests { use super::{ - acl_names_others, dir_writers, empty_lending_read, group_entry, group_is_private, - is_private_group, made_by_us, others_can_rename, user_entry, utimens_link_if_still, - verify_made_dir, ChainTrust, DirWriters, FoundDir, FsOwners, MadeObject, MadeTrust, - Preserve, UserEntry, + acl_names_others, acls_let_others_write, dir_writers, empty_lending_read, group_entry, + group_is_private, is_private_group, made_by_us, others_can_rename, user_entry, + utimens_link_if_still, verify_made_dir, ChainTrust, DirWriters, FoundDir, FsOwners, + MadeObject, MadeTrust, Preserve, UserEntry, }; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -1093,6 +1124,61 @@ mod tests { assert!(!group_is_private(500, &us, b"us", [None])); } + /// An NFSv4 or CIFS ACL may let anyone write, whatever the mode shows: its attribute being + /// there at all, or not readable for certain, makes a directory others may write -- with + /// group write permission or without. A POSIX ACL widens only group write permission, and + /// counts only then. + #[test] + fn which_acl_attributes_let_others_write() { + use std::ffi::CStr; + let absent = || Err(std::io::Error::from_raw_os_error(libc::ENODATA)); + let unsupported = || Err(std::io::Error::from_raw_os_error(libc::EOPNOTSUPP)); + let named_user = { + let mut xattr = 2u32.to_le_bytes().to_vec(); + for tag in [0x01u16, 0x02, 0x04, 0x10, 0x20] { + xattr.extend(tag.to_le_bytes()); + xattr.extend(7u16.to_le_bytes()); + xattr.extend(0u32.to_le_bytes()); + } + xattr + }; + // Each case: the one attribute present (or failing), and whether that lets others + // write without group write permission and with it. + let cases: [(&CStr, std::io::Result>, bool, bool); 6] = [ + (c"system.nfs4_acl", Ok(vec![0; 8]), true, true), + (c"system.nfs4_acl_xdr", Ok(Vec::new()), true, true), + (c"system.cifs_acl", Ok(vec![1]), true, true), + ( + c"system.cifs_acl", + Err(std::io::Error::from_raw_os_error(libc::EIO)), + true, + true, + ), + (c"system.posix_acl_access", Ok(named_user), false, true), + (c"system.posix_acl_access", unsupported(), false, false), + ]; + for (name, value, without_group, with_group) in cases { + let read = |asked: &CStr| -> std::io::Result> { + if asked != name { + return absent(); + } + match &value { + Ok(value) => Ok(value.clone()), + Err(e) => Err(std::io::Error::from_raw_os_error(e.raw_os_error().unwrap())), + } + }; + assert_eq!( + acls_let_others_write(read, false), + without_group, + "{name:?}" + ); + assert_eq!(acls_let_others_write(read, true), with_group, "{name:?}"); + } + // No attribute at all, or none supported: the mode tells. + assert!(!acls_let_others_write(|_| absent(), true)); + assert!(!acls_let_others_write(|_| unsupported(), true)); + } + /// An access ACL names someone else when it has any entry but the owner's, the owning /// group's, the mask and others'; one that cannot be read for certain counts as naming. #[test] @@ -1332,17 +1418,16 @@ mod tests { .unwrap(); } - /// A link holds its directory's descriptor weakly. A group-writable one, whose group needs - /// asking about, counts as one others may write once its holder has closed it; one the - /// mode alone settles does not need it. + /// A link holds its directory's descriptor weakly: its ACLs, read only when asked, cannot + /// be read once its holder has closed it, and it then counts as one others may write. #[test] - fn a_closed_group_writable_directory_is_not_trusted() { + fn a_closed_directory_is_not_trusted() { let tmp = crate::tmp::TempDir::new().unwrap(); let mode = Preserve { mode: true, owner: true, }; - for (perm, open, closed) in [(0o755, true, true), (0o775, private_here(), false)] { + for (perm, open, closed) in [(0o755, true, false), (0o775, private_here(), false)] { std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(perm)).unwrap(); let held = Rc::new(std::fs::File::open(tmp.path()).unwrap()); let trust = ChainTrust::anchor(&held).unwrap(); From f558aa9e5846676d7474b836c585d69a2ab8c864 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 20:20:27 +0000 Subject: [PATCH 36/71] plib, cp, pax: one trust rule, test instrumentation behind a test-hooks feature - nobody_else_can_create, which only a plib test used, was a second copy of the rule ChainTrust follows. The rule is now only_the_user_writes alone, and nobody_else_can_create is a cfg(test) wrapper of it. - The private_group_queries counter lived in every build of plib. It is read by cp's and pax's unit tests, in other crates, which cfg(test) in plib does not reach; it is now behind plib's test-hooks feature, enabled only by tree's and pax's [dev-dependencies], so a cargo build of the utilities never carries it. - cp's as_asked repeated the no-mode/no-owner arm of ChainTrust::found_dir and NamedAnchor::named_dir. All three now use Preserve::where_trusted. - dir_writers and others_can_rename say why they are different rules: one asks who could have created a name the caller finds (the sticky bit stops nobody creating one), the other who could have renamed over a directory just made (the sticky bit stops that, and what it lets through is verified anyway). Co-Authored-By: Claude Opus 5.5 --- pax/Cargo.toml | 1 + plib/Cargo.toml | 5 +++ plib/src/madefs.rs | 77 ++++++++++++++++++++++++++++----------------- tree/Cargo.toml | 1 + tree/common/copy.rs | 12 +------ 5 files changed, 57 insertions(+), 39 deletions(-) diff --git a/pax/Cargo.toml b/pax/Cargo.toml index e2856c3d9..0a15bf4f5 100644 --- a/pax/Cargo.toml +++ b/pax/Cargo.toml @@ -35,6 +35,7 @@ ftw = { path = "../ftw" } [dev-dependencies] filetime = "0.2" +plib = { path = "../plib", features = ["test-hooks"] } [[bin]] name = "pax" diff --git a/plib/Cargo.toml b/plib/Cargo.toml index 67d1588af..286598453 100644 --- a/plib/Cargo.toml +++ b/plib/Cargo.toml @@ -29,6 +29,11 @@ ftw = { path = "../ftw" } [build-dependencies] cc = "1" +[features] +# Instrumentation only tests read, across crates: enabled by the [dev-dependencies] of the +# crates whose tests use it, so release builds never carry it. +test-hooks = [] + [lints] workspace = true diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index cca4dc6e3..415656b1d 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -173,7 +173,8 @@ pub fn made_by_us(made: MadeObject, parent_uid: Option, euid: u32) -> Optio /// Whether anyone but `euid` can rename entries in the directory `parent`: its owner, when that /// is someone else, and anyone with group or other write permission on it when it is not -/// sticky. +/// sticky. (Who could have created a name the caller finds is another question, with another +/// answer: `dir_writers`.) /// /// Only what `st_mode` shows is seen. Write permission a POSIX ACL grants to named users or /// groups shows there (in the group bits, the ACL mask); write permission a macOS or NFSv4 ACL @@ -195,6 +196,18 @@ pub struct Preserve { pub owner: bool, } +impl Preserve { + /// What a directory found existing is given where nobody else could have created its name: + /// what was asked for, or its times alone when neither mode nor owner was. + pub fn where_trusted(self) -> FoundDir { + if self.mode || self.owner { + FoundDir::AsRequested + } else { + FoundDir::TimesOnly + } + } +} + /// What a directory found already existing -- not one the caller made and verified -- is to /// be given (`ChainTrust::found_dir`). #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -344,8 +357,7 @@ impl NamedAnchor { pub fn named_dir(&self, requested: Preserve) -> FoundDir { match &self.through_link { Some(trust) => trust.found_dir(requested), - None if requested.mode || requested.owner => FoundDir::AsRequested, - None => FoundDir::TimesOnly, + None => requested.where_trusted(), } } } @@ -474,12 +486,9 @@ impl ChainTrust { /// being which of mode and owner the user asked to preserve. Only when one of them was /// asked for is the trust worked out. pub fn found_dir(&self, requested: Preserve) -> FoundDir { - if !requested.mode && !requested.owner { - FoundDir::TimesOnly - } else if self.entries_safe() { - FoundDir::AsRequested - } else { - FoundDir::LeaveAlone + match requested.where_trusted() { + FoundDir::AsRequested if !self.entries_safe() => FoundDir::LeaveAlone, + given => given, } } @@ -527,6 +536,14 @@ enum DirWriters { } /// `DirWriters` for the directory `st`, the effective user being `euid`. +/// +/// Not the rule `others_can_rename` follows, on purpose. That one asks whether someone could +/// have renamed something over a directory the caller has just made, and the sticky bit stops +/// others renaming the caller's entries. This one asks whether someone could have created a +/// name the caller finds -- renaming in a directory of their choosing -- and the sticky bit +/// stops nobody creating a new name. Nor does `others_can_rename` make an exception for the +/// user's private group: where it says yes, the directory just made is verified anyway +/// (`verify_made_dir`), with no lookup. fn dir_writers(st: &libc::stat, euid: u32) -> DirWriters { // Cast needed: `mode_t` is u16 on macOS and u32 on Linux. S_IWGRP is 0o020 and S_IWOTH // 0o002 (fixed by POSIX). @@ -544,18 +561,9 @@ fn dir_writers(st: &libc::stat, euid: u32) -> DirWriters { } } -/// Whether nobody but the effective user can create entries in the directory open on `fd`: it -/// is owned by that user and grants no other write permission, and no group write permission -/// either unless its group is the user's private group (`is_private_group`) -- the user's -/// alone, so the directories a umask of 002 leaves group-writable, as Debian-style user private -/// groups intend, count as the user's; and no ACL it carries may let others write -/// (`acls_let_others_write`: POSIX, NFSv4 and CIFS ACLs on Linux). A sticky directory others -/// may write counts as one they can create entries in. -/// -/// A macOS ACL grants write permission the mode does not show, and is not read: a residual -- -/// below a directory such an ACL lets others write, a directory found existing, possibly one -/// of theirs renamed there, is given the mode or owner asked for. -pub fn nobody_else_can_create(fd: RawFd) -> io::Result { +/// `only_the_user_writes` for the directory open on `fd`, worked out at once: for tests. +#[cfg(test)] +fn nobody_else_can_create(fd: RawFd) -> io::Result { let euid = unsafe { libc::geteuid() }; Ok(only_the_user_writes( dir_writers(&fstat(fd)?, euid), @@ -563,13 +571,21 @@ pub fn nobody_else_can_create(fd: RawFd) -> io::Result { )) } -/// Whether nobody but the user can create entries in a directory whose mode shows `writers`, -/// held as `dir` (`None` once its holder has closed it, which settles nothing and so counts as -/// others'): the rule `nobody_else_can_create` and `ChainTrust` both follow. +/// Whether nobody but the effective user can create entries in a directory whose mode shows +/// `writers`, held as `dir` (`None` once its holder has closed it, which settles nothing and so +/// counts as others'): the rule `ChainTrust` follows. +/// +/// The directory must be the user's and grant no other write permission, and no group write +/// permission either unless its group is the user's private group (`is_private_group`) -- the +/// user's alone, so the directories a umask of 002 leaves group-writable, as Debian-style user +/// private groups intend, count as the user's; and no ACL it carries may let others write +/// (`acls_let_others_write`: POSIX, NFSv4 and CIFS ACLs on Linux), read last, only when the +/// rest says the user's. A sticky directory others may write counts as one they can create +/// entries in. /// -/// Beyond the mode: a directory group-writable for the user's private group (`is_private_group`) -/// counts as the user's alone; and either way, an ACL read from the directory may let others -/// write (`acls_let_others_write`). The ACL is read last, only when the rest says the user's. +/// A macOS ACL grants write permission the mode does not show, and is not read: a residual -- +/// below a directory such an ACL lets others write, a directory found existing, possibly one +/// of theirs renamed there, is given the mode or owner asked for. fn only_the_user_writes(writers: DirWriters, dir: Option) -> bool { let acls_allow = |group_writable| { dir.is_some_and(|fd| !acls_let_others_write(|name| read_xattr(fd, name), group_writable)) @@ -703,6 +719,7 @@ fn acl_names_others(xattr: &[u8]) -> bool { /// - A group password lets anyone who knows it `newgrp` into the group; with shadow groups it /// is out of the user's reach to read, and is not considered. pub fn is_private_group(gid: u32, euid: u32) -> bool { + #[cfg(feature = "test-hooks")] PRIVATE_GROUP_QUERIES.with(|queries| queries.set(queries.get() + 1)); if !cfg!(target_os = "linux") { return false; @@ -715,6 +732,7 @@ pub fn is_private_group(gid: u32, euid: u32) -> bool { .or_insert_with(|| read_private_group(gid, euid)) } +#[cfg(feature = "test-hooks")] thread_local! { /// How many times this thread has asked `is_private_group`, cached or not. static PRIVATE_GROUP_QUERIES: std::cell::Cell = const { std::cell::Cell::new(0) }; @@ -722,7 +740,10 @@ thread_local! { /// How many times this thread has asked whether a group is private (`is_private_group`): for /// tests that a run which asks nothing that needs it -- no mode or owner to preserve -- looks -/// nothing up. +/// nothing up. The tests are cp's and pax's, in other crates, which `cfg(test)` here would not +/// reach; so it is the `test-hooks` feature, which only their `[dev-dependencies]` enable: a +/// `cargo build` of the utilities never carries it, only the builds `cargo test` makes. +#[cfg(feature = "test-hooks")] pub fn private_group_queries() -> usize { PRIVATE_GROUP_QUERIES.with(|queries| queries.get()) } diff --git a/tree/Cargo.toml b/tree/Cargo.toml index 7dbe96504..dafe8cde0 100644 --- a/tree/Cargo.toml +++ b/tree/Cargo.toml @@ -18,6 +18,7 @@ chrono.workspace = true errno.workspace = true [dev-dependencies] +plib = { path = "../plib", features = ["test-hooks"] } [features] posixutils_test_all = [] diff --git a/tree/common/copy.rs b/tree/common/copy.rs index db9f99840..8bc16657d 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -361,16 +361,6 @@ struct DestDir { own: bool, } -/// What a directory that may take what -p asks of it is given: `ChainTrust::found_dir` where -/// the chain is trusted. -fn as_asked(requested: Preserve) -> FoundDir { - if requested.mode || requested.owner { - FoundDir::AsRequested - } else { - FoundDir::TimesOnly - } -} - /// The trust the anchor of `OperandTrust::Parent` hands the operand `target`, found existing as /// the directory with identity `id`: the directory `target` names it in, when it is there under /// its own name. @@ -488,7 +478,7 @@ fn own_dir_trust( fd: &Rc, requested: Preserve, ) -> io::Result<(DirFinish, ChainTrust)> { - let finish = DirFinish::Found(as_asked(requested)); + let finish = DirFinish::Found(requested.where_trusted()); Ok((finish, ChainTrust::made(fd)?)) } From 2c03f269b968c66a761442750def26166f3e046d Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Thu, 8 Oct 2026 20:22:27 +0000 Subject: [PATCH 37/71] plib, cp, pax: document the trust rule's remaining residuals From the final audit, each where the rule it qualifies is described: - is_private_group: getgrgid returns the first NSS source's entry, so a same-gid group in a later source, listing others, is not seen; - ChainTrust::named: the working directory, and the directories of an absolute path above its first link, are trusted as the user named them; only directories holding links are judged; - MadeDirs: after -p as root gives a made directory away, a refresh can record a change its new owner made while cp filled it; - read_xattr: without a procfs to read an O_PATH directory's ACLs through, the directory counts as others-writable; - pax DirTree::max_levels: directories deeper than the levels pax keeps open count as others-writable when their trust is asked. Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 5 ++++- plib/src/madefs.rs | 10 +++++++++- tree/common/copy.rs | 7 +++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 929bb1de4..122903d8c 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -179,7 +179,10 @@ pub(crate) struct DirTree { /// shares less of the chain than that, and cuts the chain off there before /// anything below it can be reused. chain: RefCell, - /// How many levels `chain` may hold: each is an open descriptor. + /// How many levels `chain` may hold: each is an open descriptor. A directory deeper than + /// that is closed once walked through, and its trust (`ChainTrust`), worked out only when a + /// directory found below it is to be given a mode or owner, then counts as one others may + /// write: an existing directory that deep keeps its attributes under -p, failing closed. max_levels: usize, /// The parent most recently walked to, and the trust it hands the /// directories found in it, so consecutive members of one directory diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index 415656b1d..da164f59f 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -456,6 +456,10 @@ impl ChainTrust { /// `path` is resolved here one component at a time from held descriptors /// (`link_holders`), and must reach the very directory opened; anything that cannot be /// resolved so, or is not, trusts nothing. + /// + /// Residual: a relative path starts from the working directory, which is trusted as the user + /// named it -- by running there -- and so are the directories of an absolute path above its + /// first link: only the directories holding links are judged. pub fn named(path: &Path, dir: &Rc) -> io::Result { let Some(holders) = link_holders(path, dir.as_raw_fd()) else { return Ok(NamedAnchor { @@ -641,7 +645,8 @@ fn acls_let_others_write( /// An `O_PATH` descriptor takes no `fgetxattr` (EBADF); the attribute is then read through /// `/proc/self/fd/N`, once `/proc` is verified to be procfs (`procfs_dir`), which names the /// same inode and needs no permission on it to read a `system.` attribute. (The path is -/// resolved again after the check: only root can mount something else over `/proc`.) +/// resolved again after the check: only root can mount something else over `/proc`.) Without +/// a procfs to read through, the read fails, and the directory counts as one others may write. #[cfg(target_os = "linux")] fn read_xattr(fd: RawFd, name: &CStr) -> io::Result> { let mut buf = vec![0u8; 4096]; @@ -718,6 +723,9 @@ fn acl_names_others(xattr: &[u8]) -> bool { /// long as they run. /// - A group password lets anyone who knows it `newgrp` into the group; with shadow groups it /// is out of the user's reach to read, and is not considered. +/// - `getgrgid` returns the entry of the first NSS source that has the gid: a group of the same +/// gid in a later source, listing others -- an administrator's misconfiguration -- is not +/// seen. pub fn is_private_group(gid: u32, euid: u32) -> bool { #[cfg(feature = "test-hooks")] PRIVATE_GROUP_QUERIES.with(|queries| queries.set(queries.get() + 1)); diff --git a/tree/common/copy.rs b/tree/common/copy.rs index 8bc16657d..70497c207 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -264,8 +264,11 @@ pub enum OperandTrust { /// it hands them nothing they did not have. That is the residual, with root, who can make a /// directory with any owner. cp notes its own changes from the descriptor it has held /// throughout -- a directory held open keeps its number from being reused -- once it has filled -/// and finished one (`refresh`), so an honest later operand still finds it unchanged. -/// Recording a directory replaces whatever its number stood for. +/// and finished one (`refresh`), so an honest later operand still finds it unchanged. Residual +/// of that: once -p as root has given a made directory away, its new owner can change it while +/// cp still fills it, and the refresh records their change as cp's -- passing for the run's own +/// a directory that is theirs anyway. Recording a directory replaces whatever its number stood +/// for. #[derive(Default)] pub struct MadeDirs(HashMap<(u64, u64), MadeAt>); From e22c535eb37f297a3b579be8914eb9dd155497ef Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 20:02:00 +0000 Subject: [PATCH 38/71] plib: look up a private group through the reentrant user and group lookups madefs's is_private_group read the user, the group and each listed member with getpwuid, getgrgid and getpwnam, whose static storage any other thread's lookup overwrites; its own lock serialised only its own callers. Under a concurrent lookup elsewhere in the process -- a parallel test, say -- it could copy out another entry's name and judge a private group shared, or the reverse. user_entry and group_entry now go through plib::user::lookup_by_uid, plib::group::lookup_by_gid and plib::user::lookup_by_name (getpw*_r / getgr*_r). A failed lookup (Err) fails closed, as a missing entry does: the group counts as not private. plib::testing::user_private_group uses lookup_by_uid too. New test private_group_lookup_is_unaffected_by_other_threads_lookups asks read_private_group 100000 times while another thread loops over getpwuid(0), getgrgid(0) and getpwnam("root"); before this change it saw changed answers in most runs. Co-Authored-By: Claude Opus 5.5 --- plib/src/madefs.rs | 115 ++++++++++++++++++++++++++------------------ plib/src/testing.rs | 2 +- 2 files changed, 68 insertions(+), 49 deletions(-) diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index da164f59f..299bb08b8 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -21,7 +21,7 @@ use gettextrs::gettext; use std::cell::OnceCell; use std::collections::HashMap; -use std::ffi::{CStr, CString, OsStr}; +use std::ffi::{CStr, CString, OsStr, OsString}; #[cfg(target_os = "linux")] use std::fs::File; use std::io; @@ -703,9 +703,11 @@ fn acl_names_others(xattr: &[u8]) -> bool { /// Whether the group `gid` is the private group of the user `euid`, by the user-private-group /// convention (`useradd`, `adduser`): nobody else is in it, so its write permission is the /// user's own. All of these must hold (`group_is_private`): -/// - it is the user's primary group (`getpwuid`); -/// - its name (`getgrgid`) is the user's name, byte for byte; -/// - every member it lists resolves (`getpwnam`, on the name's bytes) to the user's uid. +/// - it is the user's primary group (`crate::user::lookup_by_uid`); +/// - its name (`crate::group::lookup_by_gid`) is the user's name, byte for byte; +/// - every member it lists -- that one group's own member list; no other group, and no +/// account, is enumerated -- resolves (`crate::user::lookup_by_name`, on the name's bytes) +/// to the user's uid. /// /// Three lookups by key, once per group and user in a process; nothing is enumerated. Anything /// that cannot be read counts as not private. It applies only on Linux, the one system where @@ -723,7 +725,7 @@ fn acl_names_others(xattr: &[u8]) -> bool { /// long as they run. /// - A group password lets anyone who knows it `newgrp` into the group; with shadow groups it /// is out of the user's reach to read, and is not considered. -/// - `getgrgid` returns the entry of the first NSS source that has the gid: a group of the same +/// - A group lookup returns the entry of the first NSS source that has the gid: a group of the same /// gid in a later source, listing others -- an administrator's misconfiguration -- is not /// seen. pub fn is_private_group(gid: u32, euid: u32) -> bool { @@ -756,8 +758,9 @@ pub fn private_group_queries() -> usize { PRIVATE_GROUP_QUERIES.with(|queries| queries.get()) } -/// `is_private_group`, read from the databases (under its lock: the lookups return storage the -/// next one reuses). +/// `is_private_group`, read from the databases through the reentrant lookups +/// (`crate::user::lookup_by_uid`, `crate::group::lookup_by_gid`), which no other thread's +/// lookup can overwrite. A lookup that fails, or finds nothing, counts as not private. fn read_private_group(gid: u32, euid: u32) -> bool { let Some((user_name, user_gid)) = user_entry(euid) else { return false; @@ -781,46 +784,25 @@ pub struct UserEntry<'a> { pub name: &'a [u8], } -/// The name and primary gid of the user `uid` (`getpwuid`), the name as the database holds it. -fn user_entry(uid: u32) -> Option<(CString, u32)> { - let passwd = unsafe { libc::getpwuid(uid) }; - if passwd.is_null() { - return None; - } - let passwd = unsafe { &*passwd }; - let name = unsafe { CStr::from_ptr(passwd.pw_name) }.to_owned(); - Some((name, passwd.pw_gid)) +/// The name and primary gid of the user `uid` (`crate::user::lookup_by_uid`), the name as the +/// database holds it; `None` when there is no such user or the lookup fails. +fn user_entry(uid: u32) -> Option<(OsString, u32)> { + let user = crate::user::lookup_by_uid(uid).ok()??; + Some((user.name, user.gid)) } -/// The name of the group `gid` (`getgrgid`), as the database holds it, and the uid each member -/// it lists resolves to (`getpwnam` on the name's bytes), `None` for a name that resolves to -/// nobody; `None` altogether when the group cannot be read. -fn group_entry(gid: u32) -> Option<(CString, Vec>)> { - // Copied out first: `getgrgid` and `getpwnam` each return storage the next call reuses. - let (group_name, names) = unsafe { - let group = libc::getgrgid(gid); - if group.is_null() { - return None; - } - let group_name = CStr::from_ptr((*group).gr_name).to_owned(); - let mut names = Vec::new(); - let mut member = (*group).gr_mem; - // read_unaligned: macOS does not align the member array. - while !member.is_null() { - let name = std::ptr::read_unaligned(member as *const *const libc::c_char); - if name.is_null() { - break; - } - names.push(CStr::from_ptr(name).to_owned()); - member = member.add(1); - } - (group_name, names) - }; - let uid_of = |name: &CStr| { - let passwd = unsafe { libc::getpwnam(name.as_ptr()) }; - (!passwd.is_null()).then(|| unsafe { (*passwd).pw_uid }) - }; - Some((group_name, names.iter().map(|name| uid_of(name)).collect())) +/// The name of the group `gid` (`crate::group::lookup_by_gid`), as the database holds it, and +/// the uid each member it lists resolves to (`crate::user::lookup_by_name` on the name's +/// bytes), `None` for a name that resolves to nobody; `None` altogether when the group cannot +/// be read or a member's lookup fails. +fn group_entry(gid: u32) -> Option<(OsString, Vec>)> { + let group = crate::group::lookup_by_gid(gid).ok()??; + let member_uids = group + .members + .iter() + .map(|name| Some(crate::user::lookup_by_name(name).ok()?.map(|user| user.uid))) + .collect::>>()?; + Some((group.name, member_uids)) } /// The rule `is_private_group` follows, for the group `gid` named `group_name` and the user @@ -1076,11 +1058,12 @@ pub fn utimens_link_if_still( mod tests { use super::{ acl_names_others, acls_let_others_write, dir_writers, empty_lending_read, group_entry, - group_is_private, is_private_group, made_by_us, others_can_rename, user_entry, - utimens_link_if_still, verify_made_dir, ChainTrust, DirWriters, FoundDir, FsOwners, - MadeObject, MadeTrust, Preserve, UserEntry, + group_is_private, is_private_group, made_by_us, others_can_rename, read_private_group, + user_entry, utimens_link_if_still, verify_made_dir, ChainTrust, DirWriters, FoundDir, + FsOwners, MadeObject, MadeTrust, Preserve, UserEntry, }; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::Path; use std::rc::Rc; @@ -1264,6 +1247,42 @@ mod tests { assert_eq!(is_private_group(user.gid, euid), expected); } + /// Reading whether a group is private gives the same answer while another thread -- other + /// code in the process, a parallel test -- looks up other users and groups with the + /// non-reentrant `getpwuid`, `getgrgid` and `getpwnam`, whose static storage those calls + /// overwrite. + #[test] + fn private_group_lookup_is_unaffected_by_other_threads_lookups() { + use std::sync::atomic::{AtomicBool, Ordering}; + let euid = unsafe { libc::geteuid() }; + let Ok(Some(user)) = crate::user::lookup_by_uid(euid) else { + return; + }; + let expected = read_private_group(user.gid, euid); + let (started, stop) = (AtomicBool::new(false), AtomicBool::new(false)); + std::thread::scope(|scope| { + scope.spawn(|| { + started.store(true, Ordering::Relaxed); + while !stop.load(Ordering::Relaxed) { + // Other entries than the ones asked about: root's, and group 0. + unsafe { + libc::getpwuid(0); + libc::getgrgid(0); + libc::getpwnam(c"root".as_ptr()); + } + } + }); + while !started.load(Ordering::Relaxed) { + std::hint::spin_loop(); + } + let differed = (0..100_000) + .filter(|_| read_private_group(user.gid, euid) != expected) + .count(); + stop.store(true, Ordering::Relaxed); + assert_eq!(differed, 0, "answers changed under other threads' lookups"); + }); + } + /// A found directory gets its times only unless mode or owner was asked for; then what was /// asked for only where nobody else could have created its name -- in its parent, and in /// every directory above it up to the anchor. A directory the caller made restarts the diff --git a/plib/src/testing.rs b/plib/src/testing.rs index 4082b6d60..a37aae390 100644 --- a/plib/src/testing.rs +++ b/plib/src/testing.rs @@ -747,7 +747,7 @@ impl AsRef for TempFile { #[cfg(unix)] pub fn user_private_group() -> Option { let euid = unsafe { libc::geteuid() }; - let gid = crate::user::get_by_uid(euid)?.gid; + let gid = crate::user::lookup_by_uid(euid).ok()??.gid; let ours = gid == unsafe { libc::getegid() }; (ours && crate::madefs::is_private_group(gid, euid)).then_some(gid) } From 68697b67677cc51db965d52edc4bafe47584ef47 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 20:05:31 +0000 Subject: [PATCH 39/71] plib: let root trust a destination tree one other user owns alone By the user's decision, a directory owned by the user an extraction or copy is for counts as trusted: root extracting with pax -p e, or copying with cp -p, into /home/alice, which alice owns, with the directories a umask of 002 left below it. ChainTrust refused every directory not owned by the effective user, so root's -p into a user's home left every directory it found alone and exited 1. The rule, in dir_writers, kept as narrow as it can be: - only when the effective user is root; - the user is the owner of the first directory in the chain that is not root's own (the anchor, or one found below root's directories); - every directory below that must be owned by that same user: root's own, or a third user's, below it counts as one others can write; - each of those directories passes the same test the effective user's own do: no other write permission, sticky or not; group write permission only for the owner's private group (is_private_group, asked of the owner), and no ACL that lets others write; - a directory the caller made and verified (ChainTrust::made) never starts such a chain: the owner it may have been given since proves nothing. A non-root effective user still trusts only its own directories. DirWriters now names the owner (Owner, OwnerAndGroup), and each Link records the user the chain has gone into (Link::owner). ChainTrust's link takes the effective user as a parameter (link_as) so the unit tests can ask as root: root_trusts_a_tree_one_other_user_owns_alone walks real directories the test user owns, and root_trusts_no_mixed_owners covers mixed owners, made directories and non-root callers on synthetic stats. The first failed before this change. Co-Authored-By: Claude Opus 5.5 --- plib/src/madefs.rs | 252 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 220 insertions(+), 32 deletions(-) diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index 299bb08b8..576490b99 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -233,6 +233,11 @@ pub enum FoundDir { /// Who owns the directory found proves nothing. Giving such a directory a mode would open it /// up; giving it an owner would give it away. /// +/// Root working for a user -- extracting into `/home/alice`, which alice owns -- also trusts +/// the directories of that one user, below its own: alice can create names there, but only in +/// her own tree, which the copy is for. One user only, owning every directory from the first +/// one not root's down, and none others can write (`dir_writers`). +/// /// So the trust is carried down the chain, one directory at a time, from descriptors: the /// anchor hands it to its entries when nobody else can create entries in it (`anchor`); a /// directory found existing hands it on when it was handed it and nobody else can create @@ -362,6 +367,14 @@ impl NamedAnchor { } } +/// Where the directory of a `ChainTrust` link stands: below the anchor the user named (`anchor`, +/// `found`), or one the caller made and verified (`made`). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum ChainStart { + Anchor, + Made, +} + /// One directory of a `ChainTrust`. struct Link { /// What the directory this one is in hands it; `None` where a chain starts (`anchor`, @@ -369,6 +382,10 @@ struct Link { above: Option, /// Who else can create entries in it, from its `fstat`. writers: DirWriters, + /// The one user other than root that the chain has gone into the directories of, root + /// being the effective user (`dir_writers`): from then on, every directory must be theirs. + /// `None` while every directory has been the effective user's. + owner: Option, /// The directory, for its ACL; `None` for `unlocated`. dir: Option>, /// Whether directories found existing in this one may take what was asked for, once @@ -401,14 +418,35 @@ impl std::fmt::Debug for ChainTrust { impl ChainTrust { /// The link for the directory held as `dir`, below `above`. - fn link(above: Option, dir: &Rc) -> io::Result { - let euid = unsafe { libc::geteuid() }; - let writers = dir_writers(&fstat(dir.as_raw_fd())?, euid); + fn link( + above: Option, + dir: &Rc, + start: ChainStart, + ) -> io::Result { + Self::link_as(above, dir, start, unsafe { libc::geteuid() }) + } + + /// `link`, the effective user being `euid`. + fn link_as( + above: Option, + dir: &Rc, + start: ChainStart, + euid: u32, + ) -> io::Result { + let above_owner = above.as_ref().and_then(|ChainTrust(link)| link.owner); + let writers = dir_writers(&fstat(dir.as_raw_fd())?, euid, above_owner, start); + let owner = match writers { + DirWriters::Owner { uid } | DirWriters::OwnerAndGroup { uid, .. } if uid != euid => { + Some(uid) + } + _ => above_owner, + }; let dir: Weak = Rc::downgrade(dir); let dir: Weak = dir; Ok(ChainTrust(Rc::new(Link { above, writers, + owner, dir: Some(dir), entries_safe: OnceCell::new(), }))) @@ -417,7 +455,7 @@ impl ChainTrust { /// The trust the anchor -- the directory the user named, held as `dir` -- hands its /// entries. pub fn anchor(dir: &Rc) -> io::Result { - Self::link(None, dir) + Self::link(None, dir, ChainStart::Anchor) } /// The trust handed where the caller cannot tell which directory a directory found is in -- @@ -426,6 +464,7 @@ impl ChainTrust { ChainTrust(Rc::new(Link { above: None, writers: DirWriters::Others, + owner: None, dir: None, entries_safe: OnceCell::from(false), })) @@ -434,13 +473,13 @@ impl ChainTrust { /// The trust a directory found existing, held as `dir`, in a directory that handed it /// `self`, hands its own entries. pub fn found(&self, dir: &Rc) -> io::Result { - Self::link(Some(self.clone()), dir) + Self::link(Some(self.clone()), dir, ChainStart::Anchor) } /// The trust a directory the caller made and verified (`verify_made_dir`), held as `dir`, /// hands its entries, wherever it is. pub fn made(dir: &Rc) -> io::Result { - Self::link(None, dir) + Self::link(None, dir, ChainStart::Made) } /// The anchor for a directory the user named as `path`, opened (following links, as named) @@ -529,17 +568,34 @@ impl Link { /// Who can create entries in a directory, as far as its `fstat` shows. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum DirWriters { - /// The effective user alone: it is theirs and grants no group or other write permission. - User, + /// Its owner `uid` alone -- the effective user, or the one user root is working in the tree + /// of (`dir_writers`): it grants no group or other write permission. + Owner { uid: u32 }, /// Someone else too: it is someone else's, or grants other write permission. Others, - /// The user, and members of its group `gid`: it is the user's (`euid`), grants group write - /// permission and no other. Nobody else when that group is the user's private one and no - /// ACL names anyone else (`nobody_else_can_create`). - UserAndGroup { gid: u32, euid: u32 }, + /// Its owner `uid`, and members of its group `gid`: it grants group write permission and no + /// other. Nobody else when that group is the owner's private one and no ACL names anyone + /// else (`only_the_user_writes`). + OwnerAndGroup { uid: u32, gid: u32 }, } -/// `DirWriters` for the directory `st`, the effective user being `euid`. +/// `DirWriters` for the directory `st`, the effective user being `euid`, in a chain that has +/// gone into the directories of the user `above_owner` (`Link::owner`), where it stands as +/// `start`. +/// +/// A directory may be its owner's alone only where its owner is someone the trust can rest +/// with: +/// - the effective user, while the chain has gone into no other user's directories; +/// - for root, one user other than root, the one the copy or extraction is for -- root +/// extracting into `/home/alice`, which alice owns, with the directories a umask of 002 left +/// below it. That user is the owner of the first directory in the chain not root's own, and +/// must own every directory below it: root's own, or a third user's, below it is mixed in, +/// and others than its owner could have created its name. A directory the caller made and +/// verified (`ChainStart::Made`) starts no such chain: it is root's own, or the owner it was +/// given since, which proves nothing about who could create entries in it. +/// +/// Anyone else, a sticky directory included, counts as others. So does a non-root effective +/// user meeting a directory not theirs: nobody but root acts for another user. /// /// Not the rule `others_can_rename` follows, on purpose. That one asks whether someone could /// have renamed something over a directory the caller has just made, and the sticky bit stops @@ -548,19 +604,29 @@ enum DirWriters { /// stops nobody creating a new name. Nor does `others_can_rename` make an exception for the /// user's private group: where it says yes, the directory just made is verified anyway /// (`verify_made_dir`), with no lookup. -fn dir_writers(st: &libc::stat, euid: u32) -> DirWriters { +fn dir_writers( + st: &libc::stat, + euid: u32, + above_owner: Option, + start: ChainStart, +) -> DirWriters { // Cast needed: `mode_t` is u16 on macOS and u32 on Linux. S_IWGRP is 0o020 and S_IWOTH // 0o002 (fixed by POSIX). #[allow(clippy::unnecessary_cast)] let mode = st.st_mode as u32; - if st.st_uid != euid || mode & 0o002 != 0 { + let uid = st.st_uid; + let trusted_owner = match above_owner { + Some(owner) => uid == owner, + None => uid == euid || (euid == 0 && uid != 0 && start == ChainStart::Anchor), + }; + if !trusted_owner || mode & 0o002 != 0 { DirWriters::Others } else if mode & 0o020 == 0 { - DirWriters::User + DirWriters::Owner { uid } } else { - DirWriters::UserAndGroup { + DirWriters::OwnerAndGroup { + uid, gid: st.st_gid, - euid, } } } @@ -570,18 +636,19 @@ fn dir_writers(st: &libc::stat, euid: u32) -> DirWriters { fn nobody_else_can_create(fd: RawFd) -> io::Result { let euid = unsafe { libc::geteuid() }; Ok(only_the_user_writes( - dir_writers(&fstat(fd)?, euid), + dir_writers(&fstat(fd)?, euid, None, ChainStart::Anchor), Some(fd), )) } -/// Whether nobody but the effective user can create entries in a directory whose mode shows -/// `writers`, held as `dir` (`None` once its holder has closed it, which settles nothing and so -/// counts as others'): the rule `ChainTrust` follows. +/// Whether nobody but the effective user -- or, for root, the one user whose tree it is working +/// in (`dir_writers`) -- can create entries in a directory whose mode shows `writers`, held as +/// `dir` (`None` once its holder has closed it, which settles nothing and so counts as +/// others'): the rule `ChainTrust` follows. "The user" below is that owner. /// /// The directory must be the user's and grant no other write permission, and no group write -/// permission either unless its group is the user's private group (`is_private_group`) -- the -/// user's alone, so the directories a umask of 002 leaves group-writable, as Debian-style user +/// permission either unless its group is the user's private group (`is_private_group`, asked +/// of the directory's owner) -- the user's alone, so the directories a umask of 002 leaves group-writable, as Debian-style user /// private groups intend, count as the user's; and no ACL it carries may let others write /// (`acls_let_others_write`: POSIX, NFSv4 and CIFS ACLs on Linux), read last, only when the /// rest says the user's. A sticky directory others may write counts as one they can create @@ -596,8 +663,8 @@ fn only_the_user_writes(writers: DirWriters, dir: Option) -> bool { }; match writers { DirWriters::Others => false, - DirWriters::User => acls_allow(false), - DirWriters::UserAndGroup { gid, euid } => is_private_group(gid, euid) && acls_allow(true), + DirWriters::Owner { .. } => acls_allow(false), + DirWriters::OwnerAndGroup { uid, gid } => is_private_group(gid, uid) && acls_allow(true), } } @@ -1059,8 +1126,8 @@ mod tests { use super::{ acl_names_others, acls_let_others_write, dir_writers, empty_lending_read, group_entry, group_is_private, is_private_group, made_by_us, others_can_rename, read_private_group, - user_entry, utimens_link_if_still, verify_made_dir, ChainTrust, DirWriters, FoundDir, - FsOwners, MadeObject, MadeTrust, Preserve, UserEntry, + user_entry, utimens_link_if_still, verify_made_dir, ChainStart, ChainTrust, DirWriters, + FoundDir, FsOwners, MadeObject, MadeTrust, Preserve, UserEntry, }; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::ffi::OsStrExt; @@ -1080,11 +1147,12 @@ mod tests { /// entries beside it. #[test] fn who_can_create_in_a_parent() { - use DirWriters::{Others, User, UserAndGroup}; - let group = |gid, euid| UserAndGroup { gid, euid }; + use DirWriters::{Others, Owner, OwnerAndGroup}; + let dir_writers = |st: &libc::stat, euid| dir_writers(st, euid, None, ChainStart::Anchor); + let group = |gid, uid| OwnerAndGroup { uid, gid }; // Nobody but the user can. - assert_eq!(dir_writers(&parent(US, 0o755), US), User); - assert_eq!(dir_writers(&parent(0, 0o755), 0), User); + assert_eq!(dir_writers(&parent(US, 0o755), US), Owner { uid: US }); + assert_eq!(dir_writers(&parent(0, 0o755), 0), Owner { uid: 0 }); // A sticky directory others may write -- /tmp, root extracting into it too. assert_eq!(dir_writers(&parent(US, 0o1777), US), Others); assert_eq!(dir_writers(&parent(0, 0o1777), 0), Others); @@ -1103,6 +1171,126 @@ mod tests { assert_eq!(dir_writers(&parent(OTHER, 0o775), US), Others); } + /// Root copying or extracting for a user, into a tree that user owns -- `/home/alice`, + /// with the directories a umask of 002 leaves below it -- trusts it as the user's own: one + /// owner, not root, owning every directory from the anchor (or below root's own) down, + /// none of them writable by others. The test user stands for that user here, and root's + /// view is taken by asking as euid 0. + #[test] + fn root_trusts_a_tree_one_other_user_owns_alone() { + let me = unsafe { libc::geteuid() }; + if me == 0 { + eprintln!("note: run as root, the test user cannot stand for another user; skipped"); + return; + } + let tmp = crate::tmp::TempDir::new().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("x/y")).unwrap(); + std::fs::create_dir_all(root.join("open/z")).unwrap(); + for (dir, mode) in [("", 0o755), ("x", 0o755), ("x/y", 0o755), ("open", 0o777)] { + let path = root.join(dir); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); + } + let mode = Preserve { + mode: true, + owner: true, + }; + let fd = |path: &str| Rc::new(std::fs::File::open(root.join(path)).unwrap()); + let (root_fd, x_fd, y_fd, open_fd) = (fd(""), fd("x"), fd("x/y"), fd("open")); + let slash = Rc::new(std::fs::File::open("/").unwrap()); + let as_root = |above: Option<&ChainTrust>, dir: &Rc| { + ChainTrust::link_as(above.cloned(), dir, ChainStart::Anchor, 0).unwrap() + }; + + // The user's own tree, from the anchor down. + let anchor = as_root(None, &root_fd); + let x = as_root(Some(&anchor), &x_fd); + assert_eq!(anchor.found_dir(mode), FoundDir::AsRequested); + assert_eq!(x.found_dir(mode), FoundDir::AsRequested); + // Below root's own directory too: `/` stands for `/home`. + let home = as_root(None, &slash); + let below_home = as_root(Some(&home), &root_fd); + assert_eq!(below_home.found_dir(mode), FoundDir::AsRequested); + // A directory others can write, owned by the user or not, is refused. + let open = as_root(Some(&anchor), &open_fd); + assert_eq!(open.found_dir(mode), FoundDir::LeaveAlone); + // Root's own directory below the user's is mixed in: the user can rename it there. + let mixed = as_root(Some(&x), &slash); + assert_eq!(mixed.found_dir(mode), FoundDir::LeaveAlone); + // A directory the caller made and verified that has become the user's is not a point + // to start trusting the user from. + let made = ChainTrust::link_as(None, &y_fd, ChainStart::Made, 0).unwrap(); + assert_eq!(made.found_dir(mode), FoundDir::LeaveAlone); + // Group write permission counts as the user's only for the user's own private group. + if let Some(gid) = crate::testing::user_private_group() { + std::fs::set_permissions(root.join("x"), std::fs::Permissions::from_mode(0o775)) + .unwrap(); + assert_eq!(x_fd.metadata().unwrap().gid(), gid); + let anchor = as_root(None, &root_fd); + let x = as_root(Some(&anchor), &x_fd); + assert_eq!(x.found_dir(mode), FoundDir::AsRequested); + } else { + eprintln!("note: no user private group here; group-writable case skipped"); + } + // Anyone but root asks for directories that are their own, as before. + let other = ChainTrust::link_as(None, &root_fd, ChainStart::Anchor, me + 1).unwrap(); + assert_eq!(other.found_dir(mode), FoundDir::LeaveAlone); + } + + /// Below a directory owned by one user other than root, only that user's directories + /// continue the chain: another user's, or root's, are mixed in. + #[test] + fn root_trusts_no_mixed_owners() { + use DirWriters::{Others, Owner, OwnerAndGroup}; + // Root's own, at the top. + assert_eq!( + dir_writers(&parent(0, 0o755), 0, None, ChainStart::Anchor), + Owner { uid: 0 } + ); + // One user's, below root's or at the top. + assert_eq!( + dir_writers(&parent(US, 0o755), 0, None, ChainStart::Anchor), + Owner { uid: US } + ); + assert_eq!( + dir_writers(&parent(US, 0o755), 0, Some(US), ChainStart::Anchor), + Owner { uid: US } + ); + let mut st = parent(US, 0o775); + st.st_gid = 4242; + assert_eq!( + dir_writers(&st, 0, Some(US), ChainStart::Anchor), + OwnerAndGroup { uid: US, gid: 4242 } + ); + // Another user's below the first one's, or root's below either. + assert_eq!( + dir_writers(&parent(OTHER, 0o755), 0, Some(US), ChainStart::Anchor), + Others + ); + assert_eq!( + dir_writers(&parent(0, 0o755), 0, Some(US), ChainStart::Anchor), + Others + ); + // Not where a made directory starts a chain, and never for anyone but root. + assert_eq!( + dir_writers(&parent(US, 0o755), 0, None, ChainStart::Made), + Others + ); + assert_eq!( + dir_writers(&parent(US, 0o755), OTHER, None, ChainStart::Anchor), + Others + ); + // Others' write permission, the user's tree or not. + assert_eq!( + dir_writers(&parent(US, 0o757), 0, Some(US), ChainStart::Anchor), + Others + ); + assert_eq!( + dir_writers(&parent(US, 0o1777), 0, None, ChainStart::Anchor), + Others + ); + } + /// A group is the user's private one, by the user-private-group convention, only when it /// is the user's primary group, bears the user's name, and lists nobody else. #[test] From 10c87077daef5f910853938a7cbb43c8020a2297 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 20:12:43 +0000 Subject: [PATCH 40/71] plib, cp, pax: in a user's tree, root gives only that user's directories anything 1a07c8f1 let root, copying or extracting into a tree one other user owns alone, trust that tree. Two holes, found in review: - A directory found in the user's tree was judged by its parents only. But alice can rename into her tree any directory she can reach -- one of root's from within it, or bob's from a non-sticky directory or one her group can write -- and root would then give it the archive's (or source's) mode and owner: bob's private directory opened up, or given to alice. ChainTrust::found_dir now takes the directory found, and where the chain has gone into another user's tree it must be that user's own, by the fstat of the very descriptor the caller will change; one that cannot be read gets nothing. NamedAnchor::named_dir asks the same of the named directory, from the fstat its link took. cp's found_dir_trust and pax's found_dir_with_mode pass their held descriptors. - Where the user's tree starts the chain (the anchor, or the first directory holding a link the named path follows), whoever can write the directory above it could have renamed a directory of theirs, or another user's, to its name, and the chain would then trust that directory's owner instead: alice renaming bob's directory to /home/alice/x before root extracts into it. The first such link now also requires rests_on_roots_own: climbing `..` from the held descriptor, each opened in the one below (O_PATH, O_DIRECTORY, O_CLOEXEC), every directory up to the first of root's must be the same user's and writable by nobody else, and that one root's alone. The climb is lazy (only when a mode or owner is asked of a found directory) and fails closed: an unopenable `..`, the top of the hierarchy reached without one of root's directories, or more than 4096 levels. A directory root made and verified that another member's name now holds (pax's (id, key) standing, cp's MadeDirs path) is Ordinary there and judged as found: unless alice owns it, it gets nothing. Tests, written first and failing before this change: root_gives_nothing_to_a_directory_not_the_users_found_in_their_tree and root_trusts_a_users_tree_at_the_anchor_only_where_it_rests_on_roots_own (this crate's own directory, below root's /home, as the positive case; a directory in /tmp as the negative). The earlier test now starts the user's tree below `/`, standing for /home. Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 14 ++- plib/src/madefs.rs | 258 ++++++++++++++++++++++++++++++++++-------- tree/common/copy.rs | 9 +- 3 files changed, 226 insertions(+), 55 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 122903d8c..5840b5060 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -707,7 +707,7 @@ fn apply_dir_attrs(tree: &DirTree, dir: &PendingDir, policy: &AttrPolicy) -> Pax Standing::Replaced => return Err(PaxError::Io(made::replaced())), Standing::Unverified => return Err(attrs_withheld()), Standing::Implicit | Standing::Made => true, - Standing::Ordinary => found_dir_with_mode(trust, policy)?, + Standing::Ordinary => found_dir_with_mode(trust, &fd, policy)?, }; if search_only { return set_attrs_search_only(fd.as_fd(), &dir.attrs, policy, with_mode); @@ -715,17 +715,21 @@ fn apply_dir_attrs(tree: &DirTree, dir: &PendingDir, policy: &AttrPolicy) -> Pax set_attrs_with(&AttrTarget::Fd(fd.as_fd()), &dir.attrs, policy, with_mode) } -/// For a directory found existing at a member's name, in a parent handing it -/// `trust`: whether it takes the member's mode, or an error when it is to +/// For a directory found existing at a member's name, held as `fd`, in a +/// parent handing it `trust`: whether it takes the member's mode, or an error when it is to /// take nothing at all (`ChainTrust::found_dir`). Its owner it takes only /// under `-p o`, which `set_attrs_with` already follows; its times, as by /// default, whenever it takes anything. -fn found_dir_with_mode(trust: ChainTrust, policy: &AttrPolicy) -> PaxResult { +fn found_dir_with_mode( + trust: ChainTrust, + fd: &impl AsRawFd, + policy: &AttrPolicy, +) -> PaxResult { let requested = Preserve { mode: policy.preserve_perms, owner: policy.preserve_owner, }; - match trust.found_dir(requested) { + match trust.found_dir(fd, requested) { FoundDir::TimesOnly => Ok(false), FoundDir::AsRequested => Ok(policy.preserve_perms), FoundDir::LeaveAlone => Err(PaxError::Io(std::io::Error::other( diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index 576490b99..3a0771045 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -236,7 +236,10 @@ pub enum FoundDir { /// Root working for a user -- extracting into `/home/alice`, which alice owns -- also trusts /// the directories of that one user, below its own: alice can create names there, but only in /// her own tree, which the copy is for. One user only, owning every directory from the first -/// one not root's down, and none others can write (`dir_writers`). +/// one not root's down, and none others can write (`dir_writers`); where that first one starts +/// the chain, every directory above it up to one of root's own must be hers alone too +/// (`rests_on_roots_own`); and a directory found in her tree must itself be hers (`found_dir`), +/// since she can rename into it any directory she can reach. /// /// So the trust is carried down the chain, one directory at a time, from descriptors: the /// anchor hands it to its entries when nobody else can create entries in it (`anchor`); a @@ -361,7 +364,7 @@ impl NamedAnchor { /// reached through a link, what a directory found in the link's directory may be. pub fn named_dir(&self, requested: Preserve) -> FoundDir { match &self.through_link { - Some(trust) => trust.found_dir(requested), + Some(trust) => trust.found_dir_owned(|| self.hands.0.uid, requested), None => requested.where_trusted(), } } @@ -382,6 +385,8 @@ struct Link { above: Option, /// Who else can create entries in it, from its `fstat`. writers: DirWriters, + /// Its owner, from the same `fstat`; `None` for `unlocated`. + uid: Option, /// The one user other than root that the chain has gone into the directories of, root /// being the effective user (`dir_writers`): from then on, every directory must be theirs. /// `None` while every directory has been the effective user's. @@ -434,7 +439,8 @@ impl ChainTrust { euid: u32, ) -> io::Result { let above_owner = above.as_ref().and_then(|ChainTrust(link)| link.owner); - let writers = dir_writers(&fstat(dir.as_raw_fd())?, euid, above_owner, start); + let st = fstat(dir.as_raw_fd())?; + let writers = dir_writers(&st, euid, above_owner, start); let owner = match writers { DirWriters::Owner { uid } | DirWriters::OwnerAndGroup { uid, .. } if uid != euid => { Some(uid) @@ -446,6 +452,7 @@ impl ChainTrust { Ok(ChainTrust(Rc::new(Link { above, writers, + uid: Some(st.st_uid), owner, dir: Some(dir), entries_safe: OnceCell::new(), @@ -464,6 +471,7 @@ impl ChainTrust { ChainTrust(Rc::new(Link { above: None, writers: DirWriters::Others, + uid: None, owner: None, dir: None, entries_safe: OnceCell::from(false), @@ -525,12 +533,27 @@ impl ChainTrust { }) } - /// What a directory found existing in a directory of this trust may be given, `requested` - /// being which of mode and owner the user asked to preserve. Only when one of them was - /// asked for is the trust worked out. - pub fn found_dir(&self, requested: Preserve) -> FoundDir { + /// What a directory found existing in a directory of this trust, held as `dir`, may be + /// given, `requested` being which of mode and owner the user asked to preserve. Only when + /// one of them was asked for is the trust worked out. + /// + /// In the tree of the user root works for (`dir_writers`), that user can rename into it any + /// directory they can reach -- one of root's, or another user's from a directory they + /// share -- so there the directory found must be that user's own too, by its `fstat`. + pub fn found_dir(&self, dir: &D, requested: Preserve) -> FoundDir { + self.found_dir_owned( + || fstat(dir.as_raw_fd()).ok().map(|st| st.st_uid), + requested, + ) + } + + /// `found_dir`, for a directory whose owner `uid` reads, `None` where it cannot be read. + fn found_dir_owned(&self, uid: impl FnOnce() -> Option, requested: Preserve) -> FoundDir { match requested.where_trusted() { FoundDir::AsRequested if !self.entries_safe() => FoundDir::LeaveAlone, + FoundDir::AsRequested if self.0.owner.is_some_and(|owner| uid() != Some(owner)) => { + FoundDir::LeaveAlone + } given => given, } } @@ -558,10 +581,17 @@ impl ChainTrust { } impl Link { - /// Whether nobody but the user can create entries in this directory itself. + /// Whether nobody but the user can create entries in this directory itself -- and, where + /// it is the first of the tree of the user root works for and starts the chain, where + /// nobody else could have put it either (`rests_on_roots_own`). fn own_entries_safe(&self) -> bool { let dir = self.dir.as_ref().and_then(Weak::upgrade); - only_the_user_writes(self.writers, dir.as_ref().map(|dir| dir.as_raw_fd())) + let fd = dir.as_ref().map(|dir| dir.as_raw_fd()); + let placed = || match (&self.above, self.owner, fd) { + (None, Some(owner), Some(fd)) => rests_on_roots_own(fd, owner), + _ => true, + }; + only_the_user_writes(self.writers, fd) && placed() } } @@ -668,6 +698,58 @@ fn only_the_user_writes(writers: DirWriters, dir: Option) -> bool { } } +/// The most directories `rests_on_roots_own` climbs. +const MAX_CLIMB: usize = 4096; + +/// Whether the directory open on `fd`, the first of the tree of the user `owner` that root works +/// for (`dir_writers`) and the top of its chain, is where nobody but root or `owner` could have +/// put it: every directory above it, up to the first of root's own, is `owner`'s, and that one +/// root's, each writable by nobody else (`only_the_user_writes`, root's directory judged as +/// root's). +/// +/// Otherwise anyone who can write a directory above it could have renamed a directory of +/// their own, or another user's they can reach, to its name -- and the chain would go on to +/// trust that directory's owner instead. +/// +/// The climb goes from descriptors, each `..` opened in the one below it -- naming wherever +/// that directory is now, never a path -- and fails closed: a `..` that cannot be opened or +/// read, the root of the file hierarchy reached without one of root's directories, or more +/// than `MAX_CLIMB` levels, and the tree is not trusted. +fn rests_on_roots_own(fd: RawFd, owner: u32) -> bool { + let id = |st: &libc::stat| (st.st_dev, st.st_ino); + let Ok(st) = fstat(fd) else { + return false; + }; + let mut below = id(&st); + let mut held: Option = None; + for _ in 0..MAX_CLIMB { + let at = held.as_ref().map_or(fd, |held| held.as_raw_fd()); + let flags = SEARCH_ONLY | libc::O_DIRECTORY | libc::O_CLOEXEC; + let up = unsafe { libc::openat(at, c"..".as_ptr(), flags) }; + if up < 0 { + return false; + } + let up = unsafe { OwnedFd::from_raw_fd(up) }; + let Ok(st) = fstat(up.as_raw_fd()) else { + return false; + }; + if id(&st) == below { + return false; + } + if st.st_uid == 0 { + let writers = dir_writers(&st, 0, None, ChainStart::Anchor); + return only_the_user_writes(writers, Some(up.as_raw_fd())); + } + let writers = dir_writers(&st, 0, Some(owner), ChainStart::Anchor); + if !only_the_user_writes(writers, Some(up.as_raw_fd())) { + return false; + } + below = id(&st); + held = Some(up); + } + false +} + /// Whether an ACL of a directory may let others write it, `read` reading one of its extended /// attributes, failing with ENODATA or EOPNOTSUPP where it has none or they are not supported: /// - an NFSv4 or CIFS ACL (`system.nfs4_acl`, `system.nfs4_acl_xdr`, `system.cifs_acl`): its @@ -1173,9 +1255,9 @@ mod tests { /// Root copying or extracting for a user, into a tree that user owns -- `/home/alice`, /// with the directories a umask of 002 leaves below it -- trusts it as the user's own: one - /// owner, not root, owning every directory from the anchor (or below root's own) down, - /// none of them writable by others. The test user stands for that user here, and root's - /// view is taken by asking as euid 0. + /// owner, not root, owning every directory below root's own, none of them writable by + /// others. The test user stands for that user here, and root's view is taken by asking as + /// euid 0; `/` stands for `/home`. #[test] fn root_trusts_a_tree_one_other_user_owns_alone() { let me = unsafe { libc::geteuid() }; @@ -1185,7 +1267,7 @@ mod tests { } let tmp = crate::tmp::TempDir::new().unwrap(); let root = tmp.path(); - std::fs::create_dir_all(root.join("x/y")).unwrap(); + std::fs::create_dir_all(root.join("x/y/z")).unwrap(); std::fs::create_dir_all(root.join("open/z")).unwrap(); for (dir, mode) in [("", 0o755), ("x", 0o755), ("x/y", 0o755), ("open", 0o777)] { let path = root.join(dir); @@ -1197,44 +1279,125 @@ mod tests { }; let fd = |path: &str| Rc::new(std::fs::File::open(root.join(path)).unwrap()); let (root_fd, x_fd, y_fd, open_fd) = (fd(""), fd("x"), fd("x/y"), fd("open")); + let (z_fd, open_z_fd) = (fd("x/y/z"), fd("open/z")); let slash = Rc::new(std::fs::File::open("/").unwrap()); let as_root = |above: Option<&ChainTrust>, dir: &Rc| { ChainTrust::link_as(above.cloned(), dir, ChainStart::Anchor, 0).unwrap() }; - // The user's own tree, from the anchor down. - let anchor = as_root(None, &root_fd); - let x = as_root(Some(&anchor), &x_fd); - assert_eq!(anchor.found_dir(mode), FoundDir::AsRequested); - assert_eq!(x.found_dir(mode), FoundDir::AsRequested); - // Below root's own directory too: `/` stands for `/home`. + // The user's own tree, below root's own directory. let home = as_root(None, &slash); - let below_home = as_root(Some(&home), &root_fd); - assert_eq!(below_home.found_dir(mode), FoundDir::AsRequested); + let tree = as_root(Some(&home), &root_fd); + let x = as_root(Some(&tree), &x_fd); + assert_eq!(home.found_dir(&*root_fd, mode), FoundDir::AsRequested); + assert_eq!(tree.found_dir(&*x_fd, mode), FoundDir::AsRequested); + assert_eq!(x.found_dir(&*y_fd, mode), FoundDir::AsRequested); // A directory others can write, owned by the user or not, is refused. - let open = as_root(Some(&anchor), &open_fd); - assert_eq!(open.found_dir(mode), FoundDir::LeaveAlone); + let open = as_root(Some(&tree), &open_fd); + assert_eq!(open.found_dir(&*open_z_fd, mode), FoundDir::LeaveAlone); // Root's own directory below the user's is mixed in: the user can rename it there. let mixed = as_root(Some(&x), &slash); - assert_eq!(mixed.found_dir(mode), FoundDir::LeaveAlone); + assert_eq!(mixed.found_dir(&*z_fd, mode), FoundDir::LeaveAlone); // A directory the caller made and verified that has become the user's is not a point // to start trusting the user from. let made = ChainTrust::link_as(None, &y_fd, ChainStart::Made, 0).unwrap(); - assert_eq!(made.found_dir(mode), FoundDir::LeaveAlone); + assert_eq!(made.found_dir(&*z_fd, mode), FoundDir::LeaveAlone); // Group write permission counts as the user's only for the user's own private group. if let Some(gid) = crate::testing::user_private_group() { std::fs::set_permissions(root.join("x"), std::fs::Permissions::from_mode(0o775)) .unwrap(); assert_eq!(x_fd.metadata().unwrap().gid(), gid); - let anchor = as_root(None, &root_fd); - let x = as_root(Some(&anchor), &x_fd); - assert_eq!(x.found_dir(mode), FoundDir::AsRequested); + let x = as_root(Some(&tree), &x_fd); + assert_eq!(x.found_dir(&*y_fd, mode), FoundDir::AsRequested); } else { eprintln!("note: no user private group here; group-writable case skipped"); } // Anyone but root asks for directories that are their own, as before. let other = ChainTrust::link_as(None, &root_fd, ChainStart::Anchor, me + 1).unwrap(); - assert_eq!(other.found_dir(mode), FoundDir::LeaveAlone); + assert_eq!(other.found_dir(&*x_fd, mode), FoundDir::LeaveAlone); + } + + /// In the tree of a user root works for, the user can rename into place any directory + /// they can reach -- one of root's, or another user's from a directory they share -- and + /// root must give such a one nothing: only the user's own directories are found there. + #[test] + fn root_gives_nothing_to_a_directory_not_the_users_found_in_their_tree() { + if unsafe { libc::geteuid() } == 0 { + eprintln!("note: run as root, the test user cannot stand for another user; skipped"); + return; + } + let mode = Preserve { + mode: true, + owner: false, + }; + let tmp = crate::tmp::TempDir::new().unwrap(); + std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + let tree = Rc::new(std::fs::File::open(tmp.path()).unwrap()); + let slash = Rc::new(std::fs::File::open("/").unwrap()); + let home = ChainTrust::link_as(None, &slash, ChainStart::Anchor, 0).unwrap(); + let tree = ChainTrust::link_as(Some(home), &tree, ChainStart::Anchor, 0).unwrap(); + // `/` stands for a directory of root's renamed into the user's tree. + assert_eq!(tree.found_dir(&*slash, mode), FoundDir::LeaveAlone); + // And one that cannot be read at all is nobody's for certain. + assert_eq!(tree.found_dir(&-1, mode), FoundDir::LeaveAlone); + } + + /// Where the user's tree starts the chain -- the anchor root was handed, or the directory + /// holding a link it named -- someone else may have renamed it to that name: the user's + /// tree is trusted only where every directory above it up to one of root's own is the + /// user's alone, and root's is no directory others can write either. This crate's own + /// directory is the test user's, below root's `/home`; a directory in `/tmp` is not. + #[test] + fn root_trusts_a_users_tree_at_the_anchor_only_where_it_rests_on_roots_own() { + let me = unsafe { libc::geteuid() }; + if me == 0 { + eprintln!("note: run as root, the test user cannot stand for another user; skipped"); + return; + } + let mode = Preserve { + mode: true, + owner: false, + }; + let tmp = crate::tmp::TempDir::new().unwrap(); + std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::create_dir(tmp.path().join("x")).unwrap(); + let in_tmp = Rc::new(std::fs::File::open(tmp.path()).unwrap()); + let x = std::fs::File::open(tmp.path().join("x")).unwrap(); + let anchor = ChainTrust::link_as(None, &in_tmp, ChainStart::Anchor, 0).unwrap(); + assert_eq!(anchor.found_dir(&x, mode), FoundDir::LeaveAlone); + + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + if !rests_on_root(crate_dir, me) { + eprintln!("note: this crate is not in a tree of the test user's own; case skipped"); + return; + } + let held = Rc::new(std::fs::File::open(crate_dir).unwrap()); + let src = std::fs::File::open(crate_dir.join("src")).unwrap(); + let anchor = ChainTrust::link_as(None, &held, ChainStart::Anchor, 0).unwrap(); + assert_eq!(anchor.found_dir(&src, mode), FoundDir::AsRequested); + } + + /// Whether `dir` and every directory above it up to the first of root's are `uid`'s, + /// writable by nobody else -- group write permission only for the user's private group -- + /// and that one of root's by nobody but root: as the test sees it, by name. + fn rests_on_root(dir: &Path, uid: u32) -> bool { + let private = crate::testing::user_private_group(); + for dir in dir.ancestors() { + let Ok(md) = std::fs::metadata(dir) else { + return false; + }; + let writers = md.mode() & 0o022; + if md.uid() == 0 { + return writers == 0; + } + if md.uid() != uid || md.mode() & 0o002 != 0 { + return false; + } + if writers == 0o020 && private != Some(md.gid()) { + return false; + } + } + false } /// Below a directory owned by one user other than root, only that user's directories @@ -1498,35 +1661,35 @@ mod tests { owner: true, }; let fd = |path: &str| Rc::new(std::fs::File::open(root.join(path)).unwrap()); - let (root_fd, g_fd, x_fd) = (fd(""), fd("g"), fd("g/x")); + let (root_fd, g_fd, x_fd, d_fd) = (fd(""), fd("g"), fd("g/x"), fd("g/x/d")); let anchor = ChainTrust::anchor(&root_fd).unwrap(); let g = anchor.found(&g_fd).unwrap(); let x = g.found(&x_fd).unwrap(); // Nothing is worked out until a mode or owner is asked for. - assert_eq!(x.found_dir(none), FoundDir::TimesOnly); + assert_eq!(x.found_dir(&*d_fd, none), FoundDir::TimesOnly); assert!(x.0.entries_safe.get().is_none() && anchor.0.entries_safe.get().is_none()); // `g` itself: found in the anchor, which only the user can write. - assert_eq!(anchor.found_dir(mode), FoundDir::AsRequested); + assert_eq!(anchor.found_dir(&*g_fd, mode), FoundDir::AsRequested); // `x`: found in `g`, which others can write. - assert_eq!(g.found_dir(mode), FoundDir::LeaveAlone); + assert_eq!(g.found_dir(&*x_fd, mode), FoundDir::LeaveAlone); // `d`: its parent `x` is the user's alone, but `x` may be anyone's // directory renamed into `g`. - assert_eq!(x.found_dir(mode), FoundDir::LeaveAlone); - assert_eq!(x.found_dir(owner), FoundDir::LeaveAlone); - assert_eq!(x.found_dir(none), FoundDir::TimesOnly); + assert_eq!(x.found_dir(&*d_fd, mode), FoundDir::LeaveAlone); + assert_eq!(x.found_dir(&*d_fd, owner), FoundDir::LeaveAlone); + assert_eq!(x.found_dir(&*d_fd, none), FoundDir::TimesOnly); // Had the caller made and verified `x`, what it finds in it is safe. let made_x = ChainTrust::made(&x_fd).unwrap(); - assert_eq!(made_x.found_dir(mode), FoundDir::AsRequested); - assert_eq!(made_x.found_dir(owner), FoundDir::AsRequested); + assert_eq!(made_x.found_dir(&*d_fd, mode), FoundDir::AsRequested); + assert_eq!(made_x.found_dir(&*d_fd, owner), FoundDir::AsRequested); // A directory found in no directory the caller can locate takes nothing asked for, and // hands that on. let unlocated = ChainTrust::unlocated(); - assert_eq!(unlocated.found_dir(mode), FoundDir::LeaveAlone); - assert_eq!(unlocated.found_dir(none), FoundDir::TimesOnly); + assert_eq!(unlocated.found_dir(&*root_fd, mode), FoundDir::LeaveAlone); + assert_eq!(unlocated.found_dir(&*root_fd, none), FoundDir::TimesOnly); let below = unlocated.found(&root_fd).unwrap(); - assert_eq!(below.found_dir(mode), FoundDir::LeaveAlone); + assert_eq!(below.found_dir(&*root_fd, mode), FoundDir::LeaveAlone); } /// A directory the user names is trusted as named -- unless its name's last component is a @@ -1554,7 +1717,7 @@ mod tests { let named = ChainTrust::named(&path, &held).unwrap(); assert_eq!(named.named_dir(mode), through, "{path:?} in {open_mode:o}"); assert_eq!( - named.hands.found_dir(mode), + named.hands.found_dir(&*held, mode), through, "{path:?} in {open_mode:o}" ); @@ -1564,13 +1727,13 @@ mod tests { // Named directly. let named = ChainTrust::named(&home, &held).unwrap(); assert_eq!(named.named_dir(mode), FoundDir::AsRequested); - assert_eq!(named.hands.found_dir(mode), FoundDir::AsRequested); + assert_eq!(named.hands.found_dir(&*held, mode), FoundDir::AsRequested); // A name that holds another directory than the one opened. std::fs::rename(&home, tmp.path().join("moved")).unwrap(); std::fs::create_dir(&home).unwrap(); let named = ChainTrust::named(&home, &held).unwrap(); assert_eq!(named.named_dir(mode), FoundDir::LeaveAlone); - assert_eq!(named.hands.found_dir(mode), FoundDir::LeaveAlone); + assert_eq!(named.hands.found_dir(&*held, mode), FoundDir::LeaveAlone); } /// A link anywhere in a named path -- in its last component, in one before it, met again @@ -1610,7 +1773,7 @@ mod tests { let judge = |named: &Path, opened: &Path| { let dir = held(opened); let named = ChainTrust::named(named, &dir).unwrap(); - (named.named_dir(mode), named.hands.found_dir(mode)) + (named.named_dir(mode), named.hands.found_dir(&*dir, mode)) }; let trusted = (FoundDir::AsRequested, FoundDir::AsRequested); let untrusted = (FoundDir::LeaveAlone, FoundDir::LeaveAlone); @@ -1666,16 +1829,17 @@ mod tests { for (perm, open, closed) in [(0o755, true, false), (0o775, private_here(), false)] { std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(perm)).unwrap(); let held = Rc::new(std::fs::File::open(tmp.path()).unwrap()); + let child = std::fs::File::open(tmp.path()).unwrap(); let trust = ChainTrust::anchor(&held).unwrap(); let again = ChainTrust::anchor(&held).unwrap(); assert_eq!( - trust.found_dir(mode) == FoundDir::AsRequested, + trust.found_dir(&child, mode) == FoundDir::AsRequested, open, "{perm:o}" ); drop(held); assert_eq!( - again.found_dir(mode) == FoundDir::AsRequested, + again.found_dir(&child, mode) == FoundDir::AsRequested, closed, "{perm:o}" ); diff --git a/tree/common/copy.rs b/tree/common/copy.rs index 70497c207..89fe8ea59 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -470,7 +470,7 @@ fn found_dir_trust( OperandTrust::Chain(chain) => (chain.clone(), None), }; // Asked while the anchor `parent_anchor` opened is still held. - let finish = DirFinish::Found(chain.found_dir(requested)); + let finish = DirFinish::Found(chain.found_dir(&**fd, requested)); Ok((finish, chain.found(fd)?)) } @@ -2922,11 +2922,14 @@ mod tests { }; let x = fs::metadata(dir.join("x")).unwrap(); let (trust, _) = super::parent_anchor(&parent, (x.dev(), x.ino()), target).unwrap(); - assert_eq!(trust.found_dir(mode), super::FoundDir::AsRequested); + assert_eq!( + trust.found_dir(&*parent, mode), + super::FoundDir::AsRequested + ); let other = fs::metadata(dir).unwrap(); let (trust, _) = super::parent_anchor(&parent, (other.dev(), other.ino()), target).unwrap(); - assert_eq!(trust.found_dir(mode), super::FoundDir::LeaveAlone); + assert_eq!(trust.found_dir(&*parent, mode), super::FoundDir::LeaveAlone); } /// What `fstat` might report of a directory: only what `MadeDirs` reads is set. From bbe69ec0f5bb3c01f486da3c27ef1be10230e0a3 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 20:43:07 +0000 Subject: [PATCH 41/71] plib: trust a tree another user owns alone on Linux only By the user's decision, root's trust in a destination tree one other user owns alone (dir_writers) holds on Linux only. Elsewhere no ACL is read -- read_xattr answers EOPNOTSUPP, which counts as none -- so a macOS ACL granting bob write on alice's 0755 directory went unseen, and bob could have had root give a directory he renamed there to him. Off Linux, root again trusts only its own directories, as before this branch; the private-group rule is already Linux-only. The owner part of the rule is now a pure function, trusted_owner, which takes whether root works for users as a parameter; dir_writers passes ROOT_WORKS_FOR_USERS, cfg!(target_os = "linux"). root_trusts_no_mixed_owners checks the off-Linux answer through it (it failed before this change); the three tests that ask as root on real directories are Linux-only. `cargo clippy --target x86_64-apple-darwin -p plib --all-targets` is clean. Co-Authored-By: Claude Opus 5.5 --- plib/src/madefs.rs | 63 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index 3a0771045..f46d5a918 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -616,9 +616,9 @@ enum DirWriters { /// A directory may be its owner's alone only where its owner is someone the trust can rest /// with: /// - the effective user, while the chain has gone into no other user's directories; -/// - for root, one user other than root, the one the copy or extraction is for -- root -/// extracting into `/home/alice`, which alice owns, with the directories a umask of 002 left -/// below it. That user is the owner of the first directory in the chain not root's own, and +/// - for root, on Linux only (`ROOT_WORKS_FOR_USERS`), one user other than root, the one the +/// copy or extraction is for -- root extracting into `/home/alice`, which alice owns, with +/// the directories a umask of 002 left below it. That user is the owner of the first directory in the chain not root's own, and /// must own every directory below it: root's own, or a third user's, below it is mixed in, /// and others than its owner could have created its name. A directory the caller made and /// verified (`ChainStart::Made`) starts no such chain: it is root's own, or the owner it was @@ -645,11 +645,7 @@ fn dir_writers( #[allow(clippy::unnecessary_cast)] let mode = st.st_mode as u32; let uid = st.st_uid; - let trusted_owner = match above_owner { - Some(owner) => uid == owner, - None => uid == euid || (euid == 0 && uid != 0 && start == ChainStart::Anchor), - }; - if !trusted_owner || mode & 0o002 != 0 { + if !trusted_owner(uid, euid, above_owner, start, ROOT_WORKS_FOR_USERS) || mode & 0o002 != 0 { DirWriters::Others } else if mode & 0o020 == 0 { DirWriters::Owner { uid } @@ -661,6 +657,32 @@ fn dir_writers( } } +/// Whether root trusts a tree one other user owns alone (`dir_writers`): on Linux only, the one +/// system where every ACL that could let someone else write such a directory is read +/// (`acls_let_others_write`). Elsewhere an ACL is never read -- a macOS ACL granting bob write +/// on alice's 0755 directory would go unseen -- so root trusts only its own directories, as +/// the private-group rule is off there too (`is_private_group`). +const ROOT_WORKS_FOR_USERS: bool = cfg!(target_os = "linux"); + +/// Whether a directory owned by `uid` may be the trusted owner's alone, the effective user being +/// `euid`, in a chain that has gone into the tree of `above_owner`, where it stands as `start`; +/// `works_for_users` being whether root may trust a tree one other user owns alone +/// (`ROOT_WORKS_FOR_USERS`). The rule `dir_writers` states. +fn trusted_owner( + uid: u32, + euid: u32, + above_owner: Option, + start: ChainStart, + works_for_users: bool, +) -> bool { + match above_owner { + Some(owner) => uid == owner, + None => { + uid == euid || (works_for_users && euid == 0 && uid != 0 && start == ChainStart::Anchor) + } + } +} + /// `only_the_user_writes` for the directory open on `fd`, worked out at once: for tests. #[cfg(test)] fn nobody_else_can_create(fd: RawFd) -> io::Result { @@ -1208,8 +1230,9 @@ mod tests { use super::{ acl_names_others, acls_let_others_write, dir_writers, empty_lending_read, group_entry, group_is_private, is_private_group, made_by_us, others_can_rename, read_private_group, - user_entry, utimens_link_if_still, verify_made_dir, ChainStart, ChainTrust, DirWriters, - FoundDir, FsOwners, MadeObject, MadeTrust, Preserve, UserEntry, + trusted_owner, user_entry, utimens_link_if_still, verify_made_dir, ChainStart, ChainTrust, + DirWriters, FoundDir, FsOwners, MadeObject, MadeTrust, Preserve, UserEntry, + ROOT_WORKS_FOR_USERS, }; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::ffi::OsStrExt; @@ -1258,6 +1281,8 @@ mod tests { /// owner, not root, owning every directory below root's own, none of them writable by /// others. The test user stands for that user here, and root's view is taken by asking as /// euid 0; `/` stands for `/home`. + // Root works for a user on Linux only (`ROOT_WORKS_FOR_USERS`). + #[cfg(target_os = "linux")] #[test] fn root_trusts_a_tree_one_other_user_owns_alone() { let me = unsafe { libc::geteuid() }; @@ -1320,6 +1345,8 @@ mod tests { /// In the tree of a user root works for, the user can rename into place any directory /// they can reach -- one of root's, or another user's from a directory they share -- and /// root must give such a one nothing: only the user's own directories are found there. + // Root works for a user on Linux only (`ROOT_WORKS_FOR_USERS`). + #[cfg(target_os = "linux")] #[test] fn root_gives_nothing_to_a_directory_not_the_users_found_in_their_tree() { if unsafe { libc::geteuid() } == 0 { @@ -1347,6 +1374,8 @@ mod tests { /// tree is trusted only where every directory above it up to one of root's own is the /// user's alone, and root's is no directory others can write either. This crate's own /// directory is the test user's, below root's `/home`; a directory in `/tmp` is not. + // Root works for a user on Linux only (`ROOT_WORKS_FOR_USERS`). + #[cfg(target_os = "linux")] #[test] fn root_trusts_a_users_tree_at_the_anchor_only_where_it_rests_on_roots_own() { let me = unsafe { libc::geteuid() }; @@ -1377,6 +1406,7 @@ mod tests { assert_eq!(anchor.found_dir(&src, mode), FoundDir::AsRequested); } + #[cfg(target_os = "linux")] /// Whether `dir` and every directory above it up to the first of root's are `uid`'s, /// writable by nobody else -- group write permission only for the user's private group -- /// and that one of root's by nobody but root: as the test sees it, by name. @@ -1404,16 +1434,25 @@ mod tests { /// continue the chain: another user's, or root's, are mixed in. #[test] fn root_trusts_no_mixed_owners() { + // Off Linux, where no ACL is read, root trusts nobody's tree but its own. + assert!(trusted_owner(US, 0, None, ChainStart::Anchor, true)); + assert!(!trusted_owner(US, 0, None, ChainStart::Anchor, false)); + assert!(trusted_owner(0, 0, None, ChainStart::Anchor, false)); + assert!(trusted_owner(US, US, None, ChainStart::Anchor, false)); use DirWriters::{Others, Owner, OwnerAndGroup}; // Root's own, at the top. assert_eq!( dir_writers(&parent(0, 0o755), 0, None, ChainStart::Anchor), Owner { uid: 0 } ); - // One user's, below root's or at the top. + // One user's, below root's or at the top -- on Linux only. assert_eq!( dir_writers(&parent(US, 0o755), 0, None, ChainStart::Anchor), - Owner { uid: US } + if ROOT_WORKS_FOR_USERS { + Owner { uid: US } + } else { + Others + } ); assert_eq!( dir_writers(&parent(US, 0o755), 0, Some(US), ChainStart::Anchor), From 9f62580101347189798e6b55cbcfaa873e36a617 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 20:45:45 +0000 Subject: [PATCH 42/71] pax: count a directory as made only while it is as this run left it DirTree::standing took a directory for one this run made (Made or Implicit) on its (st_dev, st_ino) and member path alone, and such a directory is stamped without the existing-directory rule (found_dir). Root running pax -rw -pe into a parent bob can write, not sticky, makes the member directory d and defers its attributes; bob removes d and makes his own there, which ext4 may give the same inode number. At apply time standing matched, and root gave bob's directory the member's mode, and under -p o its owner. As cp does with MadeDirs, pax now records what it left each directory it made as (LeftAs: owner, group and permission bits, from the fstat that verified it), and standing counts it as made only while its fstat still shows that. Anything else is Ordinary, judged as found existing: through found_dir, which leaves it alone where others can write its parent. Bob cannot make a directory owned by root (or by whoever pax runs as). apply_dir_attrs notes what it gave a made directory, so it stays the one made afterwards. standing now takes the fstat rather than the inode number; every caller had it. Not the ctime: every entry pax adds below the directory changes it, at too many sites to note. Test: test_a_directory_made_again_at_a_reused_number_is_not_the_one_made records a made and an implicit directory, then asks about the same (dev, ino) with another owner, group or mode (failed before this change). Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 179 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 156 insertions(+), 23 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 5840b5060..caf765ad6 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -216,6 +216,11 @@ pub(crate) struct DirTree { /// found existing, these take a member's attributes wherever they are -- /// at that path: one renamed to another member's name is found there. made: RefCell>>, + /// What each directory in `implicit` and `made` was left as by this run + /// (`LeftAs`): its inode number alone cannot tell it from a directory + /// someone else made after removing it, which can be given the same + /// number (`standing`). + left_as: RefCell>, /// The mtime each pre-existing directory had when this run first walked /// into it, before any member created below it changed that. -u compares /// against this: a `find -depth` list names a directory after its @@ -270,6 +275,7 @@ impl DirTree { replaced: RefCell::new(HashSet::new()), unverified: RefCell::new(HashSet::new()), made: RefCell::new(HashMap::new()), + left_as: RefCell::new(HashMap::new()), pre_run_mtimes: RefCell::new(HashMap::new()), }) } @@ -363,7 +369,7 @@ impl DirTree { } } let next = Rc::new(next); - trust = match self.standing(id, key) { + trust = match self.standing(&st, key) { Standing::Implicit | Standing::Made => ChainTrust::made(&next)?, _ => trust.found(&next)?, }; @@ -385,12 +391,19 @@ impl DirTree { /// directory consults: the walk and `open_dir` (through `admit`), /// `make_dir_at` and `apply_dir_attrs`. A directory this run made counts /// as made only at the path it was made at: one someone renamed to - /// another member's name is one found existing there. - fn standing(&self, id: (u64, u64), key: &[u8]) -> Standing { + /// another member's name is one found existing there. Nor where it is no + /// longer owned, grouped or moded as this run left it (`LeftAs`): someone + /// who can write its parent can remove it and make another there, which + /// the filesystem can give the same inode number. + fn standing(&self, st: &libc::stat, key: &[u8]) -> Standing { + let id = file_id(st); + // Made there, and still as this run left it: an inode number can be + // handed on to a directory someone else makes in its place (`LeftAs`). let made_at = |made: &RefCell>>| { made.borrow() .get(&id) .is_some_and(|at| at.as_slice() == key) + && self.left_as.borrow().get(&id) == Some(&LeftAs::of(st)) }; if self.is_replaced(id) { Standing::Replaced @@ -435,6 +448,7 @@ impl DirTree { self.made.borrow_mut().remove(&id); self.unverified.borrow_mut().remove(&id); self.replaced.borrow_mut().remove(&id); + self.left_as.borrow_mut().remove(&id); let made = match fresh.trust { MadeTrust::ParentOwnerOnly => { self.unverified.borrow_mut().insert(id); @@ -444,6 +458,15 @@ impl DirTree { MadeTrust::Full => &self.made, }; made.borrow_mut().insert(id, key.to_vec()); + self.left_as.borrow_mut().insert(id, fresh.left_as); + } + + /// Note what this run has just left the directory `st` it made as, once it has given it + /// attributes itself (`apply_dir_attrs`). + fn note_left_as(&self, st: &libc::stat) { + if let Some(left) = self.left_as.borrow_mut().get_mut(&file_id(st)) { + *left = LeftAs::of(st); + } } /// Record the directory with `id` as found in place of one this run @@ -503,7 +526,7 @@ impl DirTree { /// Whether `st`, met at `member`, is a directory this run created there /// only to hold members below it, rather than one that was there before. pub(crate) fn is_implicit(&self, st: &libc::stat, member: &MemberPath) -> bool { - self.standing(file_id(st), &member.key()) == Standing::Implicit + self.standing(st, &member.key()) == Standing::Implicit } /// `is_implicit`, for the member that names the directory and so gives it @@ -515,7 +538,7 @@ impl DirTree { pub(crate) fn claim_implicit(&self, st: &libc::stat, member: &MemberPath) -> bool { let id = file_id(st); let key = member.key(); - if self.standing(id, &key) != Standing::Implicit { + if self.standing(st, &key) != Standing::Implicit { return false; } self.implicit.borrow_mut().remove(&id); @@ -700,19 +723,27 @@ fn apply_dir_attrs(tree: &DirTree, dir: &PendingDir, policy: &AttrPolicy) -> Pax Err(PaxError::Io(e)) if is_superseded(&e) => return Ok(()), Err(e) => return Err(e), }; - if fstat(fd.as_fd()).is_none_or(|st| file_id(&st) != dir.id) { + let Some(st) = fstat(fd.as_fd()).filter(|st| file_id(st) == dir.id) else { return Ok(()); - } - let with_mode = match tree.standing(dir.id, &member.key()) { + }; + let (with_mode, ours) = match tree.standing(&st, &member.key()) { Standing::Replaced => return Err(PaxError::Io(made::replaced())), Standing::Unverified => return Err(attrs_withheld()), - Standing::Implicit | Standing::Made => true, - Standing::Ordinary => found_dir_with_mode(trust, &fd, policy)?, + Standing::Implicit | Standing::Made => (true, true), + Standing::Ordinary => (found_dir_with_mode(trust, &fd, policy)?, false), }; - if search_only { - return set_attrs_search_only(fd.as_fd(), &dir.attrs, policy, with_mode); + let set = if search_only { + set_attrs_search_only(fd.as_fd(), &dir.attrs, policy, with_mode) + } else { + set_attrs_with(&AttrTarget::Fd(fd.as_fd()), &dir.attrs, policy, with_mode) + }; + // What it was given is this run's doing: it is still the directory made. + if ours { + if let Some(st) = fstat(fd.as_fd()) { + tree.note_left_as(&st); + } } - set_attrs_with(&AttrTarget::Fd(fd.as_fd()), &dir.attrs, policy, with_mode) + set } /// For a directory found existing at a member's name, held as `fd`, in a @@ -850,6 +881,35 @@ enum DirOrigin { struct FreshDir { id: (u64, u64), trust: MadeTrust, + left_as: LeftAs, +} + +/// What this run left a directory it made as: owner, group and permission +/// bits, from its `fstat`. A directory someone else made at the same name +/// after removing this one can have the same `(st_dev, st_ino)`, but not this +/// owner unless they are the user pax runs as -- who could have changed the +/// directory anyway. +/// +/// Not its ctime: every entry this run adds below the directory changes that, +/// at too many sites to note each one. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct LeftAs { + uid: u32, + gid: u32, + mode: u32, +} + +impl LeftAs { + fn of(st: &libc::stat) -> Self { + // Cast needed: `mode_t` is u16 on macOS and u32 on Linux. + #[allow(clippy::unnecessary_cast)] + let mode = st.st_mode as u32 & 0o7777; + LeftAs { + uid: st.st_uid, + gid: st.st_gid, + mode, + } + } } impl FreshDir { @@ -864,6 +924,7 @@ impl FreshDir { Ok(Some(FreshDir { id: file_id(&st), trust, + left_as: LeftAs::of(&st), })) } } @@ -990,7 +1051,7 @@ pub(crate) fn make_dir_at( let existing_dir = stat_at(dirfd, name).filter(|st| st.st_mode & libc::S_IFMT == libc::S_IFDIR); if let Some(st) = existing_dir { let id = file_id(&st); - return match tree.standing(id, &member.key()) { + return match tree.standing(&st, &member.key()) { Standing::Replaced => Err(PaxError::Io(made::replaced())), Standing::Implicit => { tree.claim_implicit(&st, member); @@ -2011,14 +2072,19 @@ mod tests { let tree = DirTree::open_path(dir.path()).unwrap(); let id = (1, 4242); let (p, q) = (member("p").key(), member("q").key()); - let fresh = |trust| FreshDir { id, trust }; + let fresh = |trust| FreshDir { + id, + trust, + left_as: LEFT, + }; + let st = status(id, LEFT); tree.record_made(fresh(MadeTrust::Full), &p, false); - assert!(tree.standing(id, &p) == Standing::Made); + assert!(tree.standing(&st, &p) == Standing::Made); tree.record_made(fresh(MadeTrust::Full), &q, true); - assert!(tree.standing(id, &q) == Standing::Implicit); + assert!(tree.standing(&st, &q) == Standing::Implicit); assert!( - tree.standing(id, &p) == Standing::Ordinary, + tree.standing(&st, &p) == Standing::Ordinary, "the number still stood for the directory made for p/" ); @@ -2026,11 +2092,77 @@ mod tests { // directory proven new. tree.replaced.borrow_mut().insert(id); tree.record_made(fresh(MadeTrust::Full), &p, false); - assert!(tree.standing(id, &p) == Standing::Made); + assert!(tree.standing(&st, &p) == Standing::Made); tree.record_made(fresh(MadeTrust::ParentOwnerOnly), &q, true); - assert!(tree.standing(id, &q) == Standing::Unverified); + assert!(tree.standing(&st, &q) == Standing::Unverified); tree.record_made(fresh(MadeTrust::Full), &p, false); - assert!(tree.standing(id, &p) == Standing::Made); + assert!(tree.standing(&st, &p) == Standing::Made); + } + + /// What `record_made` is told a directory was left as, in the tests. + const LEFT: LeftAs = LeftAs { + uid: 0, + gid: 0, + mode: 0o700, + }; + + /// An `fstat` answer for the directory `id`, as `left` says. + fn status(id: (u64, u64), left: LeftAs) -> libc::stat { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + // Casts needed: the field types differ between platforms. + #[allow(clippy::unnecessary_cast)] + { + st.st_dev = id.0 as _; + st.st_ino = id.1 as _; + st.st_mode = (libc::S_IFDIR as u32 | left.mode) as _; + } + st.st_uid = left.uid; + st.st_gid = left.gid; + st + } + + /// A directory this run made, removed by someone who can write its parent + /// and made again there by them, can be given the same inode number -- + /// ext4 hands a freed one straight back. Met at its member's name before + /// its attributes are applied, it is not the directory made: it is found + /// existing there (`Standing::Ordinary`), and judged as one, unless it is + /// still owned, grouped and moded as this run left it. + #[test] + fn test_a_directory_made_again_at_a_reused_number_is_not_the_one_made() { + let dir = plib::tmp::TempDir::new().unwrap(); + let tree = DirTree::open_path(dir.path()).unwrap(); + let id = (1, 4242); + let (p, q) = (member("p").key(), member("q").key()); + let fresh = FreshDir { + id, + trust: MadeTrust::Full, + left_as: LEFT, + }; + tree.record_made(fresh, &p, false); + tree.record_made( + FreshDir { + id: (1, 4243), + ..fresh + }, + &q, + true, + ); + let bobs = LeftAs { uid: 1000, ..LEFT }; + let regrouped = LeftAs { gid: 1000, ..LEFT }; + let opened = LeftAs { + mode: 0o755, + ..LEFT + }; + for other in [bobs, regrouped, opened] { + assert!(tree.standing(&status(id, other), &p) == Standing::Ordinary); + assert!(tree.standing(&status((1, 4243), other), &q) == Standing::Ordinary); + } + assert!(tree.standing(&status(id, LEFT), &p) == Standing::Made); + assert!(tree.standing(&status((1, 4243), LEFT), &q) == Standing::Implicit); + + // What this run gives it itself is noted, and it stays the one made. + tree.note_left_as(&status(id, opened)); + assert!(tree.standing(&status(id, opened), &p) == Standing::Made); } /// A directory recorded as found in place of one made, or as made with @@ -2060,8 +2192,9 @@ mod tests { assert_eq!(decided, DirAttrs::Withheld(u)); assert!(!tree.claim_implicit(&stat_at(tree.root(), c"u").unwrap(), &member("u"))); - assert!(tree.standing(r, &r_key) == Standing::Replaced); - assert!(tree.standing(u, &u_key) == Standing::Unverified); + let st = |name: &CStr| stat_at(tree.root(), name).unwrap(); + assert!(tree.standing(&st(c"r"), &r_key) == Standing::Replaced); + assert!(tree.standing(&st(c"u"), &u_key) == Standing::Unverified); } /// Every site that enters, merges into or stamps a directory consults the From 1025d8b8c4c7ebff207ada8db178fcf7d2671555 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 20:46:39 +0000 Subject: [PATCH 43/71] NONPOSIX, plib: document the existing-directory rule, where it fails closed, and its residuals NONPOSIX.md had no word on what cp -p and pax -p do to a directory that already existed. It now has one section, under cp, which pax's entry points to: the rule (mode and owner only where nobody but the user could have created the name, up to the named destination), what happens elsewhere (left alone, a diagnostic, exit 1), and where it fails closed by the user's decision -- an NFSv4 or CIFS ACL, group write permission for a shared primary group under umask 002, an ACL or database that cannot be read. Also root's trust in a tree one other user owns alone (Linux only), and the residuals. In madefs, two residuals the audit found are recorded where the rule lives: a second /etc/group line for the same gid, listing others, is not seen (is_private_group); and rests_on_roots_own runs once, lazily, so where the named path already crosses a directory others can write, its writer can time a move of their own directory against the climb. Co-Authored-By: Claude Opus 5.5 --- NONPOSIX.md | 40 ++++++++++++++++++++++++++++++++++++++++ plib/src/madefs.rs | 9 ++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/NONPOSIX.md b/NONPOSIX.md index cfa4900d7..777a7880f 100644 --- a/NONPOSIX.md +++ b/NONPOSIX.md @@ -228,6 +228,44 @@ dh_installexamples), with GNU cp's meaning: quoted as GNU coreutils quotes them. sysvinit installs with `cp -afv etc/* $(DESTDIR)...`. +#### Existing directories under `-p` + +`cp -p` (and `-a`) and `pax -p` give a directory that already existed in the +destination what `-p` asks for -- its mode and owner from the source -- only +where nobody but the user could have created its name: in its parent and in +every directory above it, up to the destination the user named. Elsewhere it +keeps its mode, owner and times, its contents are still copied, a diagnostic +names it, and the exit status is 1. Without a mode or owner asked for it gets +its times only, as by default. GNU cp and libarchive do no such check. + +A directory counts as one nobody else can create entries in when it is the +user's, grants no other write permission (sticky or not), grants group write +permission only for the user's private group -- its primary group, named as +the user, listing no other member -- and carries no ACL that lets others +write. This fails closed, with the diagnostic and exit 1, wherever it cannot +tell: + + * an NFSv4 or CIFS ACL (`system.nfs4_acl`, `system.nfs4_acl_xdr`, + `system.cifs_acl`), whose entries are not evaluated; + * group write permission for a group the user shares -- a primary group that + is not a user-private group, as under a umask of 002 without user private + groups; + * an ACL attribute, or the user and group databases, that cannot be read. + +On Linux, root working in another user's tree -- extracting into +`/home/alice`, which alice owns -- also trusts that one user's directories, as +the user's own: every directory below root's own must be alice's alone, and +so must every directory above where her tree starts, up to one of root's, and +each directory found there must itself be hers. Elsewhere, where ACLs are +not read, root trusts only its own directories. + +What is not seen: groups granted outside the user and group databases +(`pam_group`, systemd `SupplementaryGroups=`); another account given the +user's primary gid (accounts are not enumerated); a second entry for the same +gid, in a later NSS source or a second `/etc/group` line; a group password; +a macOS ACL; and, in another user's tree, a move of that user's own +directories timed against the lazy check above it. + ### cpio The whole utility is an addition: a compatibility front-end over `pax` @@ -611,6 +649,8 @@ Debian source packages, with GNU patch's meaning: asked for (script or `/dev/tty`) rather than taken for the end. Reads GNU volume labels and `'M'` continuation headers; writes whole members per volume. Written in ustar format only, and incompatible with `-z`. + * `-p` on a directory that already existed follows the rule under + [cp, existing directories under `-p`](#existing-directories-under--p). * `-x bcpio`, `-x sv4cpio`, `-x sv4crc` — the historic pax names for the old binary cpio header and the SVR4 "newc" headers without and with a data checksum. POSIX names only `cpio` (odc), `pax` and `ustar`. All three are diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index f46d5a918..39f6dc309 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -737,6 +737,12 @@ const MAX_CLIMB: usize = 4096; /// that directory is now, never a path -- and fails closed: a `..` that cannot be opened or /// read, the root of the file hierarchy reached without one of root's directories, or more /// than `MAX_CLIMB` levels, and the tree is not trusted. +/// +/// Residual: the climb runs once, lazily, when a mode or owner is first asked of a directory +/// found in the tree -- not when the anchor was opened. Where the named path already crosses +/// a directory others can write, whoever can write it can move the tree between the open and +/// the climb, and time the climb to see the tree where it rests on root's own. What they can +/// move there is only their own directory: only directories of theirs are affected. fn rests_on_roots_own(fd: RawFd, owner: u32) -> bool { let id = |st: &libc::stat| (st.st_dev, st.st_ino); let Ok(st) = fstat(fd) else { @@ -898,7 +904,8 @@ fn acl_names_others(xattr: &[u8]) -> bool { /// is out of the user's reach to read, and is not considered. /// - A group lookup returns the entry of the first NSS source that has the gid: a group of the same /// gid in a later source, listing others -- an administrator's misconfiguration -- is not -/// seen. +/// seen. Nor is a second line for the same gid in the same source (`/etc/group` holding +/// two), listing others: the lookup returns the first. pub fn is_private_group(gid: u32, euid: u32) -> bool { #[cfg(feature = "test-hooks")] PRIVATE_GROUP_QUERIES.with(|queries| queries.set(queries.get() + 1)); From ebd4c9e2f854c017681f728a40acf68ceeab907e Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 20:46:42 +0000 Subject: [PATCH 44/71] NONPOSIX: record that cp --parents refuses a source ending in .. The user's 2026-10-08 decision, implemented by 27dbfbbf, was to be recorded here and never was: cp --parents refuses a source whose last component is `..` (diagnostic, exit 1), where GNU cp copies onto the directory the destination's trailing `..` reaches, outside the target. The entry also says that a source of `.` copies into the target itself. Co-Authored-By: Claude Opus 5.5 --- NONPOSIX.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/NONPOSIX.md b/NONPOSIX.md index 777a7880f..160f778f0 100644 --- a/NONPOSIX.md +++ b/NONPOSIX.md @@ -213,7 +213,11 @@ dh_installexamples), with GNU cp's meaning: * `--parents` — the destination of each source is the target directory followed by the source's path, and missing directories on that path are made from the source's (with `-p`, their owner, mode and times too). The - target must be an existing directory. + target must be an existing directory. Unlike GNU, a source ending in `..` + is refused with a diagnostic and exit status 1: its destination ends in + `..` too, which names no entry inside the target but whatever directory + that reaches -- for `..` itself the target's parent -- and GNU cp copies + onto that. A source of `.` copies its contents into the target itself. * `-l` — each non-directory is hard-linked to its source instead of copied; with `-R`, directories are made and the files in them linked. An existing destination is replaced only under `-f` (or `-i` answered yes); one that is From 2cdd0047b1b9e8cce37c87bf573d00067311f266 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 20:54:58 +0000 Subject: [PATCH 45/71] pax tests: re-extract a umask 002 tree of the user's private group with -p e The private-group rule (from cp-chaintrust) was tested for one directory below a group-writable destination, by its mode. This tests what a user meets: a whole tree made under umask 002, every directory group-writable and of the user's private group, extracted again with -p e under umask 002. Every directory found must take the member's times and mode, with no diagnostic and exit 0. pax built from 394299fd, before the rule landed, fails it: "not applying owner, mode or times ... others can create entries beside it" for t, t/a and t/a/b, their times left as they were, exit 1. A shared-group destination is still refused, as test_reextract_into_a_group_writable_ destination_with_pe and cp's tests already check. Co-Authored-By: Claude Opus 5.5 --- pax/tests/security/existing_dir.rs | 75 ++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/pax/tests/security/existing_dir.rs b/pax/tests/security/existing_dir.rs index c495325ef..5e6d924b8 100644 --- a/pax/tests/security/existing_dir.rs +++ b/pax/tests/security/existing_dir.rs @@ -407,3 +407,78 @@ fn test_copy_trusts_no_destination_reached_through_a_link_others_could_plant() { } } } + +/// Run pax with `args` in `dir` under umask 002. +fn pax_umask_002(dir: &Path, args: &[&str]) -> Output { + use std::os::unix::process::CommandExt; + let mut command = Command::new(env!("CARGO_BIN_EXE_pax")); + command.args(args).current_dir(dir).stdin(Stdio::null()); + // SAFETY: umask is async-signal-safe. + unsafe { + command.pre_exec(|| { + libc::umask(0o002); + Ok(()) + }); + } + command.output().unwrap() +} + +/// The modification time of `path`, in seconds. +fn mtime_of(path: &Path) -> i64 { + use std::os::unix::fs::MetadataExt; + fs::metadata(path).unwrap().mtime() +} + +/// Set the modification time of the directory or file `path`. +fn set_mtime(path: &Path, time: std::time::SystemTime) { + fs::File::open(path).unwrap().set_modified(time).unwrap(); +} + +/// A tree made under a umask of 002 -- every directory group-writable, of the +/// user's private group, as Debian-style user private groups intend -- and +/// extracted again with -p e: every directory found there is the user's alone, +/// so each takes the member's times and mode, with no diagnostic and exit 0. +/// Any group-writable directory on the way used to leave every directory +/// found below it untouched, with "not applying owner, mode or times" and +/// exit 1. +#[test] +fn test_pe_reextracts_a_umask_002_tree_of_the_users_private_group() { + if plib::testing::user_private_group().is_none() { + eprintln!("note: this host gives the user no private group; test skipped"); + return; + } + let temp = TempDir::new().unwrap(); + let src = temp.path().join("src"); + let dirs = ["t", "t/a", "t/a/b"]; + fs::create_dir_all(src.join("t/a/b")).unwrap(); + fs::write(src.join("t/a/b/f"), "data\n").unwrap(); + let then = std::time::UNIX_EPOCH + std::time::Duration::from_secs(978_307_200); + for dir in dirs.iter().rev() { + fs::set_permissions(src.join(dir), fs::Permissions::from_mode(0o775)).unwrap(); + set_mtime(&src.join(dir), then); + } + let out = pax(&src, &["-w", "-f", "../a.tar", "t"]); + assert!(out.status.success(), "pax -w"); + let archive = temp.path().join("a.tar"); + let archive = archive.to_str().unwrap(); + + let dest = temp.path().join("dest"); + fs::create_dir(&dest).unwrap(); + fs::set_permissions(&dest, fs::Permissions::from_mode(0o775)).unwrap(); + let out = pax_umask_002(&dest, &["-r", "-f", archive]); + assert!(out.status.success(), "first extraction"); + let now = std::time::SystemTime::now(); + for dir in dirs { + assert_eq!(mode_of(&dest.join(dir)), 0o775, "{dir}"); + set_mtime(&dest.join(dir), now); + } + + let out = pax_umask_002(&dest, &["-r", "-p", "e", "-f", archive]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(0), "stderr: {stderr}"); + assert!(!stderr.contains(DIAGNOSTIC), "stderr: {stderr}"); + for dir in dirs { + assert_eq!(mtime_of(&dest.join(dir)), 978_307_200, "{dir}: times"); + assert_eq!(mode_of(&dest.join(dir)), 0o775, "{dir}: mode"); + } +} From 1f8238c7ad6c44c60edbdf2e0c96c0f0ec17f67b Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 20:56:49 +0000 Subject: [PATCH 46/71] pax: stop counting a made directory as made once -p o has given it away Under -p o as root, apply_dir_attrs gives a directory pax made the member's owner, and note_left_as then recorded that owner as what pax left it as. The new owner, bob, can remove it and make another at its name -- ext4 can hand back the same inode number -- with the recorded mode. A second pending apply for that name (an archive holding d twice with different owners, or -rw -s mapping two sources onto one name; the pending list does not dedupe) then met Standing::Made, skipped the existing-directory rule, and root gave bob's planted directory the second member's owner and mode. note_left_as now forgets a directory it finds owned by someone other than the user pax runs as and than the owner it was made with: it is no longer a directory this run made, and a later apply judges it as found existing (found_dir_with_mode). The owner it was made with still counts, rather than the effective user alone: on a filesystem that stores no owners (vfat, exfat, classic ntfs) made_by_us accepts a directory reporting the mount's owner in full, and pax gives it nothing else, so such directories keep their standing. Not ctime: nothing short of the owner check distinguishes bob's directory from pax's once bob owns it, and while pax still owns it only the user pax runs as could replace it with one owned the same. Test: a made and an implicit directory, given another owner by an apply, are Ordinary at the same id, mode and path afterwards (before: Made); one left with the user's owner or the mount's stays Made. Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 80 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index caf765ad6..a5e5be214 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -463,10 +463,28 @@ impl DirTree { /// Note what this run has just left the directory `st` it made as, once it has given it /// attributes itself (`apply_dir_attrs`). + /// + /// Unless that gave it away: owned now by someone other than the user pax runs as and than + /// the owner it was made with (root under -p o), it is theirs, and no longer counts as made. + /// They can remove it and make another at its name, which the filesystem can give the same + /// inode number, owner and mode, so `LeftAs` cannot tell the two apart; a second pending + /// apply for that name (a member named twice, two sources -s maps onto one) judges what it + /// finds as found existing (`found_dir_with_mode`). The owner it was made with stays: on a + /// filesystem that stores no owners that is the mount's, and pax gives it nothing else. fn note_left_as(&self, st: &libc::stat) { - if let Some(left) = self.left_as.borrow_mut().get_mut(&file_id(st)) { - *left = LeftAs::of(st); + let id = file_id(st); + let euid = unsafe { libc::geteuid() }; + let mut left_as = self.left_as.borrow_mut(); + let Some(left) = left_as.get_mut(&id) else { + return; + }; + if st.st_uid != euid && st.st_uid != left.uid { + left_as.remove(&id); + self.implicit.borrow_mut().remove(&id); + self.made.borrow_mut().remove(&id); + return; } + *left = LeftAs::of(st); } /// Record the directory with `id` as found in place of one this run @@ -2165,6 +2183,64 @@ mod tests { assert!(tree.standing(&status(id, opened), &p) == Standing::Made); } + /// A directory this run made and then, under -p o as root, gave to + /// someone else is theirs from then on: they can remove it and make + /// another at its name, which ext4 can give the same number, and set its + /// mode to what this run left. A second pending apply for that name -- a + /// member named twice, or two sources -s maps onto one name -- must then + /// judge it as found existing (`Standing::Ordinary`), never as the one + /// made, or root gives the planted directory the second member's owner + /// and mode without the existing-directory rule. + #[test] + fn test_a_made_directory_given_to_someone_else_is_no_longer_the_one_made() { + let dir = plib::tmp::TempDir::new().unwrap(); + let tree = DirTree::open_path(dir.path()).unwrap(); + let euid = unsafe { libc::geteuid() }; + let ours = LeftAs { uid: euid, ..LEFT }; + let given = LeftAs { + uid: euid.wrapping_add(1), + mode: 0o755, + ..LEFT + }; + let (p, q) = (member("p").key(), member("q").key()); + for (id, key, implicit) in [((1, 4242), &p, false), ((1, 4243), &q, true)] { + let fresh = FreshDir { + id, + trust: MadeTrust::Full, + left_as: ours, + }; + tree.record_made(fresh, key, implicit); + // pax gave it the member's owner and mode, and noted that. + tree.note_left_as(&status(id, given)); + assert!( + tree.standing(&status(id, given), key) == Standing::Ordinary, + "implicit={implicit}: a directory given away still counted as made" + ); + } + + // Left with its own owner -- the user's, or the one a filesystem that + // stores no owners reports for everything -- it stays the one made. + let mount_owner = LeftAs { + uid: euid.wrapping_add(2), + ..LEFT + }; + for left in [ours, mount_owner] { + let id = (1, 4244); + let fresh = FreshDir { + id, + trust: MadeTrust::Full, + left_as: left, + }; + tree.record_made(fresh, &p, false); + let stamped = LeftAs { + mode: 0o750, + ..left + }; + tree.note_left_as(&status(id, stamped)); + assert!(tree.standing(&status(id, stamped), &p) == Standing::Made); + } + } + /// A directory recorded as found in place of one made, or as made with /// an owner that could not be verified, keeps that standing whatever /// reaches it -- the walk, copy mode's entry, a member naming it -- and From 162fcf7311d22c1fe7a4420fc9fec9b9c405bbe1 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 20:58:01 +0000 Subject: [PATCH 47/71] pax -k: keep an existing directory whose owner could not be verified, silently make_dir_at matched Standing::Unverified -- a directory this run made with an owner it could not verify, on NFS or FUSE -- before the -k arms, so pax -r -k (and -rw -k) withheld its attributes and reported "not applying owner, mode or times", exit 1, where -k leaves any directory found existing alone and says nothing. -k now keeps it first, as it keeps a made or ordinary one. Nothing is weakened: under -k it takes no attributes either way, and without -k its attributes are still withheld and diagnosed. Test: an Unverified directory (through the registry, as the existing standing tests reach it) is Keep under -k (before: Withheld) and still Withheld without it. Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index a5e5be214..069a6ef55 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -1075,8 +1075,12 @@ pub(crate) fn make_dir_at( tree.claim_implicit(&st, member); Ok(DirAttrs::Apply(id)) } + // -k leaves it alone, as it does any directory found existing; + // there is nothing to withhold. + Standing::Unverified | Standing::Made | Standing::Ordinary if no_clobber => { + Ok(DirAttrs::Keep) + } Standing::Unverified => Ok(DirAttrs::Withheld(id)), - Standing::Made | Standing::Ordinary if no_clobber => Ok(DirAttrs::Keep), // Whether one found existing may take them is decided when they // are applied, from its parent (`apply_dir_attrs`). Standing::Made | Standing::Ordinary => Ok(DirAttrs::Apply(id)), @@ -2241,6 +2245,26 @@ mod tests { } } + /// -k leaves an existing directory entirely alone, and says nothing: one + /// this run made with an owner it could not verify (`Standing::Unverified`, + /// NFS or FUSE) too. Its attributes were withheld and diagnosed even under + /// -k; without -k they still are. + #[test] + fn test_no_clobber_keeps_an_unverified_directory_silently() { + let dir = plib::tmp::TempDir::new().unwrap(); + let tree = DirTree::open_path(dir.path()).unwrap(); + std::fs::create_dir(dir.path().join("u")).unwrap(); + let u = file_id(&stat_at(tree.root(), c"u").unwrap()); + tree.unverified.borrow_mut().insert(u); + + let kept = make_dir_at(&tree, tree.root(), &member("u"), 0o755, true).unwrap(); + assert_eq!(kept, DirAttrs::Keep); + let decided = make_dir_at(&tree, tree.root(), &member("u"), 0o755, false).unwrap(); + assert_eq!(decided, DirAttrs::Withheld(u)); + let st = stat_at(tree.root(), c"u").unwrap(); + assert!(tree.standing(&st, &member("u").key()) == Standing::Unverified); + } + /// A directory recorded as found in place of one made, or as made with /// an owner that could not be verified, keeps that standing whatever /// reaches it -- the walk, copy mode's entry, a member naming it -- and From e113ae8429432060e59ed116f00ecbc59cd31f01 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 21:00:54 +0000 Subject: [PATCH 48/71] pax: decide whether a link destination already is its source by the pinned inode When link_replacing_with found the destination name taken, it decided "the destination already is the source" -- leave it, and count the member as linked to itself -- by resolving the source's name again with fstatat. Once the source is pinned, someone who can rename in the source directory can point that name at the destination's file: the member was then skipped, and its destination name left holding another file. The source's identity now comes from the pinned inode (Linux, with a verified procfs), or from the identity the caller examined (`expected`) for a source linked by name. Only a source known by neither -- callers that pass no `expected` -- is resolved by name again. Under -H/-L the symbolic link itself still counts as the source, as before. Test: a test-only hook point (race_hook Point::LinkExists) runs when the destination is found taken; there the source's name is made a link to the destination's file. The destination must end up holding the pinned source (before: skipped as already linked to itself). Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 80 +++++++++++++++++++++++++++++++++++++----- pax/modes/race_hook.rs | 3 ++ 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 069a6ef55..502b6bcf2 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -1221,10 +1221,12 @@ fn created(dirfd: BorrowedFd<'_>, name: &CStr) -> bool { /// be the link source itself (`pax -rwl tree .`, or a member linked to its own /// name), and unlinking it destroys the only thing there was to link. Nothing /// is changed and `true` is returned; the caller decides whether that merits a -/// diagnostic. Identity is (dev, ino) of both names, the source resolved the -/// way `linkat` resolves it: the name itself, or with `follow` the file a -/// symbolic link refers to -- which may be the very file at `name` -- and the -/// link too, which is just as much the source (`pax -rwl -H link .`). +/// diagnostic. Identity is (dev, ino): the source's is the pinned inode's, or +/// the one the caller expects (`link_replacing_with`); only for a source known +/// by neither is the name resolved again, the way `linkat` resolves it -- the +/// name itself, or with `follow` the file a symbolic link refers to. With +/// `follow` the link itself counts too, being just as much the source +/// (`pax -rwl -H link .`). pub(crate) fn link_replacing( from_dir: libc::c_int, from_name: &CStr, @@ -1269,16 +1271,31 @@ pub(crate) fn link_replacing_with( if no_clobber { return Ok(false); } + #[cfg(test)] + crate::modes::race_hook::reached( + crate::modes::race_hook::Point::LinkExists, + dirfd.as_raw_fd(), + name, + ); if let Some(dst) = stat_at(dirfd, name) { - let src_flags = if follow { 0 } else { libc::AT_SYMLINK_NOFOLLOW }; - let resolved = fstatat(from_dir, from_name, src_flags); + // The source is the file pinned, or the one the caller examined: its + // name, resolved again, can be pointed at the destination's file by + // now, which would skip the member as linked to itself. Only a source + // known by neither is resolved again. + let resolved = source.identity().or_else(|| { + let src_flags = if follow { 0 } else { libc::AT_SYMLINK_NOFOLLOW }; + fstatat(from_dir, from_name, src_flags).map(|st| file_id(&st)) + }); + // Followed, the link itself is the source just as much + // (`pax -rwl -H link .`). let link = follow .then(|| fstatat(from_dir, from_name, libc::AT_SYMLINK_NOFOLLOW)) - .flatten(); + .flatten() + .map(|st| file_id(&st)); if [resolved, link] - .iter() + .into_iter() .flatten() - .any(|src| file_id(src) == file_id(&dst)) + .any(|src| src == file_id(&dst)) { return Ok(true); } @@ -1357,6 +1374,16 @@ impl<'a> LinkSource<'a> { Ok(Some(LinkSource::Pinned { proc_dir, pin })) } + /// The source's `(st_dev, st_ino)`: the pinned inode's, or the one the + /// caller expects of a name; `None` for a name nothing is known of. + fn identity(&self) -> Option<(u64, u64)> { + match self { + #[cfg(target_os = "linux")] + LinkSource::Pinned { pin, .. } => fstat(pin.as_fd()).map(|st| file_id(&st)), + LinkSource::Name { expected, .. } => *expected, + } + } + /// Make `name` in `dirfd` a hard link to the source. fn link_to(&self, dirfd: BorrowedFd<'_>, name: &CStr) -> std::io::Result<()> { let to = dirfd.as_raw_fd(); @@ -2245,6 +2272,41 @@ mod tests { } } + /// Whether the destination already is the source decides whether a link + /// is made at all: that file is left in place, and the member counted as + /// linked to itself. It was decided by resolving the source's name again, + /// which someone who can rename in the source directory can point at the + /// destination's file once the source is pinned: the member was then + /// skipped, its name left holding another file. The pinned inode decides. + #[cfg(target_os = "linux")] + #[test] + fn test_link_replacing_judges_itself_by_the_pinned_source() { + use crate::modes::race_hook::{with_hook, Point}; + let dir = plib::tmp::TempDir::new().unwrap(); + std::fs::write(dir.path().join("a"), "source\n").unwrap(); + // Another name keeps the source linkable once `a` is taken from it. + std::fs::hard_link(dir.path().join("a"), dir.path().join("a2")).unwrap(); + std::fs::write(dir.path().join("d"), "other\n").unwrap(); + let tree = DirTree::open_path(dir.path()).unwrap(); + let root = tree.root().as_raw_fd(); + let source = file_id(&stat_at(tree.root(), c"a").unwrap()); + + let path = dir.path().to_path_buf(); + let swap = move |point, _: libc::c_int, _: &CStr| { + if point == Point::LinkExists { + // `a` now names the destination's file. + std::fs::remove_file(path.join("a")).unwrap(); + std::fs::hard_link(path.join("d"), path.join("a")).unwrap(); + } + }; + let linked = with_hook(swap, || { + link_replacing_with(root, c"a", false, Some(source), tree.root(), c"d", false) + }); + assert!(!linked.unwrap(), "skipped as already linked to itself"); + let d = stat_at(tree.root(), c"d").unwrap(); + assert_eq!(file_id(&d), source, "d does not hold the source"); + } + /// -k leaves an existing directory entirely alone, and says nothing: one /// this run made with an owner it could not verify (`Standing::Unverified`, /// NFS or FUSE) too. Its attributes were withheld and diagnosed even under diff --git a/pax/modes/race_hook.rs b/pax/modes/race_hook.rs index 5b3dac323..3248877d7 100644 --- a/pax/modes/race_hook.rs +++ b/pax/modes/race_hook.rs @@ -35,6 +35,9 @@ pub(crate) enum Point { Linking, /// `-l` has just made the link `name` in the destination. Linked, + /// A link to the source, already pinned, found the destination `name` + /// taken. + LinkExists, } type Hook = Box; From 066c786f643ed8ec39d5369ec3d10212c5c20931 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 21:02:32 +0000 Subject: [PATCH 49/71] pax: link a cpio link set's names to the set's file pinned, not by name join_link_set (a later name linked to a surviving earlier one) and move_names_to (the earlier names moved over to the file just made for the set's data) called link_replacing, which links by name with no `expected` identity. The holder name was checked to hold the set's file first, but between that check and linkat anyone who can rename in that directory could put another file at the name, and the set's names became links to it. Both now call link_replacing_with with the identity they checked -- the set's file, or the file just made -- so on Linux with a verified procfs the link is made to the pinned inode itself, and elsewhere a link to any other file is removed again and the member fails. The test seam race_hook Point::Linking moves from copy mode's -l call site into link_replacing_with itself, at the same point (just before the source is pinned), so it now covers every caller. Test: move_names_to with another file renamed over the made file's name at Point::Linking must fail and leave the earlier name alone (before: it linked the earlier name to the planted file). Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 2 ++ pax/modes/copy.rs | 6 ----- pax/modes/race_hook.rs | 4 +-- pax/modes/read.rs | 55 +++++++++++++++++++++++++++++++++++------- 4 files changed, 50 insertions(+), 17 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 502b6bcf2..a6e06f35d 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -1259,6 +1259,8 @@ pub(crate) fn link_replacing_with( name: &CStr, no_clobber: bool, ) -> PaxResult { + #[cfg(test)] + crate::modes::race_hook::reached(crate::modes::race_hook::Point::Linking, from_dir, from_name); let source = LinkSource::new(from_dir, from_name, follow, expected)?; let link = || source.link_to(dirfd, name); diff --git a/pax/modes/copy.rs b/pax/modes/copy.rs index 671380fe4..cffd80498 100644 --- a/pax/modes/copy.rs +++ b/pax/modes/copy.rs @@ -748,12 +748,6 @@ fn copy_file( // `pax -rwl tree .` names every file as its own destination. Under // -H/-L the walk followed a symbolic link here, and the link made is // to the file it refers to, as POSIX requires of -l. - #[cfg(test)] - crate::modes::race_hook::reached( - crate::modes::race_hook::Point::Linking, - entry.dir_fd(), - entry.file_name(), - ); let linked = link_replacing_with( entry.dir_fd(), entry.file_name(), diff --git a/pax/modes/race_hook.rs b/pax/modes/race_hook.rs index 3248877d7..8e43782ac 100644 --- a/pax/modes/race_hook.rs +++ b/pax/modes/race_hook.rs @@ -30,8 +30,8 @@ pub(crate) enum Point { Made, /// A directory has just been made at `name` with `mkdirat`. MadeDir, - /// Copy mode's `-l` is about to link the source `name`, which the walk - /// has already examined. + /// A hard link is about to be made to the source `name`, which the + /// caller has already examined: copy mode's `-l`, a cpio link set's names. Linking, /// `-l` has just made the link `name` in the destination. Linked, diff --git a/pax/modes/read.rs b/pax/modes/read.rs index 5ffba7575..ccf228c6b 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -14,9 +14,9 @@ use crate::error::{PaxError, PaxResult}; use crate::formats::OptionRecords; use crate::interactive::{InteractivePrompter, RenameResult}; use crate::modes::anchored::{ - attrs_withheld, create_replacing, link_replacing, make_dir_at, set_attrs_fd, - set_made_node_attrs, stat_at, unlink_at, AttrPolicy, Attrs, DirAttrs, DirTree, MemberPath, - PendingDirs, + attrs_withheld, create_replacing, link_replacing, link_replacing_with, make_dir_at, + set_attrs_fd, set_made_node_attrs, stat_at, unlink_at, AttrPolicy, Attrs, DirAttrs, DirTree, + MemberPath, PendingDirs, }; use crate::modes::select::Selector; use crate::pattern::Pattern; @@ -698,9 +698,13 @@ fn join_link_set( .iter() .find_map(|path| holding_name(tree, path, set.file)); if let Some((src_dir, src_leaf)) = holder { - link_replacing( + // To the set's file, pinned: the holder was found holding it, but + // its name can be given another file before the link is made. + link_replacing_with( src_dir.as_raw_fd(), &src_leaf, + false, + Some(set.file), dirfd, name, options.no_clobber, @@ -719,7 +723,7 @@ fn join_link_set( // -k kept what was there; the data is still the earlier names'. return fill_link_set(archive, tree, entry, options, set); }; - let mut names = move_names_to(holders, dirfd, name)?; + let mut names = move_names_to(holders, dirfd, name, file)?; names.push(member.display.clone()); // Created empty when no earlier name survived to link to: the data is // then still to come, on a later name. @@ -751,7 +755,7 @@ fn fill_link_set( let dir = Rc::clone(dir); let (temp, file) = create_temp_file(dir.as_fd(), entry, options)?; let filled = write_file_data(archive, file, entry, options) - .and_then(|id| Ok((id, move_names_to(holders, dir.as_fd(), &temp)?))); + .and_then(|id| Ok((id, move_names_to(holders, dir.as_fd(), &temp, id)?))); let removed = unlink_at(dir.as_fd(), &temp); let (file, names) = filled?; removed?; @@ -765,17 +769,21 @@ fn fill_link_set( } /// Link each of `holders` -- names of a set, with the directory and leaf they -/// were found at -- to `name` in `dirfd`, returning their paths. +/// were found at -- to `name` in `dirfd`, the file `file` this extraction has +/// just made, returning their paths. fn move_names_to( holders: Vec<((Rc, CString), PathBuf)>, dirfd: BorrowedFd<'_>, name: &CStr, + file: (u64, u64), ) -> PaxResult> { let mut names = Vec::with_capacity(holders.len() + 1); for ((dir, leaf), path) in holders { // These names were created by this extraction, so they are replaced - // even under -k. - link_replacing(dirfd.as_raw_fd(), name, dir.as_fd(), &leaf, false)?; + // even under -k. The link is to the file made, pinned, never to + // whatever has been put at its name since. + let from = dirfd.as_raw_fd(); + link_replacing_with(from, name, false, Some(file), dir.as_fd(), &leaf, false)?; names.push(path); } Ok(names) @@ -1115,6 +1123,35 @@ mod tests { /// Extraction chowned by the numeric fields alone, so an archive carried /// between hosts restored each file to whichever account happened to hold /// the originating host's uid -- the problem uname exists to solve. + /// A link set's earlier names are moved over to the file just made for + /// its data by linking that file's name again. Someone who can rename in + /// that directory can put another file at the name first, and the set's + /// names became links to it. The link is made to the file made, or not + /// at all. + #[cfg(target_os = "linux")] + #[test] + fn test_link_set_names_move_only_to_the_file_made() { + use crate::modes::race_hook::{with_hook, Point}; + let dir = plib::tmp::TempDir::new().unwrap(); + std::fs::write(dir.path().join("new"), "data\n").unwrap(); + std::fs::write(dir.path().join("old"), "").unwrap(); + let tree = DirTree::open_path(dir.path()).unwrap(); + let made = id_at(tree.root(), c"new").unwrap(); + let root = Rc::new(tree.root().try_clone_to_owned().unwrap()); + let holders = vec![((Rc::clone(&root), c"old".to_owned()), PathBuf::from("old"))]; + + let path = dir.path().to_path_buf(); + let swap = move |point, _: libc::c_int, name: &CStr| { + if point == Point::Linking && name == c"new" { + std::fs::write(path.join("planted"), "planted\n").unwrap(); + std::fs::rename(path.join("planted"), path.join("new")).unwrap(); + } + }; + let moved = with_hook(swap, || move_names_to(holders, root.as_fd(), c"new", made)); + assert!(moved.is_err(), "linked to the file put in its place"); + assert_eq!(std::fs::read(dir.path().join("old")).unwrap(), b""); + } + #[test] fn test_owner_prefers_the_recorded_name_over_the_numeric_id() { let euid = unsafe { libc::geteuid() }; From d50ebd74016bc556b0061fc870a50e45ff0a59e0 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 21:08:00 +0000 Subject: [PATCH 50/71] plib: verify /proc once per process and hold the descriptor procfs_dir opened /proc by name and fstatfs'd it on every call, and its callers -- pax's pinned link source (per file under -rwl), the search-only attribute path, chmod_pinned, the blocking reopen of a regular file, and cp's pinned copies -- call it per file. It now opens and verifies /proc on the first call and keeps the verified descriptor (or the failure) in a OnceLock for the rest of the process, returning a BorrowedFd<'static>. Holding it is also safer than reopening /proc by name each time: what was verified is what every later self/fd/N lookup goes through, whatever is mounted at /proc since. self resolves to the process doing the lookup, so a forked child still reaches its own descriptors, and the descriptor stays O_CLOEXEC. pax -rwl over 200,000 empty files (200 directories of 1000), three runs each: 1.65, 1.67, 1.65 s before; 1.55, 1.54, 1.54 s after. Over 1000 files, openat + fstatfs calls fall from 2031 + 1001 to 1032 + 2. Test: two calls, both held, return the same descriptor, and it is procfs (the old function gave descriptors 3 and 4). Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 5 ++++- plib/src/madefs.rs | 48 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index a6e06f35d..304ae2bab 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -1315,7 +1315,10 @@ enum LinkSource<'a> { /// linked through its `self/fd/N` entry in a procfs-verified `/proc` /// (Linux). #[cfg(target_os = "linux")] - Pinned { proc_dir: File, pin: OwnedFd }, + Pinned { + proc_dir: BorrowedFd<'static>, + pin: OwnedFd, + }, /// A name, resolved again by `linkat`: `flags` is `AT_SYMLINK_FOLLOW` or /// 0, and `expected` what the link made is checked against afterwards. Name { diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index 39f6dc309..d22ac726b 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -22,14 +22,16 @@ use gettextrs::gettext; use std::cell::OnceCell; use std::collections::HashMap; use std::ffi::{CStr, CString, OsStr, OsString}; -#[cfg(target_os = "linux")] -use std::fs::File; use std::io; +#[cfg(target_os = "linux")] +use std::os::fd::{AsFd, BorrowedFd}; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; use std::os::unix::ffi::OsStrExt; use std::path::{Component, Path}; use std::rc::{Rc, Weak}; use std::sync::Mutex; +#[cfg(target_os = "linux")] +use std::sync::OnceLock; /// Open flags for a directory that is only ever walked through, used as the `dirfd` of an `*at` /// call, or `fstat`ed (`ChainTrust`). @@ -1170,8 +1172,28 @@ pub fn chmod_pinned(fd: RawFd, mode: libc::mode_t) -> io::Result<()> { /// `/proc`, opened and verified to be procfs (`PROC_SUPER_MAGIC`), so that `self/fd/N` names /// exactly the inode open on descriptor N rather than whatever else is mounted or planted there. +/// +/// Opened and verified once per process, on the first call, and held open from then on: every +/// later call hands out the same descriptor, or the same failure. A pinned chmod, link or +/// reopen used to open `/proc` by name and `fstatfs` it every time, a pair of system calls per +/// file. Holding the verified descriptor is also safer than reopening by name: what is checked +/// is what every later lookup goes through, whatever is mounted at `/proc` since. `self` +/// resolves to whichever process looks it up, so a child forked after the first call that uses +/// the descriptor still reaches its own `fd/N`. It is `O_CLOEXEC`, so nothing exec'd inherits +/// it. +#[cfg(target_os = "linux")] +pub fn procfs_dir() -> io::Result> { + static PROC: OnceLock, String)>> = OnceLock::new(); + match PROC.get_or_init(|| open_procfs().map_err(|e| (e.raw_os_error(), e.to_string()))) { + Ok(fd) => Ok(fd.as_fd()), + Err((Some(errno), _)) => Err(io::Error::from_raw_os_error(*errno)), + Err((None, message)) => Err(io::Error::other(message.clone())), + } +} + +/// Open `/proc` and verify it is procfs (`procfs_dir`). #[cfg(target_os = "linux")] -pub fn procfs_dir() -> io::Result { +fn open_procfs() -> io::Result { const PROC_SUPER_MAGIC: u32 = 0x9fa0; let fd = unsafe { libc::open( @@ -1182,7 +1204,7 @@ pub fn procfs_dir() -> io::Result { if fd == -1 { return Err(io::Error::last_os_error()); } - let dir = unsafe { File::from_raw_fd(fd) }; + let dir = unsafe { OwnedFd::from_raw_fd(fd) }; let mut st = std::mem::MaybeUninit::::uninit(); if unsafe { libc::fstatfs(dir.as_raw_fd(), st.as_mut_ptr()) } != 0 { return Err(io::Error::last_os_error()); @@ -2130,6 +2152,24 @@ mod tests { /// A made symbolic link's times by name reach only the pinned link, never a hard link to /// another file swapped in for it. + /// `/proc` is opened and verified once per process: every call hands out + /// the same descriptor, which stays open and procfs. + #[cfg(target_os = "linux")] + #[test] + fn procfs_dir_is_verified_once_and_held() { + use std::os::fd::AsRawFd; + // Both held at once: two opens could not share a number. + let first = super::procfs_dir().unwrap(); + let second = super::procfs_dir().unwrap(); + assert_eq!(first.as_raw_fd(), second.as_raw_fd()); + let mut st = std::mem::MaybeUninit::::uninit(); + assert_eq!( + unsafe { libc::fstatfs(second.as_raw_fd(), st.as_mut_ptr()) }, + 0 + ); + assert_eq!(unsafe { st.assume_init() }.f_type as u32, 0x9fa0); + } + #[test] fn link_times_by_name_reach_only_the_pinned_link() { use std::os::fd::AsRawFd; From 2553d61b7bec0bae0c23c16a8cbc88f20af1db4c Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 21:09:11 +0000 Subject: [PATCH 51/71] pax: say what Standing::Made and DirTree::made actually require Both said a directory this run made "takes attributes wherever it is". standing() returns Made only when the directory is met at the member path it was made at, and -- since LeftAs -- only while it is still owned, grouped and moded as this run left it, which it no longer is once this run has given it to someone else. Met anywhere else, or changed, it is Ordinary, judged by the existing-directory rule. The comments now say that, and why the path matters: otherwise someone who can rename in the destination could rename a directory this run made to another member's name and have that member's mode or owner given to it without the existing-directory rule. Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 304ae2bab..0ca2974a0 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -213,8 +213,13 @@ pub(crate) struct DirTree { /// descriptor to be the ones made, for a member naming them -- or made /// to hold members below them and since claimed by the member naming /// them -- each with the member path it was made at. Unlike a directory - /// found existing, these take a member's attributes wherever they are -- - /// at that path: one renamed to another member's name is found there. + /// found existing, one of these takes a member's attributes without the + /// existing-directory rule (`Standing::Made`), but only when met at that + /// path and still as this run left it (`left_as`). Met at any other + /// path, it is one found existing there: otherwise someone who can + /// rename in the destination could rename a directory this run made to + /// another member's name, and have that member's mode or owner given to + /// it without the existing-directory rule. made: RefCell>>, /// What each directory in `implicit` and `made` was left as by this run /// (`LeftAs`): its inode number alone cannot tell it from a directory @@ -867,7 +872,12 @@ enum Standing { /// that names it. Implicit, /// Made and verified by this run for a member naming it, or implicit and - /// since claimed: takes attributes wherever it is. + /// since claimed; met at the member path it was made at, and still owned, + /// grouped and moded as this run left it (`LeftAs`), which it no longer + /// is once this run has given it to someone else. Takes a member's + /// attributes without the existing-directory rule. A directory this run + /// made, met anywhere else -- renamed to another member's name -- or no + /// longer as it was left, is `Ordinary`. Made, /// Found existing: takes its times, and its mode and owner only when /// asked for -- and then only where nobody else could have created its From 16ef25b1bcce1492c33f6d9f4dd442afdae0df41 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 21:13:49 +0000 Subject: [PATCH 52/71] plib, pax: one set of fstat, fstatat, lstat_at and cvt helpers The same few wrappers existed four times over: plib::madefs had private fstat and lstat_at (fstat a copy of the public plib::tty::fstat); pax's made.rs its own fstat, cvt and a lstat_at in its non-Linux module; and pax's anchored.rs fstat, fstatat and stat_at, returning Option rather than io::Result. plib::madefs now exports the one set, all returning io::Result: fstat (re-exporting plib::tty::fstat, so plib keeps one implementation), fstatat, lstat_at and cvt. pax's copies are gone; its callers use these, with .ok() where they want an Option, and the three places that turned None back into io::Error::last_os_error now pass the error the call left. No behaviour change: release tests of plib, pax and tree pass the same 1450, the pax golden gate is identical, cpmvsec 29 of 29, and pax checks clean for x86_64-apple-darwin, which builds the non-Linux module. Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 99 +++++++++++++++++++++++-------------------- pax/modes/copy.rs | 27 +++++++----- pax/modes/made.rs | 40 ++++------------- pax/modes/read.rs | 19 ++++++--- plib/src/madefs.rs | 28 +++++++----- 5 files changed, 105 insertions(+), 108 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 0ca2974a0..7055ae251 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -21,7 +21,8 @@ //! always created fresh rather than written through. use crate::error::{PaxError, PaxResult}; -use crate::modes::made::{self, cvt, verify_made_dir, MadeNode, MadeTrust}; +use crate::modes::made::{self, verify_made_dir, MadeNode, MadeTrust}; +use plib::madefs::{cvt, fstat, fstatat, lstat_at}; use plib::madefs::{ChainTrust, FoundDir, NamedAnchor, Preserve, SEARCH_ONLY}; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; @@ -514,7 +515,7 @@ impl DirTree { /// says came from `origin`, to be entered: never one found in place of a /// directory this run made, now or earlier, whichever member reaches it. fn admit(&self, fd: BorrowedFd<'_>, origin: DirOrigin) -> PaxResult { - let st = fstat(fd).ok_or_else(std::io::Error::last_os_error)?; + let st = fstat(fd.as_raw_fd())?; if origin == DirOrigin::Replaced { return Err(self.refuse_replaced(file_id(&st))); } @@ -746,7 +747,10 @@ fn apply_dir_attrs(tree: &DirTree, dir: &PendingDir, policy: &AttrPolicy) -> Pax Err(PaxError::Io(e)) if is_superseded(&e) => return Ok(()), Err(e) => return Err(e), }; - let Some(st) = fstat(fd.as_fd()).filter(|st| file_id(st) == dir.id) else { + let Some(st) = fstat(fd.as_raw_fd()) + .ok() + .filter(|st| file_id(st) == dir.id) + else { return Ok(()); }; let (with_mode, ours) = match tree.standing(&st, &member.key()) { @@ -762,7 +766,7 @@ fn apply_dir_attrs(tree: &DirTree, dir: &PendingDir, policy: &AttrPolicy) -> Pax }; // What it was given is this run's doing: it is still the directory made. if ours { - if let Some(st) = fstat(fd.as_fd()) { + if let Ok(st) = fstat(fd.as_raw_fd()) { tree.note_left_as(&st); } } @@ -948,7 +952,7 @@ impl FreshDir { let Some(trust) = verify_made_dir(parent, dir)? else { return Ok(None); }; - let st = fstat(dir).ok_or_else(std::io::Error::last_os_error)?; + let st = fstat(dir.as_raw_fd())?; Ok(Some(FreshDir { id: file_id(&st), trust, @@ -1076,7 +1080,9 @@ pub(crate) fn make_dir_at( // its attributes. With -k anything else there is left entirely alone. // Otherwise extracting onto an existing directory is not an error // (POSIX), and it is kept. - let existing_dir = stat_at(dirfd, name).filter(|st| st.st_mode & libc::S_IFMT == libc::S_IFDIR); + let existing_dir = lstat_at(dirfd.as_raw_fd(), name) + .ok() + .filter(|st| st.st_mode & libc::S_IFMT == libc::S_IFDIR); if let Some(st) = existing_dir { let id = file_id(&st); return match tree.standing(&st, &member.key()) { @@ -1129,7 +1135,7 @@ fn made_dir_id(tree: &DirTree, dirfd: BorrowedFd<'_>, member: &MemberPath) -> Pa reached_made_dir(dirfd, name); let (dir, _) = open_dir_for_attrs(dirfd, name)?; let Some(fresh) = FreshDir::verify(dirfd, dir.as_fd())? else { - let st = fstat(dir.as_fd()).ok_or_else(std::io::Error::last_os_error)?; + let st = fstat(dir.as_raw_fd())?; return Err(tree.refuse_replaced(file_id(&st))); }; let id = fresh.id; @@ -1152,7 +1158,9 @@ fn reached_made_dir(dirfd: BorrowedFd<'_>, name: &CStr) { /// Whether `name` below `dirfd` is a directory, not following a symlink. fn is_directory_at(dirfd: BorrowedFd<'_>, name: &CStr) -> bool { - stat_at(dirfd, name).is_some_and(|st| st.st_mode & libc::S_IFMT == libc::S_IFDIR) + lstat_at(dirfd.as_raw_fd(), name) + .ok() + .is_some_and(|st| st.st_mode & libc::S_IFMT == libc::S_IFDIR) } /// Remove whatever currently occupies `name`, so an exclusive create can win. @@ -1289,19 +1297,21 @@ pub(crate) fn link_replacing_with( dirfd.as_raw_fd(), name, ); - if let Some(dst) = stat_at(dirfd, name) { + if let Ok(dst) = lstat_at(dirfd.as_raw_fd(), name) { // The source is the file pinned, or the one the caller examined: its // name, resolved again, can be pointed at the destination's file by // now, which would skip the member as linked to itself. Only a source // known by neither is resolved again. let resolved = source.identity().or_else(|| { let src_flags = if follow { 0 } else { libc::AT_SYMLINK_NOFOLLOW }; - fstatat(from_dir, from_name, src_flags).map(|st| file_id(&st)) + fstatat(from_dir, from_name, src_flags) + .ok() + .map(|st| file_id(&st)) }); // Followed, the link itself is the source just as much // (`pax -rwl -H link .`). let link = follow - .then(|| fstatat(from_dir, from_name, libc::AT_SYMLINK_NOFOLLOW)) + .then(|| fstatat(from_dir, from_name, libc::AT_SYMLINK_NOFOLLOW).ok()) .flatten() .map(|st| file_id(&st)); if [resolved, link] @@ -1383,7 +1393,10 @@ impl<'a> LinkSource<'a> { return Err(std::io::Error::last_os_error().into()); } let pin = unsafe { OwnedFd::from_raw_fd(fd) }; - if !fstat(pin.as_fd()).is_some_and(|st| file_id(&st) == expected) { + if !fstat(pin.as_raw_fd()) + .ok() + .is_some_and(|st| file_id(&st) == expected) + { return Err(source_changed()); } Ok(Some(LinkSource::Pinned { proc_dir, pin })) @@ -1394,7 +1407,7 @@ impl<'a> LinkSource<'a> { fn identity(&self) -> Option<(u64, u64)> { match self { #[cfg(target_os = "linux")] - LinkSource::Pinned { pin, .. } => fstat(pin.as_fd()).map(|st| file_id(&st)), + LinkSource::Pinned { pin, .. } => fstat(pin.as_raw_fd()).ok().map(|st| file_id(&st)), LinkSource::Name { expected, .. } => *expected, } } @@ -1417,7 +1430,7 @@ impl<'a> LinkSource<'a> { .. } => unsafe { libc::linkat(*from_dir, from_name.as_ptr(), to, name.as_ptr(), *flags) }, }; - made::cvt(r) + cvt(r) } /// After `link_to` made `name`: a pinned source can only have linked the @@ -1457,7 +1470,10 @@ fn linked_expected( dirfd.as_raw_fd(), name, ); - if stat_at(dirfd, name).is_some_and(|st| file_id(&st) == expected) { + if lstat_at(dirfd.as_raw_fd(), name) + .ok() + .is_some_and(|st| file_id(&st) == expected) + { return Ok(()); } unlink_at(dirfd, name)?; @@ -1567,7 +1583,10 @@ fn reopen_regular_blocking( return Err(std::io::Error::last_os_error()); } let pin = unsafe { OwnedFd::from_raw_fd(pin) }; - if !fstat(pin.as_fd()).is_some_and(|st| st.st_mode & libc::S_IFMT == libc::S_IFREG) { + if !fstat(pin.as_raw_fd()) + .ok() + .is_some_and(|st| st.st_mode & libc::S_IFMT == libc::S_IFREG) + { return Err(std::io::Error::from_raw_os_error(libc::ENXIO)); } // The magic link is followed to the pinned inode: `O_NOFOLLOW` would @@ -1613,7 +1632,7 @@ fn reopen_regular_by_name( } else { 0 }; - match fstatat(dir_fd, name, stat_flags) { + match fstatat(dir_fd, name, stat_flags).ok() { Some(st) if st.st_mode & libc::S_IFMT == libc::S_IFREG => {} Some(_) => return Err(std::io::Error::from_raw_os_error(libc::ENXIO)), None => return Err(std::io::Error::last_os_error()), @@ -1684,7 +1703,10 @@ pub(crate) fn restore_dir_atime(entry: &ftw::Entry<'_>) { return; } let dir = unsafe { OwnedFd::from_raw_fd(fd) }; - if fstat(dir.as_fd()).is_some_and(|st| file_id(&st) == (metadata.dev(), metadata.ino())) { + if fstat(dir.as_raw_fd()) + .ok() + .is_some_and(|st| file_id(&st) == (metadata.dev(), metadata.ino())) + { restore_atime(dir.as_fd(), entry.path().as_inner(), metadata); } } @@ -1977,26 +1999,6 @@ fn owner_unverified(policy: &AttrPolicy) -> PaxResult<()> { ))) } -/// `fstatat` with `AT_SYMLINK_NOFOLLOW`, for asking what a name *is* without -/// following it anywhere. -pub(crate) fn stat_at(dirfd: BorrowedFd<'_>, name: &CStr) -> Option { - fstatat(dirfd.as_raw_fd(), name, libc::AT_SYMLINK_NOFOLLOW) -} - -/// `fstat` of an open descriptor. -fn fstat(fd: BorrowedFd<'_>) -> Option { - let mut st: libc::stat = unsafe { std::mem::zeroed() }; - let r = unsafe { libc::fstat(fd.as_raw_fd(), &mut st) }; - (r == 0).then_some(st) -} - -/// `fstatat` from a raw directory descriptor such as a walk entry's. -fn fstatat(dirfd: libc::c_int, name: &CStr, flags: libc::c_int) -> Option { - let mut st: libc::stat = unsafe { std::mem::zeroed() }; - let r = unsafe { libc::fstatat(dirfd, name.as_ptr(), &mut st, flags) }; - (r == 0).then_some(st) -} - #[cfg(test)] mod tests { use super::*; @@ -2007,7 +2009,7 @@ mod tests { /// The inode a descriptor refers to. fn ino_of(fd: &OwnedFd) -> u64 { - stat_at(fd.as_fd(), c".").unwrap().st_ino + lstat_at(fd.as_raw_fd(), c".").unwrap().st_ino } fn ino_at(path: &Path) -> u64 { @@ -2304,7 +2306,7 @@ mod tests { std::fs::write(dir.path().join("d"), "other\n").unwrap(); let tree = DirTree::open_path(dir.path()).unwrap(); let root = tree.root().as_raw_fd(); - let source = file_id(&stat_at(tree.root(), c"a").unwrap()); + let source = file_id(&lstat_at(tree.root().as_raw_fd(), c"a").unwrap()); let path = dir.path().to_path_buf(); let swap = move |point, _: libc::c_int, _: &CStr| { @@ -2318,7 +2320,7 @@ mod tests { link_replacing_with(root, c"a", false, Some(source), tree.root(), c"d", false) }); assert!(!linked.unwrap(), "skipped as already linked to itself"); - let d = stat_at(tree.root(), c"d").unwrap(); + let d = lstat_at(tree.root().as_raw_fd(), c"d").unwrap(); assert_eq!(file_id(&d), source, "d does not hold the source"); } @@ -2331,14 +2333,14 @@ mod tests { let dir = plib::tmp::TempDir::new().unwrap(); let tree = DirTree::open_path(dir.path()).unwrap(); std::fs::create_dir(dir.path().join("u")).unwrap(); - let u = file_id(&stat_at(tree.root(), c"u").unwrap()); + let u = file_id(&lstat_at(tree.root().as_raw_fd(), c"u").unwrap()); tree.unverified.borrow_mut().insert(u); let kept = make_dir_at(&tree, tree.root(), &member("u"), 0o755, true).unwrap(); assert_eq!(kept, DirAttrs::Keep); let decided = make_dir_at(&tree, tree.root(), &member("u"), 0o755, false).unwrap(); assert_eq!(decided, DirAttrs::Withheld(u)); - let st = stat_at(tree.root(), c"u").unwrap(); + let st = lstat_at(tree.root().as_raw_fd(), c"u").unwrap(); assert!(tree.standing(&st, &member("u").key()) == Standing::Unverified); } @@ -2353,7 +2355,7 @@ mod tests { for name in ["r", "u"] { std::fs::create_dir(dir.path().join(name)).unwrap(); } - let id = |name: &CStr| file_id(&stat_at(tree.root(), name).unwrap()); + let id = |name: &CStr| file_id(&lstat_at(tree.root().as_raw_fd(), name).unwrap()); let (r, u) = (id(c"r"), id(c"u")); let (r_key, u_key) = (member("r").key(), member("u").key()); assert!(matches!(tree.refuse_replaced(r), PaxError::Io(_))); @@ -2367,9 +2369,12 @@ mod tests { assert!(tree.parent_of(&member("u/x"), true).is_ok()); let decided = make_dir_at(&tree, tree.root(), &member("u"), 0o755, false).unwrap(); assert_eq!(decided, DirAttrs::Withheld(u)); - assert!(!tree.claim_implicit(&stat_at(tree.root(), c"u").unwrap(), &member("u"))); + assert!(!tree.claim_implicit( + &lstat_at(tree.root().as_raw_fd(), c"u").unwrap(), + &member("u") + )); - let st = |name: &CStr| stat_at(tree.root(), name).unwrap(); + let st = |name: &CStr| lstat_at(tree.root().as_raw_fd(), name).unwrap(); assert!(tree.standing(&st(c"r"), &r_key) == Standing::Replaced); assert!(tree.standing(&st(c"u"), &u_key) == Standing::Unverified); } @@ -2385,7 +2390,7 @@ mod tests { for name in ["r", "u"] { std::fs::create_dir(dir.path().join(name)).unwrap(); } - let id = |name: &CStr| file_id(&stat_at(tree.root(), name).unwrap()); + let id = |name: &CStr| file_id(&lstat_at(tree.root().as_raw_fd(), name).unwrap()); tree.replaced.borrow_mut().insert(id(c"r")); tree.unverified.borrow_mut().insert(id(c"u")); diff --git a/pax/modes/copy.rs b/pax/modes/copy.rs index cffd80498..bc51ad277 100644 --- a/pax/modes/copy.rs +++ b/pax/modes/copy.rs @@ -18,12 +18,13 @@ use crate::error::{PaxError, PaxResult}; use crate::interactive::{InteractivePrompter, RenameResult}; use crate::modes::anchored::{ attrs_withheld, create_replacing, file_id, link_replacing, link_replacing_with, make_dir_at, - restore_atime, restore_dir_atime, set_attrs_fd, set_made_node_attrs, stat_at, AttrPolicy, - Attrs, DirAttrs, DirTree, MemberPath, PendingDirs, + restore_atime, restore_dir_atime, set_attrs_fd, set_made_node_attrs, AttrPolicy, Attrs, + DirAttrs, DirTree, MemberPath, PendingDirs, }; use crate::modes::followed_link; use crate::modes::write::FileNames; use crate::subst::{substitute_name, Substitution}; +use plib::madefs::lstat_at; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::ffi::{CStr, CString}; @@ -119,7 +120,7 @@ pub fn copy_files( dev_stack: RefCell::new(Vec::new()), fatal: RefCell::new(None), }; - if let Some(st) = stat_at(tree.root(), c".") { + if let Ok(st) = lstat_at(tree.root().as_raw_fd(), c".") { walk.dest_ids.borrow_mut().insert(file_id(&st)); } @@ -347,7 +348,7 @@ impl CopyWalk<'_> { let pfd = parent.as_fd(); let name = mp.leaf.as_c_str(); - let existing = stat_at(pfd, name); + let existing = lstat_at(pfd.as_raw_fd(), name).ok(); if self.options.no_clobber && existing.is_some() { return Ok(false); } @@ -401,7 +402,7 @@ impl CopyWalk<'_> { if self.options.substitutions.is_empty() && !self.options.interactive { return; } - if let Some(dir) = stat_at(dirfd, c".") { + if let Ok(dir) = lstat_at(dirfd.as_raw_fd(), c".") { self.made_files .borrow_mut() .entry(file_id(&dir)) @@ -419,7 +420,8 @@ impl CopyWalk<'_> { } // SAFETY: the walk keeps the entry's directory open while it is visited. let dir = unsafe { BorrowedFd::borrow_raw(entry.dir_fd()) }; - stat_at(dir, c".") + lstat_at(dir.as_raw_fd(), c".") + .ok() .and_then(|st| made.get(&file_id(&st))) .is_some_and(|names| names.contains(entry.file_name())) } @@ -454,7 +456,7 @@ impl CopyWalk<'_> { return self.descend(member, metadata); }; let parent = self.tree.parent_of(&mp, true)?; - let existing = stat_at(parent.as_fd(), &mp.leaf); + let existing = lstat_at(parent.as_raw_fd(), &mp.leaf).ok(); if existing.is_some_and(|st| is_source(&st, entry, metadata)) { return self.dir_onto_itself(src, member, metadata); } @@ -477,8 +479,7 @@ impl CopyWalk<'_> { DirAttrs::Keep => existing.map(|st| file_id(&st)), }; let dir = self.tree.open_dir(parent.as_fd(), &mp.leaf, false)?; - let dest_st = stat_at(dir.as_fd(), c".") - .ok_or_else(|| PaxError::Io(std::io::Error::last_os_error()))?; + let dest_st = lstat_at(dir.as_raw_fd(), c".").map_err(PaxError::Io)?; if expected.is_some_and(|id| id != file_id(&dest_st)) { return Err(PaxError::Io(std::io::Error::other( "directory was replaced after it was checked", @@ -620,7 +621,9 @@ fn is_source(st: &libc::stat, entry: &ftw::Entry<'_>, metadata: &ftw::Metadata) } // SAFETY: the walk keeps the entry's directory open while it is visited. let dir = unsafe { BorrowedFd::borrow_raw(entry.dir_fd()) }; - stat_at(dir, entry.file_name()).is_some_and(|link| file_id(&link) == id) + lstat_at(dir.as_raw_fd(), entry.file_name()) + .ok() + .is_some_and(|link| file_id(&link) == id) } /// Diagnose copying a file to its own name, as BSD pax words it, and skip it. @@ -808,7 +811,9 @@ fn copy_file( /// Whether `name` in `dirfd` is the file `metadata` describes. fn is_file_at(dirfd: BorrowedFd<'_>, name: &CStr, metadata: &ftw::Metadata) -> bool { - stat_at(dirfd, name).is_some_and(|st| file_id(&st) == (metadata.dev(), metadata.ino())) + lstat_at(dirfd.as_raw_fd(), name) + .ok() + .is_some_and(|st| file_id(&st) == (metadata.dev(), metadata.ino())) } /// Actually copy file contents diff --git a/pax/modes/made.rs b/pax/modes/made.rs index c9e0c96d2..d6b5e90e2 100644 --- a/pax/modes/made.rs +++ b/pax/modes/made.rs @@ -26,7 +26,7 @@ //! `plib::madefs`. pub(crate) use plib::madefs::MadeTrust; -use plib::madefs::{fs_owners, made_by_us, others_can_rename, FsOwners, MadeObject}; +use plib::madefs::{cvt, fs_owners, fstat, made_by_us, others_can_rename, FsOwners, MadeObject}; #[cfg(target_os = "linux")] pub(crate) use plib::madefs::{proc_fd_name, procfs_dir}; use std::ffi::CStr; @@ -57,23 +57,6 @@ pub(crate) fn replaced() -> io::Error { io::Error::other("replaced after it was made") } -/// `fstat` of a descriptor. -fn fstat(fd: BorrowedFd<'_>) -> io::Result { - let mut st: libc::stat = unsafe { std::mem::zeroed() }; - if unsafe { libc::fstat(fd.as_raw_fd(), &mut st) } != 0 { - return Err(io::Error::last_os_error()); - } - Ok(st) -} - -/// `Ok(())` for a successful libc call's return value, the error otherwise. -pub(crate) fn cvt(r: libc::c_int) -> io::Result<()> { - if r != 0 { - return Err(io::Error::last_os_error()); - } - Ok(()) -} - /// How far a node of type `made_type` (`S_IFIFO`, ...), found with `st` on a /// filesystem keeping owners as `owners`, is trusted to be the one pax just /// made in `dirfd`. @@ -84,7 +67,7 @@ fn check_node( owners: FsOwners, ) -> io::Result { let type_ok = st.st_mode & libc::S_IFMT == made_type; - let parent = fstat(dirfd)?; + let parent = fstat(dirfd.as_raw_fd())?; let euid = unsafe { libc::geteuid() }; node_trust(made_object(st, owners), type_ok, &parent, euid).ok_or_else(replaced) } @@ -166,7 +149,7 @@ mod linux { return Err(io::Error::last_os_error()); } let fd = unsafe { OwnedFd::from_raw_fd(fd) }; - let st = fstat(fd.as_fd())?; + let st = fstat(fd.as_raw_fd())?; let trust = check_node(&st, made_type, dirfd, owners_of(fd.as_fd()))?; Ok(MadeNode { fd, @@ -212,7 +195,7 @@ mod linux { return Err(err); } if self.symlink { - let pinned = file_id(&fstat(self.fd.as_fd())?); + let pinned = file_id(&fstat(self.fd.as_raw_fd())?); let dirfd = self.dirfd.as_raw_fd(); return match utimens_link_if_still(dirfd, self.name, pinned, times)? { true => Ok(()), @@ -248,6 +231,7 @@ mod linux { mod other { use super::*; use crate::modes::anchored::file_id; + use plib::madefs::lstat_at; use std::os::fd::{AsFd, FromRawFd, OwnedFd}; /// How a made node is held. @@ -277,12 +261,12 @@ mod other { name: &'a CStr, made_type: libc::mode_t, ) -> io::Result { - let st = lstat_at(dirfd, name)?; + let st = lstat_at(dirfd.as_raw_fd(), name)?; if st.st_mode & libc::S_IFMT != made_type { return Err(replaced()); } if let Some(fd) = open_node(dirfd, name, made_type)? { - let held = fstat(fd.as_fd())?; + let held = fstat(fd.as_raw_fd())?; if file_id(&held) != file_id(&st) { return Err(replaced()); } @@ -352,7 +336,7 @@ mod other { else { return Ok(()); }; - let st = lstat_at(dirfd, name)?; + let st = lstat_at(dirfd.as_raw_fd(), name)?; if file_id(&st) != id || st.st_mode & libc::S_IFMT != made_type { return Err(replaced()); } @@ -398,14 +382,6 @@ mod other { errno == libc::EACCES || errno == libc::ENOTSUP || errno == libc::EOPNOTSUPP } - /// `fstatat` with `AT_SYMLINK_NOFOLLOW`. - fn lstat_at(dirfd: BorrowedFd<'_>, name: &CStr) -> io::Result { - let mut st: libc::stat = unsafe { std::mem::zeroed() }; - let flags = libc::AT_SYMLINK_NOFOLLOW; - cvt(unsafe { libc::fstatat(dirfd.as_raw_fd(), name.as_ptr(), &mut st, flags) })?; - Ok(st) - } - #[cfg(test)] mod tests { use super::held_by_name_after; diff --git a/pax/modes/read.rs b/pax/modes/read.rs index ccf228c6b..c78be2447 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -15,12 +15,13 @@ use crate::formats::OptionRecords; use crate::interactive::{InteractivePrompter, RenameResult}; use crate::modes::anchored::{ attrs_withheld, create_replacing, link_replacing, link_replacing_with, make_dir_at, - set_attrs_fd, set_made_node_attrs, stat_at, unlink_at, AttrPolicy, Attrs, DirAttrs, DirTree, - MemberPath, PendingDirs, + set_attrs_fd, set_made_node_attrs, unlink_at, AttrPolicy, Attrs, DirAttrs, DirTree, MemberPath, + PendingDirs, }; use crate::modes::select::Selector; use crate::pattern::Pattern; use crate::subst::{substitute_link_target, substitute_name, Substitution}; +use plib::madefs::lstat_at; use std::ffi::{CStr, CString}; use std::fs::File; use std::io::Write; @@ -812,7 +813,9 @@ fn pin_file(dirfd: BorrowedFd<'_>, name: &CStr, file: (u64, u64)) -> Option, name: &CStr) -> Option<(u64, u64)> { - stat_at(dirfd, name).map(|st| crate::modes::anchored::file_id(&st)) + lstat_at(dirfd.as_raw_fd(), name) + .ok() + .map(|st| crate::modes::anchored::file_id(&st)) } /// (st_dev, st_ino) of an open file. @@ -1021,10 +1024,12 @@ fn is_archive_newer(tree: &DirTree, entry: &ArchiveEntry) -> bool { return true; // no such directory, so nothing there: extract it }; // A directory created here only to hold earlier members is not one. - stat_at(parent.as_fd(), &member.leaf).is_none_or(|st| { - tree.is_implicit(&st, &member) - || (entry.mtime, i64::from(entry.mtime_nsec)) > tree.mtime_before_run(&st) - }) + lstat_at(parent.as_raw_fd(), &member.leaf) + .ok() + .is_none_or(|st| { + tree.is_implicit(&st, &member) + || (entry.mtime, i64::from(entry.mtime_nsec)) > tree.mtime_before_run(&st) + }) } /// The ids to give an extracted file. diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index d22ac726b..fd91c6625 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -1091,23 +1091,29 @@ pub fn chmod_fd(fd: RawFd, mode: libc::mode_t) -> io::Result<()> { Ok(()) } -/// `fstat` of a descriptor. -fn fstat(fd: RawFd) -> io::Result { +// The descriptor and name calls pax and cp share, each giving the error a failed call left: +// `fstat` (`crate::tty::fstat`), `fstatat`, `lstat_at` and `cvt`. +pub use crate::tty::fstat; + +/// `fstatat(2)` of `name` below `dirfd` (a descriptor, or `AT_FDCWD`), with `flags`. +pub fn fstatat(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result { let mut st: libc::stat = unsafe { std::mem::zeroed() }; - if unsafe { libc::fstat(fd, &mut st) } != 0 { - return Err(io::Error::last_os_error()); - } + cvt(unsafe { libc::fstatat(dirfd, name.as_ptr(), &mut st, flags) })?; Ok(st) } -/// `fstatat` with `AT_SYMLINK_NOFOLLOW`. -fn lstat_at(dirfd: RawFd, name: &CStr) -> io::Result { - let mut st: libc::stat = unsafe { std::mem::zeroed() }; - let flags = libc::AT_SYMLINK_NOFOLLOW; - if unsafe { libc::fstatat(dirfd, name.as_ptr(), &mut st, flags) } != 0 { +/// `fstatat` with `AT_SYMLINK_NOFOLLOW`: the entry `name` itself, never what a symbolic link +/// refers to. +pub fn lstat_at(dirfd: RawFd, name: &CStr) -> io::Result { + fstatat(dirfd, name, libc::AT_SYMLINK_NOFOLLOW) +} + +/// `Ok(())` for a libc call that returned 0, the error it left otherwise. +pub fn cvt(r: libc::c_int) -> io::Result<()> { + if r != 0 { return Err(io::Error::last_os_error()); } - Ok(st) + Ok(()) } /// The `fchmodat2` system call number (Linux 6.6 and later), where it is the generic 452. From d17adf77b0d348dc94558882160c8ca05898c725 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 21:26:52 +0000 Subject: [PATCH 53/71] pax: link later names of a copied file, and tar link members, to the file this run made Two hard-link sites still linked by name with no expected identity: - copy mode linked a later name of an already-copied file to the copy's destination path, from HardLinkTracker; - read mode linked a tar or pax link member (typeflag 1) to its target member's path. In a destination directory others can write, someone can rename their own file over that path after pax makes it -- root's `pax -rw src /dest` with /dest/pub 0777, not sticky -- and pax then linked their file into a directory only root writes, under the later name. HardLinkTracker is now generic over what it remembers of a first name. Write mode keeps the member path; copy mode keeps the destination path and the identity of the copy, taken by fstat of the descriptor that made it. Read mode keeps the identity of each regular file it extracts, by member path, and forgets it when a later member puts anything else at that name. Both sites now call link_replacing_with with that identity, so on Linux with a verified procfs the link is made to the pinned inode and anything else fails the member. Where this run made nothing at the name -- -k kept a file already there, or a link member's target was not extracted by this run -- there is no identity, and the link is made by name as before: what is there is what the member refers to. link_replacing, left with no callers but its own tests, is gone; those tests call link_replacing_with with None. Tests (both through the race_hook Linking point, as 066c786f did): a later name copied from a hard-linked pair, and a tar link member, each with another file renamed over the target just before the link -- the later name must not end up holding it (before: it held the planted file). Co-Authored-By: Claude Opus 5.5 --- pax/archive.rs | 47 ++++++++++------ pax/modes/anchored.rs | 54 ++++++++++-------- pax/modes/copy.rs | 79 ++++++++++++++++++++------ pax/modes/read.rs | 104 +++++++++++++++++++++++++++++++---- pax/modes/read/race_tests.rs | 4 ++ pax/modes/write.rs | 2 +- 6 files changed, 221 insertions(+), 69 deletions(-) diff --git a/pax/archive.rs b/pax/archive.rs index 1a89964a4..6f5b00dea 100644 --- a/pax/archive.rs +++ b/pax/archive.rs @@ -9,7 +9,7 @@ use crate::error::{PaxError, PaxResult}; use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; /// Type of archive entry @@ -374,39 +374,49 @@ pub trait ArchiveWriter { /// pax -w` lists it and walks it), and a file forgotten before the repeat would /// be stored again in full -- splitting a hard-linked pair on extraction, /// depending on the order. -#[derive(Debug, Default)] -pub struct HardLinkTracker { - /// The first path each file was stored under, by (dev, ino) - stored: HashMap<(u64, u64), PathBuf>, +/// +/// `T` is what is remembered of the first name: the archive member path when +/// writing; when copying, the destination path and the identity of the copy +/// made there. +#[derive(Debug)] +pub struct HardLinkTracker { + /// What was remembered of each file's first name, by (dev, ino) + stored: HashMap<(u64, u64), T>, +} + +impl Default for HardLinkTracker { + fn default() -> Self { + HardLinkTracker { + stored: HashMap::new(), + } + } } -impl HardLinkTracker { +impl HardLinkTracker { /// Create a new tracker pub fn new() -> Self { Self::default() } - /// The name a multiply-linked file was first stored under, if one of its - /// names already has been. - pub fn lookup(&self, dev: u64, ino: u64, nlink: u32) -> Option { + /// What was remembered of the name a multiply-linked file was first + /// stored under, if one of its names already has been. + pub fn lookup(&self, dev: u64, ino: u64, nlink: u32) -> Option { if nlink <= 1 { return None; } self.stored.get(&(dev, ino)).cloned() } - /// Note that a file's first name has been stored, as `stored`: the archive - /// member path when writing, the destination path when copying. + /// Note that a file's first name has been stored, and what to remember of + /// it (`stored`). /// /// Separate from `lookup` because it must only happen once that name /// really is in the archive or the destination. Recording a file before /// its data was read made every later name of an unreadable file a link /// to a member that was never written. - pub fn record(&mut self, dev: u64, ino: u64, nlink: u32, stored: &Path) { + pub fn record(&mut self, dev: u64, ino: u64, nlink: u32, stored: T) { if nlink > 1 { - self.stored - .entry((dev, ino)) - .or_insert_with(|| stored.to_path_buf()); + self.stored.entry((dev, ino)).or_insert(stored); } } } @@ -578,6 +588,7 @@ impl std::fmt::Display for ArchiveFormat { #[cfg(test)] mod tests { use super::*; + use std::path::Path; fn member(entry_type: EntryType, ino: u64, nlink: u32) -> ArchiveEntry { ArchiveEntry { @@ -669,13 +680,13 @@ mod tests { fn test_hard_link_tracker_keeps_the_first_name() { let mut links = HardLinkTracker::new(); // A file with one name is never remembered. - links.record(1, 7, 1, Path::new("solo")); + links.record(1, 7, 1, PathBuf::from("solo")); assert_eq!(links.lookup(1, 7, 1), None); - links.record(1, 9, 2, Path::new("a")); + links.record(1, 9, 2, PathBuf::from("a")); // Recording a second time keeps the first name, and the file stays // remembered however many of its names go by. - links.record(1, 9, 2, Path::new("b")); + links.record(1, 9, 2, PathBuf::from("b")); for _ in 0..3 { assert_eq!(links.lookup(1, 9, 2).as_deref(), Some(Path::new("a"))); } diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 7055ae251..9aa25d6ec 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -1240,25 +1240,15 @@ fn created(dirfd: BorrowedFd<'_>, name: &CStr) -> bool { /// name), and unlinking it destroys the only thing there was to link. Nothing /// is changed and `true` is returned; the caller decides whether that merits a /// diagnostic. Identity is (dev, ino): the source's is the pinned inode's, or -/// the one the caller expects (`link_replacing_with`); only for a source known -/// by neither is the name resolved again, the way `linkat` resolves it -- the -/// name itself, or with `follow` the file a symbolic link refers to. With -/// `follow` the link itself counts too, being just as much the source -/// (`pax -rwl -H link .`). -pub(crate) fn link_replacing( - from_dir: libc::c_int, - from_name: &CStr, - dirfd: BorrowedFd<'_>, - name: &CStr, - no_clobber: bool, -) -> PaxResult { - link_replacing_with(from_dir, from_name, false, None, dirfd, name, no_clobber) -} - -/// `link_replacing`, linking the file a symbolic link `from_name` refers to -/// when `follow` is set -- copy mode's `-l` under `-H`/`-L`, where POSIX says -/// "the hard link created ... shall be to the file referenced by the symbolic -/// link". Without it, `from_name` itself is linked, whatever it is. +/// `expected`; only for a source known by neither is the name resolved again, +/// the way `linkat` resolves it -- the name itself, or with `follow` the file +/// a symbolic link refers to. With `follow` the link itself counts too, being +/// just as much the source (`pax -rwl -H link .`). +/// +/// With `follow`, the file a symbolic link `from_name` refers to is linked -- +/// copy mode's `-l` under `-H`/`-L`, where POSIX says "the hard link created +/// ... shall be to the file referenced by the symbolic link". Without it, +/// `from_name` itself is linked, whatever it is. /// /// `linkat` by name resolves `from_name` again -- and with `follow`, the /// link's target too -- so it can link a file other than the one the caller @@ -1267,7 +1257,9 @@ pub(crate) fn link_replacing( /// link is made to the pinned inode itself, so no other file is ever linked. /// Where it cannot be pinned, a link made by name to anything else is removed /// again and the call fails (`linked_expected`), rather than leave the -/// destination a second name for a file nobody asked to copy. +/// destination a second name for a file nobody asked to copy. Without +/// `expected` -- a source this run did not make, such as a tar link member's +/// target already there before it -- the link is made by name. pub(crate) fn link_replacing_with( from_dir: libc::c_int, from_name: &CStr, @@ -2602,7 +2594,16 @@ mod tests { let dir = DirTree::open_path(temp.path()).unwrap(); let f = CString::new("f").unwrap(); - let same = link_replacing(dir.root().as_raw_fd(), &f, dir.root(), &f, false).unwrap(); + let same = link_replacing_with( + dir.root().as_raw_fd(), + &f, + false, + None, + dir.root(), + &f, + false, + ) + .unwrap(); assert!(same, "the name was already the source"); assert_eq!( std::fs::read_to_string(temp.path().join("f")).unwrap(), @@ -2620,7 +2621,16 @@ mod tests { let f = CString::new("f").unwrap(); let g = CString::new("g").unwrap(); - let same = link_replacing(dir.root().as_raw_fd(), &f, dir.root(), &g, false).unwrap(); + let same = link_replacing_with( + dir.root().as_raw_fd(), + &f, + false, + None, + dir.root(), + &g, + false, + ) + .unwrap(); assert!(!same); assert_eq!( std::fs::read_to_string(temp.path().join("g")).unwrap(), diff --git a/pax/modes/copy.rs b/pax/modes/copy.rs index bc51ad277..2c5fadbf3 100644 --- a/pax/modes/copy.rs +++ b/pax/modes/copy.rs @@ -17,14 +17,14 @@ use crate::archive::HardLinkTracker; use crate::error::{PaxError, PaxResult}; use crate::interactive::{InteractivePrompter, RenameResult}; use crate::modes::anchored::{ - attrs_withheld, create_replacing, file_id, link_replacing, link_replacing_with, make_dir_at, - restore_atime, restore_dir_atime, set_attrs_fd, set_made_node_attrs, AttrPolicy, Attrs, - DirAttrs, DirTree, MemberPath, PendingDirs, + attrs_withheld, create_replacing, file_id, link_replacing_with, make_dir_at, restore_atime, + restore_dir_atime, set_attrs_fd, set_made_node_attrs, AttrPolicy, Attrs, DirAttrs, DirTree, + MemberPath, PendingDirs, }; use crate::modes::followed_link; use crate::modes::write::FileNames; use crate::subst::{substitute_name, Substitution}; -use plib::madefs::lstat_at; +use plib::madefs::{fstat, lstat_at}; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::ffi::{CStr, CString}; @@ -107,7 +107,7 @@ pub fn copy_files( let walk = CopyWalk { tree: &tree, options, - link_tracker: RefCell::new(HardLinkTracker::new()), + link_tracker: RefCell::new(CopiedLinks::new()), dest_ids: RefCell::new(HashSet::new()), made_files: RefCell::new(HashMap::new()), prompter: RefCell::new(if options.interactive { @@ -170,6 +170,11 @@ impl CopyWalk<'_> { } } +/// The first copy of each multiply-linked file: the member path it was made +/// at, and the identity of the copy made there -- `None` where -k kept a file +/// already at that name. +type CopiedLinks = HardLinkTracker<(PathBuf, Option<(u64, u64)>)>; + /// State the three traversal callbacks share. /// /// The destination side is untouched by this: every leaf is still resolved @@ -181,7 +186,7 @@ impl CopyWalk<'_> { struct CopyWalk<'a> { tree: &'a DirTree, options: &'a CopyOptions, - link_tracker: RefCell, + link_tracker: RefCell, /// `(st_dev, st_ino)` of every destination directory this copy has created /// or entered, and of every directory that maps onto itself. A source /// directory found in here is one being copied *into* -- unless this copy @@ -738,7 +743,7 @@ fn copy_file( name: &CStr, member: &Path, options: &CopyOptions, - link_tracker: &mut HardLinkTracker, + link_tracker: &mut CopiedLinks, metadata: &ftw::Metadata, ) -> PaxResult<()> { let src_path = entry.path(); @@ -784,18 +789,22 @@ fn copy_file( // `linkat`, which resolves an absolute path from the root of the filesystem // and ignores the anchor descriptor entirely. let (dev, ino, nlink) = (metadata.dev(), metadata.ino(), metadata.nlink() as u32); - if let Some(link_target) = link_tracker.lookup(dev, ino, nlink) { + if let Some((link_target, copy)) = link_tracker.lookup(dev, ino, nlink) { let Some(target) = MemberPath::parse(&link_target)? else { - return do_copy_file(entry, dirfd, name, metadata, options); + return do_copy_file(entry, dirfd, name, metadata, options).map(|_| ()); }; let target_dir = tree.parent_of(&target, false)?; // Resolved one component at a time from the destination anchor, the // same way the file itself was created. A name that already is that // copy -- this very name visited again, from the list and from the // walk -- is left alone rather than unlinked out from under itself. - link_replacing( + // The link is to the copy made, pinned: someone who can write the + // copy's directory can have put another file at its name since. + link_replacing_with( target_dir.as_raw_fd(), &target.leaf, + false, + copy, dirfd, name, options.no_clobber, @@ -803,9 +812,9 @@ fn copy_file( return Ok(()); } - do_copy_file(entry, dirfd, name, metadata, options)?; + let copy = do_copy_file(entry, dirfd, name, metadata, options)?; // Only a copy that exists can be linked to by the file's later names. - link_tracker.record(dev, ino, nlink, member); + link_tracker.record(dev, ino, nlink, (member.to_path_buf(), copy)); Ok(()) } @@ -816,14 +825,15 @@ fn is_file_at(dirfd: BorrowedFd<'_>, name: &CStr, metadata: &ftw::Metadata) -> b .is_some_and(|st| file_id(&st) == (metadata.dev(), metadata.ino())) } -/// Actually copy file contents +/// Actually copy file contents, returning the identity of the copy made, from +/// its descriptor; `None` where -k kept a file already at the name. fn do_copy_file( entry: &ftw::Entry<'_>, dirfd: BorrowedFd<'_>, name: &CStr, metadata: &ftw::Metadata, options: &CopyOptions, -) -> PaxResult<()> { +) -> PaxResult> { // From the descriptor of the directory the walk found it in, and re-checked // against the (dev, ino) the walk saw, rather than re-resolving the whole // source path. Whether the walk dereferenced this entry is observable from @@ -860,8 +870,9 @@ fn do_copy_file( let Some(mut dest_file) = opened else { debug_assert!(!created); - return Ok(()); + return Ok(None); }; + let copy = file_id(&fstat(dest_file.as_raw_fd())?); copy_contents(&mut src_file, &mut dest_file, metadata.size())?; if options.reset_atime { @@ -871,7 +882,7 @@ fn do_copy_file( set_attrs_fd(dest_file.as_fd(), &attrs_of(metadata), &policy_of(options))?; // A filesystem that defers writes reports their failure on close. crate::blocked_io::close_file(dest_file)?; - Ok(()) + Ok(Some(copy)) } /// The largest buffer `copy_contents` reads through. @@ -1001,6 +1012,42 @@ mod tests { use plib::tmp::TempDir; use std::fs; + /// A later name of a file already copied is linked to that copy, found by + /// the name the copy was made at. In a destination directory others can + /// write, someone can rename their own file over that name first, and the + /// later name -- in a directory only pax's user writes -- became a link + /// to theirs. The link is made to the copy itself, or not at all. + #[cfg(target_os = "linux")] + #[test] + fn test_copy_links_later_names_only_to_the_copy_made() { + use crate::modes::race_hook::{with_hook, Point}; + let src = TempDir::new().unwrap(); + let dest = TempDir::new().unwrap(); + fs::create_dir(src.path().join("pub")).unwrap(); + fs::create_dir(src.path().join("priv")).unwrap(); + fs::write(src.path().join("pub/f"), "source\n").unwrap(); + fs::hard_link(src.path().join("pub/f"), src.path().join("priv/g")).unwrap(); + let f = src.path().join("pub/f"); + let g = src.path().join("priv/g"); + let copied = |path: &std::path::Path| dest.path().join(member_name(path)); + + let (copy_of_f, planted) = (copied(&f), dest.path().join("planted")); + let swap = move |point, _: libc::c_int, name: &CStr| { + if point == Point::Linking && name == c"f" { + fs::write(&planted, "planted\n").unwrap(); + fs::rename(&planted, ©_of_f).unwrap(); + } + }; + let options = CopyOptions::default(); + let mut operands = [f.clone(), g.clone()].into_iter(); + let _ = with_hook(swap, || copy_files(&mut operands, dest.path(), &options)); + let later = fs::read_to_string(copied(&g)).unwrap_or_default(); + assert_ne!( + later, "planted\n", + "the later name was linked to the planted file" + ); + } + #[test] fn test_copy_file() { let src_dir = TempDir::new().unwrap(); diff --git a/pax/modes/read.rs b/pax/modes/read.rs index c78be2447..0b215aeb8 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -14,9 +14,8 @@ use crate::error::{PaxError, PaxResult}; use crate::formats::OptionRecords; use crate::interactive::{InteractivePrompter, RenameResult}; use crate::modes::anchored::{ - attrs_withheld, create_replacing, link_replacing, link_replacing_with, make_dir_at, - set_attrs_fd, set_made_node_attrs, unlink_at, AttrPolicy, Attrs, DirAttrs, DirTree, MemberPath, - PendingDirs, + attrs_withheld, create_replacing, link_replacing_with, make_dir_at, set_attrs_fd, + set_made_node_attrs, unlink_at, AttrPolicy, Attrs, DirAttrs, DirTree, MemberPath, PendingDirs, }; use crate::modes::select::Selector; use crate::pattern::Pattern; @@ -127,6 +126,7 @@ fn extract_members( pending_dirs: &mut PendingDirs, ) -> PaxResult<()> { let mut link_sets: LinkSets = LinkSets::default(); + let mut made_files = MadeFiles::new(); let mut pins = PinBudget::new(); let mut selector = Selector::new( &options.patterns, @@ -158,7 +158,15 @@ fn extract_members( // unconsumed data of the failed entry to realign the reader. // A failure every later member would meet too -- -O's output // gone, end of file on the terminal -- ends the run instead. - let r = extract_entry(archive, &entry, options, &mut link_sets, tree, pending_dirs); + let r = extract_entry( + archive, + &entry, + options, + &mut link_sets, + &mut made_files, + tree, + pending_dirs, + ); report_unless_fatal(&entry, r)?; } else if let Some(set) = link_sets.find_mut(&entry) { let r = fill_link_set(archive, tree, &entry, options, set); @@ -325,6 +333,7 @@ fn extract_entry( entry: &ArchiveEntry, options: &ReadOptions, link_sets: &mut LinkSets, + made_files: &mut MadeFiles, tree: &DirTree, pending_dirs: &mut PendingDirs, ) -> PaxResult<()> { @@ -367,6 +376,9 @@ fn extract_entry( let _ = std::io::Write::write_all(&mut std::io::stderr().lock(), &line); } + // Whatever this member puts at its name replaces what an earlier one made + // there; only a regular file or a link to one records it again. + made_files.remove(&member.key()); match entry.entry_type { EntryType::Directory => { let decided = extract_directory(tree, pfd, &member, entry, options)?; @@ -384,11 +396,14 @@ fn extract_entry( archive.skip_data()?; } EntryType::Hardlink => { - extract_hardlink(tree, pfd, name, entry, options)?; + extract_hardlink(tree, pfd, &member, entry, options, made_files)?; archive.skip_data()?; } EntryType::Regular => { - extract_regular(archive, tree, pfd, &member, entry, options, link_sets)?; + let made = extract_regular(archive, tree, pfd, &member, entry, options, link_sets)?; + if let Some(file) = made { + made_files.insert(member.key(), file); + } archive.skip_data()?; // Skip padding to block boundary } EntryType::BlockDevice | EntryType::CharDevice => { @@ -490,14 +505,21 @@ fn extract_symlink( Ok(()) } +/// What this run created at each member path (`MemberPath::key`): the +/// identity of the regular file it made there, from the descriptor that made +/// it. +type MadeFiles = std::collections::HashMap, (u64, u64)>; + /// Extract a hard link fn extract_hardlink( tree: &DirTree, dirfd: BorrowedFd<'_>, - name: &CStr, + member: &MemberPath, entry: &ArchiveEntry, options: &ReadOptions, + made_files: &mut MadeFiles, ) -> PaxResult<()> { + let name = member.leaf.as_c_str(); let Some(target) = entry.link_target.clone() else { return Err(PaxError::InvalidHeader( "hard link target not found".to_string(), @@ -518,13 +540,25 @@ fn extract_hardlink( // earlier member of this very archive -- could link a file from outside // the extraction directory into it. A member linked to its own name, or // to a name it already shares, finds the file in place and keeps it. - link_replacing( + // + // To the file this run extracted at the target's name, pinned, when it + // made one: someone who can write that directory can have put another + // file at the name since. A target this run did not make is linked by + // name. + let expected = made_files.get(&target_member.key()).copied(); + link_replacing_with( target_parent.as_raw_fd(), &target_member.leaf, + false, + expected, dirfd, name, options.no_clobber, )?; + // This name holds that file now, for a later member linked to it. + if let Some(file) = expected { + made_files.insert(member.key(), file); + } Ok(()) } @@ -659,18 +693,20 @@ fn extract_regular( entry: &ArchiveEntry, options: &ReadOptions, link_sets: &mut LinkSets, -) -> PaxResult<()> { +) -> PaxResult> { if let Some(set) = link_sets.find_mut(entry) { - return join_link_set(archive, tree, dirfd, member, entry, options, set); + join_link_set(archive, tree, dirfd, member, entry, options, set)?; + return Ok(None); } - if let Some(file) = extract_file(archive, dirfd, member.leaf.as_c_str(), entry, options)? { + let made = extract_file(archive, dirfd, member.leaf.as_c_str(), entry, options)?; + if let Some(file) = made { link_sets.insert(entry, || { let names = vec![member.display.clone()]; CreatedSet::new(names, file, entry.size > 0, dirfd, &member.leaf) }); } - Ok(()) + Ok(made) } /// Extract a later name of a link set whose file is already on disk. @@ -1157,6 +1193,50 @@ mod tests { assert_eq!(std::fs::read(dir.path().join("old")).unwrap(), b""); } + /// A tar link member is linked to its target member, found by the name + /// the target was extracted at. In a directory others can write, someone + /// can rename their own file over that name first, and the link member + /// became a link to theirs. The link is made to the file extracted, or + /// not at all. + #[cfg(target_os = "linux")] + #[test] + fn test_hardlink_member_links_only_to_the_file_extracted() { + use crate::modes::race_hook::{with_hook, Point}; + let dir = plib::tmp::TempDir::new().unwrap(); + std::fs::write(dir.path().join("f"), "extracted\n").unwrap(); + let tree = DirTree::open_path(dir.path()).unwrap(); + let mut made_files = MadeFiles::new(); + let target = MemberPath::parse(Path::new("f")).unwrap().unwrap(); + made_files.insert(target.key(), id_at(tree.root(), c"f").unwrap()); + + let path = dir.path().to_path_buf(); + let swap = move |point, _: libc::c_int, name: &CStr| { + if point == Point::Linking && name == c"f" { + std::fs::write(path.join("planted"), "planted\n").unwrap(); + std::fs::rename(path.join("planted"), path.join("f")).unwrap(); + } + }; + let mut entry = ArchiveEntry::new(PathBuf::from("g"), EntryType::Hardlink); + entry.link_target = Some(PathBuf::from("f")); + let member = MemberPath::parse(Path::new("g")).unwrap().unwrap(); + let options = ReadOptions::default(); + let _ = with_hook(swap, || { + extract_hardlink( + &tree, + tree.root(), + &member, + &entry, + &options, + &mut made_files, + ) + }); + let linked = std::fs::read_to_string(dir.path().join("g")).unwrap_or_default(); + assert_ne!( + linked, "planted\n", + "the link member was linked to the planted file" + ); + } + #[test] fn test_owner_prefers_the_recorded_name_over_the_numeric_id() { let euid = unsafe { libc::geteuid() }; diff --git a/pax/modes/read/race_tests.rs b/pax/modes/read/race_tests.rs index dbef9d3e2..bfe5ba389 100644 --- a/pax/modes/read/race_tests.rs +++ b/pax/modes/read/race_tests.rs @@ -277,6 +277,7 @@ fn unverified_directory_member_is_diagnosed() { let tree = DirTree::open_path(tmp.path()).unwrap(); let mut pending = PendingDirs::default(); let mut link_sets = LinkSets::default(); + let mut made_files = super::MadeFiles::new(); let entry = own_member("d", EntryType::Directory, 0o751); let mut archive = Members(Vec::new().into_iter()); let options = preserve_everything(); @@ -287,6 +288,7 @@ fn unverified_directory_member_is_diagnosed() { &entry, &options, &mut link_sets, + &mut made_files, &tree, pending, ) @@ -311,6 +313,7 @@ fn unverified_directory_named_twice_is_withheld_both_times() { let tree = DirTree::open_path(tmp.path()).unwrap(); let mut pending = PendingDirs::default(); let mut link_sets = LinkSets::default(); + let mut made_files = super::MadeFiles::new(); let entry = own_member("d", EntryType::Directory, 0o751); let mut archive = Members(Vec::new().into_iter()); let options = preserve_everything(); @@ -322,6 +325,7 @@ fn unverified_directory_named_twice_is_withheld_both_times() { &entry, &options, &mut link_sets, + &mut made_files, &tree, pending, ) diff --git a/pax/modes/write.rs b/pax/modes/write.rs index ce0a61a93..914700d76 100644 --- a/pax/modes/write.rs +++ b/pax/modes/write.rs @@ -721,7 +721,7 @@ fn write_file( archive.finish_entry()?; // Only now is there a member for a later name of this file to link to. if !later_name { - link_tracker.record(entry.dev, entry.ino, entry.nlink, &entry.path); + link_tracker.record(entry.dev, entry.ino, entry.nlink, entry.path.clone()); } if options.reset_atime { From 02a1d483b21b22bb0c18a129ba18a4dbe22d7604 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 21:31:16 +0000 Subject: [PATCH 54/71] ftw, pax: recognise a followed link by the identity the walk saw, not its name Under -H/-L, copy mode's -l links the file a symbolic link refers to, and the link itself counts as the source too: a destination that already is that link is left alone (pax -rwl -H link .). link_replacing_with recognised it by resolving the source name again with fstatat (AT_SYMLINK_NOFOLLOW), and copy mode's is_source did the same. Once the link's target is pinned, someone who can rename in the source directory can point that name at the destination's file, and the member was skipped as already linked. ftw now keeps the identity of each symbolic link from its own lstat of it, the link it examined, on the entry (Entry::symlink_id; carried with directories it descends through like is_symlink). Nothing about the walk changes: it only exposes what the walk already read. link_replacing_with's `follow` is now that identity, Some when the link is to be followed, and it is what the destination is compared with; is_source compares with it too. A followed link the walk gave no identity for fails the member rather than be resolved by name. Test: a followed link whose source name, once the target is pinned, is made a hard link to the destination's file: the destination must end up holding the link's target (before: skipped as already the link itself). Co-Authored-By: Claude Opus 5.5 --- ftw/src/lib.rs | 25 +++++++++++++++ pax/modes/anchored.rs | 71 +++++++++++++++++++++++++++++++++++-------- pax/modes/copy.rs | 22 +++++++++----- pax/modes/read.rs | 6 ++-- 4 files changed, 101 insertions(+), 23 deletions(-) diff --git a/ftw/src/lib.rs b/ftw/src/lib.rs index 97112e9df..931e630d5 100644 --- a/ftw/src/lib.rs +++ b/ftw/src/lib.rs @@ -379,6 +379,8 @@ struct TreeNode { metadata: Metadata, /// Whether the directory entry is itself a symbolic link (one the walk followed). is_symlink: Option, + /// That link's own identity (`Entry::symlink_id`). + symlink_id: Option<(u64, u64)>, path_depth: usize, } @@ -397,6 +399,7 @@ impl TreeNode { ) .with_shown_name(self.shown_name.clone()); entry.is_symlink = self.is_symlink; + entry.symlink_id = self.symlink_id; entry } @@ -420,6 +423,9 @@ pub struct Entry<'a> { shown_name: Option>, metadata: Option, is_symlink: Option, + /// `(st_dev, st_ino)` of the entry itself when it is a symbolic link, from the walk's own + /// `lstat` of it. + symlink_id: Option<(u64, u64)>, read_link: Option>, } @@ -437,6 +443,7 @@ impl<'a> Entry<'a> { shown_name: None, metadata, is_symlink: None, + symlink_id: None, read_link: None, } } @@ -470,6 +477,15 @@ impl<'a> Entry<'a> { self.is_symlink } + /// `(st_dev, st_ino)` of the entry itself when it is a symbolic link -- set exactly when + /// `is_symlink` is `Some(true)` -- from the `lstat` the walk made of it, the link it examined. + /// When the walk follows the link, `metadata` is its target's, and this is the only record of + /// the link: a caller asking whether a name is still that link compares against it rather + /// than resolving the name again. + pub fn symlink_id(&self) -> Option<(u64, u64)> { + self.symlink_id + } + /// Reads the symbolic link. pub fn read_link(&self) -> Option<&CStr> { self.read_link @@ -630,6 +646,10 @@ where } }; let is_symlink = entry_symlink_metadata.file_type() == FileType::SymbolicLink; + let symlink_id = is_symlink.then(|| { + use std::os::unix::fs::MetadataExt; + (entry_symlink_metadata.dev(), entry_symlink_metadata.ino()) + }); // Read the link target for every symbolic link, whether or not this walk follows links: // consumers need it either way -- `cp -P` and `mv` recreate the link from it, `ls -l` prints @@ -686,6 +706,7 @@ where let mut entry = Entry::new(dir_fd, path_stack, entry_filename, Some(entry_metadata)) .with_shown_name(shown_name.cloned()); entry.is_symlink = Some(is_symlink); + entry.symlink_id = symlink_id; entry.read_link = entry_readlink; if must_be_dir { @@ -1221,6 +1242,7 @@ where filename: dir_filename, shown_name, is_symlink: entry.is_symlink, + symlink_id: entry.symlink_id, metadata: entry.metadata.unwrap(), path_depth: path_stack.len(), }; @@ -1481,6 +1503,7 @@ where filename: entry_filename, shown_name: None, is_symlink: entry.is_symlink, + symlink_id: entry.symlink_id, metadata: entry.metadata.unwrap(), path_depth, } @@ -1498,6 +1521,7 @@ where filename: entry_filename, shown_name: None, is_symlink: entry.is_symlink, + symlink_id: entry.symlink_id, metadata: entry.metadata.unwrap(), path_depth, } @@ -1532,6 +1556,7 @@ where filename: entry_filename, shown_name: None, is_symlink: entry.is_symlink, + symlink_id: entry.symlink_id, metadata: entry.metadata.unwrap(), path_depth, } diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 9aa25d6ec..f88cb0638 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -1245,7 +1245,9 @@ fn created(dirfd: BorrowedFd<'_>, name: &CStr) -> bool { /// a symbolic link refers to. With `follow` the link itself counts too, being /// just as much the source (`pax -rwl -H link .`). /// -/// With `follow`, the file a symbolic link `from_name` refers to is linked -- +/// `follow` is the `(st_dev, st_ino)` of the symbolic link `from_name` the +/// caller examined, when the link is to be followed (the walk's own `lstat`, +/// `ftw::Entry::symlink_id`); the file it refers to is then linked -- /// copy mode's `-l` under `-H`/`-L`, where POSIX says "the hard link created /// ... shall be to the file referenced by the symbolic link". Without it, /// `from_name` itself is linked, whatever it is. @@ -1263,7 +1265,7 @@ fn created(dirfd: BorrowedFd<'_>, name: &CStr) -> bool { pub(crate) fn link_replacing_with( from_dir: libc::c_int, from_name: &CStr, - follow: bool, + follow: Option<(u64, u64)>, expected: Option<(u64, u64)>, dirfd: BorrowedFd<'_>, name: &CStr, @@ -1271,7 +1273,7 @@ pub(crate) fn link_replacing_with( ) -> PaxResult { #[cfg(test)] crate::modes::race_hook::reached(crate::modes::race_hook::Point::Linking, from_dir, from_name); - let source = LinkSource::new(from_dir, from_name, follow, expected)?; + let source = LinkSource::new(from_dir, from_name, follow.is_some(), expected)?; let link = || source.link_to(dirfd, name); match link() { @@ -1295,18 +1297,19 @@ pub(crate) fn link_replacing_with( // now, which would skip the member as linked to itself. Only a source // known by neither is resolved again. let resolved = source.identity().or_else(|| { - let src_flags = if follow { 0 } else { libc::AT_SYMLINK_NOFOLLOW }; + let src_flags = if follow.is_some() { + 0 + } else { + libc::AT_SYMLINK_NOFOLLOW + }; fstatat(from_dir, from_name, src_flags) .ok() .map(|st| file_id(&st)) }); // Followed, the link itself is the source just as much - // (`pax -rwl -H link .`). - let link = follow - .then(|| fstatat(from_dir, from_name, libc::AT_SYMLINK_NOFOLLOW).ok()) - .flatten() - .map(|st| file_id(&st)); - if [resolved, link] + // (`pax -rwl -H link .`): the link the caller examined, by the + // identity it saw, not by its name resolved again. + if [resolved, follow] .into_iter() .flatten() .any(|src| src == file_id(&dst)) @@ -2309,13 +2312,55 @@ mod tests { } }; let linked = with_hook(swap, || { - link_replacing_with(root, c"a", false, Some(source), tree.root(), c"d", false) + link_replacing_with(root, c"a", None, Some(source), tree.root(), c"d", false) }); assert!(!linked.unwrap(), "skipped as already linked to itself"); let d = lstat_at(tree.root().as_raw_fd(), c"d").unwrap(); assert_eq!(file_id(&d), source, "d does not hold the source"); } + /// Under -H/-L the symbolic link itself counts as the source too, so a + /// destination that already is that link is left alone. It was recognised + /// by resolving the link's name again, which someone who can rename in the + /// source directory can point at the destination's file once the link's + /// target is pinned: the member was then skipped. The link is the one the + /// walk examined, by its identity. + #[cfg(target_os = "linux")] + #[test] + fn test_link_replacing_judges_a_followed_link_by_the_walks_identity() { + use crate::modes::race_hook::{with_hook, Point}; + let dir = plib::tmp::TempDir::new().unwrap(); + std::fs::write(dir.path().join("t"), "target\n").unwrap(); + std::os::unix::fs::symlink("t", dir.path().join("l")).unwrap(); + std::fs::write(dir.path().join("d"), "other\n").unwrap(); + let tree = DirTree::open_path(dir.path()).unwrap(); + let root = tree.root().as_raw_fd(); + let id = |name: &CStr| file_id(&lstat_at(root, name).unwrap()); + let (link, target) = (id(c"l"), id(c"t")); + + let path = dir.path().to_path_buf(); + let swap = move |point, _: libc::c_int, _: &CStr| { + if point == Point::LinkExists { + // `l` now names the destination's file. + std::fs::remove_file(path.join("l")).unwrap(); + std::fs::hard_link(path.join("d"), path.join("l")).unwrap(); + } + }; + let linked = with_hook(swap, || { + link_replacing_with( + root, + c"l", + Some(link), + Some(target), + tree.root(), + c"d", + false, + ) + }); + assert!(!linked.unwrap(), "skipped as already the link itself"); + assert_eq!(id(c"d"), target, "d does not hold the link's target"); + } + /// -k leaves an existing directory entirely alone, and says nothing: one /// this run made with an owner it could not verify (`Standing::Unverified`, /// NFS or FUSE) too. Its attributes were withheld and diagnosed even under @@ -2597,7 +2642,7 @@ mod tests { let same = link_replacing_with( dir.root().as_raw_fd(), &f, - false, + None, None, dir.root(), &f, @@ -2624,7 +2669,7 @@ mod tests { let same = link_replacing_with( dir.root().as_raw_fd(), &f, - false, + None, None, dir.root(), &g, diff --git a/pax/modes/copy.rs b/pax/modes/copy.rs index 2c5fadbf3..92ee1ecbe 100644 --- a/pax/modes/copy.rs +++ b/pax/modes/copy.rs @@ -624,11 +624,16 @@ fn is_source(st: &libc::stat, entry: &ftw::Entry<'_>, metadata: &ftw::Metadata) if (st.st_mode & libc::S_IFMT) != libc::S_IFLNK || !followed_link(entry, metadata) { return false; } - // SAFETY: the walk keeps the entry's directory open while it is visited. - let dir = unsafe { BorrowedFd::borrow_raw(entry.dir_fd()) }; - lstat_at(dir.as_raw_fd(), entry.file_name()) - .ok() - .is_some_and(|link| file_id(&link) == id) + // The link the walk examined, by the identity it saw. + entry.symlink_id() == Some(id) +} + +/// The failure for a followed symbolic link the walk gave no identity for -- +/// which it gives every link it reports (`ftw::Entry::symlink_id`). +fn link_not_identified() -> PaxError { + PaxError::Io(std::io::Error::other( + "symbolic link not identified by the walk", + )) } /// Diagnose copying a file to its own name, as BSD pax words it, and skip it. @@ -756,10 +761,13 @@ fn copy_file( // `pax -rwl tree .` names every file as its own destination. Under // -H/-L the walk followed a symbolic link here, and the link made is // to the file it refers to, as POSIX requires of -l. + let follow = followed_link(entry, metadata) + .then(|| entry.symlink_id().ok_or_else(link_not_identified)) + .transpose()?; let linked = link_replacing_with( entry.dir_fd(), entry.file_name(), - followed_link(entry, metadata), + follow, Some((metadata.dev(), metadata.ino())), dirfd, name, @@ -803,7 +811,7 @@ fn copy_file( link_replacing_with( target_dir.as_raw_fd(), &target.leaf, - false, + None, copy, dirfd, name, diff --git a/pax/modes/read.rs b/pax/modes/read.rs index 0b215aeb8..5b2164526 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -549,7 +549,7 @@ fn extract_hardlink( link_replacing_with( target_parent.as_raw_fd(), &target_member.leaf, - false, + None, expected, dirfd, name, @@ -740,7 +740,7 @@ fn join_link_set( link_replacing_with( src_dir.as_raw_fd(), &src_leaf, - false, + None, Some(set.file), dirfd, name, @@ -820,7 +820,7 @@ fn move_names_to( // even under -k. The link is to the file made, pinned, never to // whatever has been put at its name since. let from = dirfd.as_raw_fd(); - link_replacing_with(from, name, false, Some(file), dir.as_fd(), &leaf, false)?; + link_replacing_with(from, name, None, Some(file), dir.as_fd(), &leaf, false)?; names.push(path); } Ok(names) From c51a0313cbd0573a7411239179d47917ff98a9e6 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 21:33:55 +0000 Subject: [PATCH 55/71] plib: keep only a verified /proc answer, retrying a failure that may pass d50ebd74 kept whatever the first procfs_dir call got for the rest of the process, failures included: a first call that met a full descriptor table (EMFILE, ENFILE) left every later pinned chmod, link and reopen failing, though descriptors were free again. Only a verified answer is kept now: the procfs descriptor, or a /proc opened and found not to be procfs. Any other failure is returned and the next call tries again. The doc comment also overclaimed: read_xattr still names /proc by path, since no *at form of getxattr takes the held descriptor; it now says so. And the test d50ebd74 added had been placed between link_times_by_name_reach_only_the_pinned_link and its doc comment; the comment is back on its test. Test: in a child process of its own, so its first call is the process's first, procfs_dir under a full descriptor table fails with EMFILE, and succeeds once descriptors are free (before: the EMFILE was kept). Co-Authored-By: Claude Opus 5.5 --- plib/src/madefs.rs | 98 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 19 deletions(-) diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index fd91c6625..97303457d 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -1179,27 +1179,46 @@ pub fn chmod_pinned(fd: RawFd, mode: libc::mode_t) -> io::Result<()> { /// `/proc`, opened and verified to be procfs (`PROC_SUPER_MAGIC`), so that `self/fd/N` names /// exactly the inode open on descriptor N rather than whatever else is mounted or planted there. /// -/// Opened and verified once per process, on the first call, and held open from then on: every -/// later call hands out the same descriptor, or the same failure. A pinned chmod, link or -/// reopen used to open `/proc` by name and `fstatfs` it every time, a pair of system calls per -/// file. Holding the verified descriptor is also safer than reopening by name: what is checked -/// is what every later lookup goes through, whatever is mounted at `/proc` since. `self` +/// Opened and verified on the first call that can, and held open from then on: every later +/// call hands out the same descriptor. A pinned chmod, link or reopen used to open `/proc` by +/// name and `fstatfs` it every time, a pair of system calls per file. Holding the verified +/// descriptor is also safer than reopening by name: a lookup made relative to it goes through +/// what was checked, whatever is mounted at `/proc` since. (`read_xattr` is the exception: no +/// `*at` form of `getxattr` takes the descriptor, so it still names `/proc` by path.) `self` /// resolves to whichever process looks it up, so a child forked after the first call that uses /// the descriptor still reaches its own `fd/N`. It is `O_CLOEXEC`, so nothing exec'd inherits /// it. +/// +/// Only a verified answer is kept: the procfs descriptor, or a `/proc` verified to be something +/// else. A failure that may pass -- a descriptor table full (EMFILE, ENFILE), `/proc` not +/// mounted yet -- is returned, and the next call tries again. #[cfg(target_os = "linux")] pub fn procfs_dir() -> io::Result> { - static PROC: OnceLock, String)>> = OnceLock::new(); - match PROC.get_or_init(|| open_procfs().map_err(|e| (e.raw_os_error(), e.to_string()))) { - Ok(fd) => Ok(fd.as_fd()), - Err((Some(errno), _)) => Err(io::Error::from_raw_os_error(*errno)), - Err((None, message)) => Err(io::Error::other(message.clone())), - } + // `None`: `/proc` was opened, and is not procfs. + static PROC: OnceLock> = OnceLock::new(); + let verified = match PROC.get() { + Some(verified) => verified, + None => { + let opened = open_procfs()?; + PROC.get_or_init(|| opened) + } + }; + verified + .as_ref() + .map(|fd| fd.as_fd()) + .ok_or_else(not_procfs) +} + +/// The failure for a `/proc` that is not procfs. +#[cfg(target_os = "linux")] +fn not_procfs() -> io::Error { + io::Error::other(gettext("/proc is not a procfs mount")) } -/// Open `/proc` and verify it is procfs (`procfs_dir`). +/// Open `/proc` and verify it is procfs (`procfs_dir`): the descriptor, or `None` for a +/// `/proc` that is something else. #[cfg(target_os = "linux")] -fn open_procfs() -> io::Result { +fn open_procfs() -> io::Result> { const PROC_SUPER_MAGIC: u32 = 0x9fa0; let fd = unsafe { libc::open( @@ -1216,10 +1235,8 @@ fn open_procfs() -> io::Result { return Err(io::Error::last_os_error()); } // `f_type` is a signed word whose width varies by architecture; the magic is its low bits. - if unsafe { st.assume_init() }.f_type as u32 != PROC_SUPER_MAGIC { - return Err(io::Error::other(gettext("/proc is not a procfs mount"))); - } - Ok(dir) + let is_procfs = unsafe { st.assume_init() }.f_type as u32 == PROC_SUPER_MAGIC; + Ok(is_procfs.then_some(dir)) } /// `self/fd/N`, relative to `procfs_dir`. @@ -2156,8 +2173,6 @@ mod tests { assert_eq!(made_by_us(dir(US, 3), Some(US), US), None); } - /// A made symbolic link's times by name reach only the pinned link, never a hard link to - /// another file swapped in for it. /// `/proc` is opened and verified once per process: every call hands out /// the same descriptor, which stays open and procfs. #[cfg(target_os = "linux")] @@ -2176,6 +2191,51 @@ mod tests { assert_eq!(unsafe { st.assume_init() }.f_type as u32, 0x9fa0); } + /// A failure to open `/proc` that may pass -- the descriptor table full + /// (EMFILE, ENFILE) -- is not kept for the rest of the process: a later + /// call tries again. Run in a child process of its own, so that its first + /// call is the process's first. + #[cfg(target_os = "linux")] + #[test] + fn procfs_dir_retries_a_transient_failure() { + const CHILD: &str = "PLIB_PROCFS_RETRY_CHILD"; + if std::env::var_os(CHILD).is_none() { + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "madefs::tests::procfs_dir_retries_a_transient_failure", + "--exact", + "--nocapture", + "--test-threads=1", + ]) + .env(CHILD, "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + out.status.success() && stdout.contains("1 passed"), + "child: {stdout}{}", + String::from_utf8_lossy(&out.stderr) + ); + return; + } + // Fill the descriptor table under a low limit. + let lim = libc::rlimit { + rlim_cur: 64, + rlim_max: 64, + }; + assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &lim) }, 0); + let mut filler = Vec::new(); + while let Ok(f) = std::fs::File::open("/dev/null") { + filler.push(f); + } + let err = super::procfs_dir().unwrap_err(); + assert_eq!(err.raw_os_error(), Some(libc::EMFILE)); + drop(filler); + assert!(super::procfs_dir().is_ok(), "the first failure was kept"); + } + + /// A made symbolic link's times by name reach only the pinned link, never a hard link to + /// another file swapped in for it. #[test] fn link_times_by_name_reach_only_the_pinned_link() { use std::os::fd::AsRawFd; From 294d55052d42f9b4759bf33f01689d59f2a14b91 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 21:51:04 +0000 Subject: [PATCH 56/71] pax: hold each file made for later names by a pin, and check ctime past the budget d17adf77 recorded a file this run made only by its (st_dev, st_ino), after the descriptor that made it was closed. In a directory others can write, someone could remove the file and make theirs at its name, and ext4 can hand the freed inode number straight back: the later link member, or the later name of a copied file, pinned the name, found the recorded number, and linked their file. A file this run makes is now a MadeFile (new pax/modes/pins.rs): its identity and the ctime it was left with, and an O_PATH pin reopened from the creating descriptor through the verified procfs -- the inode itself, never a name -- which keeps its number from being reused. A later name is linked through that pin directly (LinkSource links a caller's pin without opening the name at all). Pins are held within one budget, a quarter of RLIMIT_NOFILE and at most 256: the PinBudget read mode already kept for cpio link sets, now generic over its key and shared. Read mode keeps link sets and made files in one Links struct under one budget; copy mode keeps its copies' pins under its own. Past the budget, or without a procfs, a file is known by identity and ctime, and a source opened or checked by name must show both. ctime is refreshed when an unpinned file takes another link, and taken from the pin when the pin is closed. The residual of that fallback: the kernel stamps ctime from a coarse clock, so a file made again within the same tick (a few milliseconds) is not told apart. The pins cost read mode about 15% on tiny files: extracting 50,000 one-byte files went from 0.51 s to 0.59 s (three runs each). Tests: a pinned file is linked through its pin though another file was renamed over its name (before: refused, "source file changed"); an unpinned file whose number was "reused" -- the record given the planted file's number -- is refused on ctime (before: the planted file was linked); the budget closes the oldest pin. Co-Authored-By: Claude Opus 5.5 --- pax/archive.rs | 24 ++- pax/modes/anchored.rs | 125 ++++++++++++--- pax/modes/copy.rs | 66 ++++++-- pax/modes/mod.rs | 1 + pax/modes/pins.rs | 297 +++++++++++++++++++++++++++++++++++ pax/modes/read.rs | 272 +++++++++++++++++--------------- pax/modes/read/race_tests.rs | 28 +--- pax/modes/write.rs | 4 +- 8 files changed, 620 insertions(+), 197 deletions(-) create mode 100644 pax/modes/pins.rs diff --git a/pax/archive.rs b/pax/archive.rs index 6f5b00dea..8ed2bbbd8 100644 --- a/pax/archive.rs +++ b/pax/archive.rs @@ -392,7 +392,7 @@ impl Default for HardLinkTracker { } } -impl HardLinkTracker { +impl HardLinkTracker { /// Create a new tracker pub fn new() -> Self { Self::default() @@ -400,11 +400,24 @@ impl HardLinkTracker { /// What was remembered of the name a multiply-linked file was first /// stored under, if one of its names already has been. - pub fn lookup(&self, dev: u64, ino: u64, nlink: u32) -> Option { + pub fn lookup(&self, dev: u64, ino: u64, nlink: u32) -> Option<&T> { if nlink <= 1 { return None; } - self.stored.get(&(dev, ino)).cloned() + self.stored.get(&(dev, ino)) + } + + /// `lookup`, to change what was remembered. + pub fn lookup_mut(&mut self, dev: u64, ino: u64, nlink: u32) -> Option<&mut T> { + if nlink <= 1 { + return None; + } + self.stored.get_mut(&(dev, ino)) + } + + /// What was remembered of the file `(dev, ino)`. + pub fn by_key_mut(&mut self, key: (u64, u64)) -> Option<&mut T> { + self.stored.get_mut(&key) } /// Note that a file's first name has been stored, and what to remember of @@ -688,7 +701,10 @@ mod tests { // remembered however many of its names go by. links.record(1, 9, 2, PathBuf::from("b")); for _ in 0..3 { - assert_eq!(links.lookup(1, 9, 2).as_deref(), Some(Path::new("a"))); + assert_eq!( + links.lookup(1, 9, 2).map(PathBuf::as_path), + Some(Path::new("a")) + ); } } } diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index f88cb0638..f149fc100 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -1231,6 +1231,39 @@ fn created(dirfd: BorrowedFd<'_>, name: &CStr) -> bool { true } +/// What a caller knows of the file it means to link (`link_replacing_with`): +/// its `(st_dev, st_ino)`; for a file this run made, the `ctime` it was left +/// with, which an inode number reused for someone else's file does not have; +/// and a descriptor pinning the file itself, where one is held. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Expected<'a> { + pub(crate) id: (u64, u64), + pub(crate) ctime: Option<(i64, i64)>, + pub(crate) pin: Option>, +} + +impl Expected<'_> { + /// Whether `st` shows the file meant: its identity, and its ctime where + /// that is known. + fn matches(&self, st: &libc::stat) -> bool { + file_id(st) == self.id + && self + .ctime + .is_none_or(|ctime| ctime == crate::modes::pins::ctime_of(st)) + } +} + +impl Expected<'static> { + /// Only the identity the caller examined. + pub(crate) fn id(id: (u64, u64)) -> Self { + Expected { + id, + ctime: None, + pin: None, + } + } +} + /// Hard-link `from_name` (in `from_dir`) to `name` (in `dirfd`), replacing /// whatever holds `name` the way `create_replacing` does -- unless it already /// *is* the file being linked. @@ -1266,7 +1299,7 @@ pub(crate) fn link_replacing_with( from_dir: libc::c_int, from_name: &CStr, follow: Option<(u64, u64)>, - expected: Option<(u64, u64)>, + expected: Option>, dirfd: BorrowedFd<'_>, name: &CStr, no_clobber: bool, @@ -1332,7 +1365,7 @@ enum LinkSource<'a> { #[cfg(target_os = "linux")] Pinned { proc_dir: BorrowedFd<'static>, - pin: OwnedFd, + pin: PinFd<'a>, }, /// A name, resolved again by `linkat`: `flags` is `AT_SYMLINK_FOLLOW` or /// 0, and `expected` what the link made is checked against afterwards. @@ -1345,13 +1378,15 @@ enum LinkSource<'a> { } impl<'a> LinkSource<'a> { - /// `from_name` in `from_dir`, followed if `follow`: pinned and checked to - /// be `expected` where that is given and the platform allows. + /// `from_name` in `from_dir`, followed if `follow`, as `expected` knows + /// it: the caller's own pin of it, where it holds one and the platform + /// can link through it; otherwise pinned here, by name, and checked to be + /// that file; otherwise the name, checked before and after the link. fn new( from_dir: libc::c_int, from_name: &'a CStr, follow: bool, - expected: Option<(u64, u64)>, + expected: Option>, ) -> PaxResult { #[cfg(target_os = "linux")] if let Some(expected) = expected { @@ -1359,39 +1394,58 @@ impl<'a> LinkSource<'a> { return Ok(pinned); } } + let src_flags = if follow { 0 } else { libc::AT_SYMLINK_NOFOLLOW }; + if let Some(expected) = expected.filter(|e| e.ctime.is_some()) { + // The residual without procfs: a file removed and made again + // with this number and ctime between this check and the link. + let st = fstatat(from_dir, from_name, src_flags).map_err(|_| source_changed())?; + if !expected.matches(&st) { + return Err(source_changed()); + } + } let flags = if follow { libc::AT_SYMLINK_FOLLOW } else { 0 }; Ok(LinkSource::Name { from_dir, from_name, flags, - expected, + expected: expected.map(|e| e.id), }) } - /// Pin the source with `O_PATH` -- following a symbolic link exactly - /// when `linkat` would -- and require it to be the file `expected`. - /// `None` without a verified procfs, where the pin could not be linked. + /// The source as a descriptor to link through: the caller's pin, or one + /// opened here with `O_PATH` -- following a symbolic link exactly when + /// `linkat` would -- and required to be the file `expected`. `None` + /// without a verified procfs, where no descriptor can be linked. #[cfg(target_os = "linux")] fn pin( from_dir: libc::c_int, from_name: &CStr, follow: bool, - expected: (u64, u64), + expected: Expected<'a>, ) -> PaxResult> { let Ok(proc_dir) = made::procfs_dir() else { return Ok(None); }; - let nofollow = if follow { 0 } else { libc::O_NOFOLLOW }; - let flags = libc::O_PATH | libc::O_CLOEXEC | nofollow; - let fd = unsafe { libc::openat(from_dir, from_name.as_ptr(), flags) }; - if fd < 0 { - return Err(std::io::Error::last_os_error().into()); - } - let pin = unsafe { OwnedFd::from_raw_fd(fd) }; - if !fstat(pin.as_raw_fd()) - .ok() - .is_some_and(|st| file_id(&st) == expected) - { + let pin = match expected.pin { + Some(pin) => PinFd::Borrowed(pin), + None => { + let nofollow = if follow { 0 } else { libc::O_NOFOLLOW }; + let flags = libc::O_PATH | libc::O_CLOEXEC | nofollow; + let fd = unsafe { libc::openat(from_dir, from_name.as_ptr(), flags) }; + if fd < 0 { + return Err(std::io::Error::last_os_error().into()); + } + PinFd::Owned(unsafe { OwnedFd::from_raw_fd(fd) }) + } + }; + // A pin the caller holds is the file itself; one opened by name must + // show the identity, and the ctime where known, of the file meant. + let st = fstat(pin.as_raw_fd()).map_err(|_| source_changed())?; + let is_expected = match pin { + PinFd::Borrowed(_) => file_id(&st) == expected.id, + PinFd::Owned(_) => expected.matches(&st), + }; + if !is_expected { return Err(source_changed()); } Ok(Some(LinkSource::Pinned { proc_dir, pin })) @@ -1442,6 +1496,23 @@ impl<'a> LinkSource<'a> { } } +/// A descriptor `LinkSource` links through: opened by it, or the caller's. +#[cfg(target_os = "linux")] +enum PinFd<'a> { + Owned(OwnedFd), + Borrowed(BorrowedFd<'a>), +} + +#[cfg(target_os = "linux")] +impl AsRawFd for PinFd<'_> { + fn as_raw_fd(&self) -> libc::c_int { + match self { + PinFd::Owned(fd) => fd.as_raw_fd(), + PinFd::Borrowed(fd) => fd.as_raw_fd(), + } + } +} + /// The failure for a source that is no longer the file the walk examined. fn source_changed() -> PaxError { PaxError::Io(std::io::Error::other( @@ -2312,7 +2383,15 @@ mod tests { } }; let linked = with_hook(swap, || { - link_replacing_with(root, c"a", None, Some(source), tree.root(), c"d", false) + link_replacing_with( + root, + c"a", + None, + Some(Expected::id(source)), + tree.root(), + c"d", + false, + ) }); assert!(!linked.unwrap(), "skipped as already linked to itself"); let d = lstat_at(tree.root().as_raw_fd(), c"d").unwrap(); @@ -2351,7 +2430,7 @@ mod tests { root, c"l", Some(link), - Some(target), + Some(Expected::id(target)), tree.root(), c"d", false, diff --git a/pax/modes/copy.rs b/pax/modes/copy.rs index 92ee1ecbe..80e85d778 100644 --- a/pax/modes/copy.rs +++ b/pax/modes/copy.rs @@ -19,12 +19,13 @@ use crate::interactive::{InteractivePrompter, RenameResult}; use crate::modes::anchored::{ attrs_withheld, create_replacing, file_id, link_replacing_with, make_dir_at, restore_atime, restore_dir_atime, set_attrs_fd, set_made_node_attrs, AttrPolicy, Attrs, DirAttrs, DirTree, - MemberPath, PendingDirs, + Expected, MemberPath, PendingDirs, }; use crate::modes::followed_link; +use crate::modes::pins::{MadeFile, PinBudget}; use crate::modes::write::FileNames; use crate::subst::{substitute_name, Substitution}; -use plib::madefs::{fstat, lstat_at}; +use plib::madefs::lstat_at; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::ffi::{CStr, CString}; @@ -171,9 +172,41 @@ impl CopyWalk<'_> { } /// The first copy of each multiply-linked file: the member path it was made -/// at, and the identity of the copy made there -- `None` where -k kept a file -/// already at that name. -type CopiedLinks = HardLinkTracker<(PathBuf, Option<(u64, u64)>)>; +/// at, and the copy made there (`MadeFile`, pinned within the budget) -- +/// `None` where -k kept a file already at that name. +struct CopiedLinks { + names: HardLinkTracker<(PathBuf, Option)>, + pins: PinBudget<(u64, u64)>, +} + +impl CopiedLinks { + fn new() -> Self { + CopiedLinks { + names: HardLinkTracker::new(), + pins: PinBudget::new(), + } + } + + /// Record the first copy of the source file `(dev, ino)`, `made` at + /// `member`, within the budget of pins. + fn record( + &mut self, + (dev, ino): (u64, u64), + nlink: u32, + member: &Path, + made: Option, + ) { + let pinned = made.as_ref().is_some_and(MadeFile::is_pinned); + self.names + .record(dev, ino, nlink, (member.to_path_buf(), made)); + let names = &mut self.names; + self.pins.note((dev, ino), pinned, |key| { + if let Some((_, Some(made))) = names.by_key_mut(*key) { + made.unpin(); + } + }); + } +} /// State the three traversal callbacks share. /// @@ -768,7 +801,7 @@ fn copy_file( entry.dir_fd(), entry.file_name(), follow, - Some((metadata.dev(), metadata.ino())), + Some(Expected::id((metadata.dev(), metadata.ino()))), dirfd, name, options.no_clobber, @@ -797,8 +830,8 @@ fn copy_file( // `linkat`, which resolves an absolute path from the root of the filesystem // and ignores the anchor descriptor entirely. let (dev, ino, nlink) = (metadata.dev(), metadata.ino(), metadata.nlink() as u32); - if let Some((link_target, copy)) = link_tracker.lookup(dev, ino, nlink) { - let Some(target) = MemberPath::parse(&link_target)? else { + if let Some((link_target, copy)) = link_tracker.names.lookup_mut(dev, ino, nlink) { + let Some(target) = MemberPath::parse(link_target)? else { return do_copy_file(entry, dirfd, name, metadata, options).map(|_| ()); }; let target_dir = tree.parent_of(&target, false)?; @@ -812,17 +845,21 @@ fn copy_file( target_dir.as_raw_fd(), &target.leaf, None, - copy, + copy.as_ref().map(MadeFile::expected), dirfd, name, options.no_clobber, )?; + // Linking a name changed its ctime. + if let Some(copy) = copy { + copy.linked(dirfd, name); + } return Ok(()); } let copy = do_copy_file(entry, dirfd, name, metadata, options)?; // Only a copy that exists can be linked to by the file's later names. - link_tracker.record(dev, ino, nlink, (member.to_path_buf(), copy)); + link_tracker.record((dev, ino), nlink, member, copy); Ok(()) } @@ -833,15 +870,15 @@ fn is_file_at(dirfd: BorrowedFd<'_>, name: &CStr, metadata: &ftw::Metadata) -> b .is_some_and(|st| file_id(&st) == (metadata.dev(), metadata.ino())) } -/// Actually copy file contents, returning the identity of the copy made, from -/// its descriptor; `None` where -k kept a file already at the name. +/// Actually copy file contents, returning the copy made, known from its +/// descriptor (`MadeFile`); `None` where -k kept a file already at the name. fn do_copy_file( entry: &ftw::Entry<'_>, dirfd: BorrowedFd<'_>, name: &CStr, metadata: &ftw::Metadata, options: &CopyOptions, -) -> PaxResult> { +) -> PaxResult> { // From the descriptor of the directory the walk found it in, and re-checked // against the (dev, ino) the walk saw, rather than re-resolving the whole // source path. Whether the walk dereferenced this entry is observable from @@ -880,7 +917,6 @@ fn do_copy_file( debug_assert!(!created); return Ok(None); }; - let copy = file_id(&fstat(dest_file.as_raw_fd())?); copy_contents(&mut src_file, &mut dest_file, metadata.size())?; if options.reset_atime { @@ -888,6 +924,8 @@ fn do_copy_file( } set_attrs_fd(dest_file.as_fd(), &attrs_of(metadata), &policy_of(options))?; + // Known once its attributes are set, which change its ctime. + let copy = MadeFile::of(dest_file.as_fd())?; // A filesystem that defers writes reports their failure on close. crate::blocked_io::close_file(dest_file)?; Ok(Some(copy)) diff --git a/pax/modes/mod.rs b/pax/modes/mod.rs index ebdcf1871..1e8a9476b 100644 --- a/pax/modes/mod.rs +++ b/pax/modes/mod.rs @@ -14,6 +14,7 @@ pub mod append; pub mod copy; pub mod list; pub(crate) mod made; +pub(crate) mod pins; #[cfg(test)] pub(crate) mod race_hook; pub mod read; diff --git a/pax/modes/pins.rs b/pax/modes/pins.rs new file mode 100644 index 000000000..2f08492b9 --- /dev/null +++ b/pax/modes/pins.rs @@ -0,0 +1,297 @@ +// +// Copyright (c) 2026 Jeff Garzik +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +//! Files this run made, held so that a later name is linked to that very +//! file, and the budget of descriptors held for them. +//! +//! A later name of a file -- a tar link member, a later name of a copied +//! hard-linked file -- is linked to the file made for an earlier one, found +//! at the name it was made at. Anyone who can write that directory can remove +//! it and make another file there, and a filesystem that reuses inode numbers +//! (ext4) can give theirs the very same `(st_dev, st_ino)`. So each file made +//! is held by an `O_PATH` descriptor, which keeps its inode -- and its number +//! -- in use and is linked through directly (`Expected::pin`). Past the budget +//! of descriptors, or without a procfs to link a descriptor through, the file +//! is known by its identity and its `ctime`, which a file someone else makes +//! later does not share -- unless it is made within the same tick of the +//! coarse clock the kernel stamps `ctime` from (a few milliseconds): the +//! residual of that fallback. + +use crate::modes::anchored::{file_id, Expected}; +use plib::madefs::{fstat, lstat_at}; +use std::collections::VecDeque; +use std::ffi::CStr; +use std::io; +use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd}; + +/// A file this run made, as a later name of it is to be linked to it. +#[derive(Debug)] +pub(crate) struct MadeFile { + /// Its `(st_dev, st_ino)`. + id: (u64, u64), + /// Its `ctime` as this run last left it: changed by everything this run + /// does to it, a link included, and by nothing anyone else can make it + /// equal to on a file of their own. + ctime: (i64, i64), + /// The file itself, held open with `O_PATH`, while the budget allows. + pin: Option, +} + +impl MadeFile { + /// The file open on `fd` -- for writing, or `O_PATH` -- which this run + /// has just made and given its attributes: pinned by a descriptor of its + /// own where a verified procfs can reopen it. + pub(crate) fn of(fd: BorrowedFd<'_>) -> io::Result { + let st = fstat(fd.as_raw_fd())?; + Ok(MadeFile { + id: file_id(&st), + ctime: ctime_of(&st), + pin: reopen_path(fd), + }) + } + + /// Its `(st_dev, st_ino)`. + pub(crate) fn id(&self) -> (u64, u64) { + self.id + } + + /// What `link_replacing_with` is to link: this file. + pub(crate) fn expected(&self) -> Expected<'_> { + Expected { + id: self.id, + ctime: Some(self.ctime), + pin: self.pin.as_ref().map(|pin| pin.as_fd()), + } + } + + /// Whether it holds a pin. + pub(crate) fn is_pinned(&self) -> bool { + self.pin.is_some() + } + + /// Close the pin, keeping the `ctime` it shows last. + pub(crate) fn unpin(&mut self) { + if let Some(pin) = self.pin.take() { + if let Ok(st) = fstat(pin.as_raw_fd()) { + self.ctime = ctime_of(&st); + } + } + } + + /// The same file, known again for another of its names: a pin of its own + /// where this one has one and it can be duplicated. + pub(crate) fn share(&self) -> Self { + let pin = self.pin.as_ref().and_then(|pin| { + let fd = unsafe { libc::fcntl(pin.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) }; + (fd >= 0).then(|| unsafe { OwnedFd::from_raw_fd(fd) }) + }); + MadeFile { + id: self.id, + ctime: self.ctime, + pin, + } + } + + /// A name has just been linked to it, which changed its `ctime`. Pinned, + /// the pin shows that when it is closed; unpinned, it is read from `name` + /// in `dirfd` if that holds the file. + pub(crate) fn linked(&mut self, dirfd: BorrowedFd<'_>, name: &CStr) { + if self.pin.is_some() { + return; + } + if let Ok(st) = lstat_at(dirfd.as_raw_fd(), name) { + if file_id(&st) == self.id { + self.ctime = ctime_of(&st); + } + } + } +} + +/// A status's `ctime`, seconds and nanoseconds. +pub(crate) fn ctime_of(st: &libc::stat) -> (i64, i64) { + // Casts needed: the field types differ between platforms. + #[allow(clippy::unnecessary_cast)] + (st.st_ctime as i64, st.st_ctime_nsec as i64) +} + +/// An `O_PATH` descriptor for the file open on `fd`, reopened through its +/// `self/fd/N` entry in a verified procfs -- the inode itself, never a name. +#[cfg(target_os = "linux")] +fn reopen_path(fd: BorrowedFd<'_>) -> Option { + let proc_dir = plib::madefs::procfs_dir().ok()?; + let entry = plib::madefs::proc_fd_name(fd.as_raw_fd()); + let flags = libc::O_PATH | libc::O_CLOEXEC; + let pin = unsafe { libc::openat(proc_dir.as_raw_fd(), entry.as_ptr(), flags) }; + (pin >= 0).then(|| unsafe { OwnedFd::from_raw_fd(pin) }) +} + +/// Without `O_PATH` and procfs a file cannot be linked through a descriptor: +/// it is known by its identity and `ctime` alone. +#[cfg(not(target_os = "linux"))] +fn reopen_path(_fd: BorrowedFd<'_>) -> Option { + None +} + +/// The holders of pins, keyed by `K`, oldest first, and how many may be held +/// at once: a quarter of the descriptor limit, and at most 256. +/// +/// An archive can hold any number of files later names refer to, and a pin +/// kept for each would leave no descriptors for the members after them. Past +/// the budget the oldest holder's pin is closed. What it held then falls back +/// to its bare `(st_dev, st_ino)` -- with its `ctime`, for a file this run +/// made (`MadeFile`) -- checked when it is next used. +pub(crate) struct PinBudget { + held: VecDeque, + limit: usize, +} + +impl PinBudget { + pub(crate) fn new() -> Self { + let mut lim = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + let soft = if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut lim) } == 0 { + lim.rlim_cur + } else { + 0 + }; + PinBudget { + held: VecDeque::new(), + limit: usize::try_from(soft / 4).map_or(256, |quarter| quarter.min(256)), + } + } + + /// Account for `key`'s holder, which `pinned` says holds a pin now or not: + /// note it when it has taken one, forget it once it holds none, and when + /// that puts the budget over, close the oldest holder's pin (`unpin`). + pub(crate) fn note(&mut self, key: K, pinned: bool, mut unpin: impl FnMut(&K)) { + let held_at = self.held.iter().position(|k| *k == key); + match (pinned, held_at) { + (false, Some(at)) => { + self.held.remove(at); + } + (true, None) => self.held.push_back(key), + _ => {} + } + while self.held.len() > self.limit { + let Some(oldest) = self.held.pop_front() else { + break; + }; + unpin(&oldest); + } + } +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use crate::modes::anchored::{link_replacing_with, DirTree}; + use crate::modes::race_hook::{with_hook, Point}; + + /// A file made, `f`, kept alive by a second name so it can still be + /// linked once `f` is taken from it; another file, `planted`, ready to be + /// renamed over `f`. + fn made_and_planted() -> (plib::tmp::TempDir, DirTree) { + let dir = plib::tmp::TempDir::new().unwrap(); + std::fs::write(dir.path().join("f"), "made\n").unwrap(); + std::fs::hard_link(dir.path().join("f"), dir.path().join("kept")).unwrap(); + std::fs::write(dir.path().join("planted"), "planted\n").unwrap(); + let tree = DirTree::open_path(dir.path()).unwrap(); + (dir, tree) + } + + /// Rename `planted` over `f` when the link is about to be made. + fn plant(dir: &std::path::Path) -> impl FnMut(Point, libc::c_int, &CStr) + 'static { + let path = dir.to_path_buf(); + move |point, _, name| { + if point == Point::Linking && name == c"f" { + std::fs::rename(path.join("planted"), path.join("f")).unwrap(); + } + } + } + + /// Held by a pin, the file made is linked through the pin: whatever is at + /// its name by then -- even a file with its very inode number, which the + /// pin keeps from being reused -- is never looked at. + #[test] + fn a_pinned_file_is_linked_through_its_pin() { + let (dir, tree) = made_and_planted(); + let root = tree.root(); + let f = std::fs::File::open(dir.path().join("f")).unwrap(); + let made = MadeFile::of(f.as_fd()).unwrap(); + assert!(made.is_pinned()); + drop(f); + + let expected = Some(made.expected()); + let linked = with_hook(plant(dir.path()), || { + link_replacing_with(root.as_raw_fd(), c"f", None, expected, root, c"g", false) + }); + assert!(linked.is_ok(), "{linked:?}"); + let g = lstat_at(root.as_raw_fd(), c"g").unwrap(); + assert_eq!(file_id(&g), made.id(), "g is not the file made"); + } + + /// Unpinned, a file at its name with the same `(st_dev, st_ino)` -- the + /// number reused for someone else's file once this one was removed, + /// simulated by recording the planted file's number -- is told from it by + /// its `ctime`, and not linked. + #[test] + fn an_unpinned_file_is_told_from_a_reused_number_by_its_ctime() { + let (dir, tree) = made_and_planted(); + let root = tree.root(); + let made_st = lstat_at(root.as_raw_fd(), c"f").unwrap(); + // ctime comes from a coarse clock: let the planted file's come from a + // later tick, as a file made after this one was removed would. + let planted = dir.path().join("planted"); + for _ in 0..200 { + let st = lstat_at(root.as_raw_fd(), c"planted").unwrap(); + if ctime_of(&st) != ctime_of(&made_st) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + let perms = std::fs::metadata(&planted).unwrap().permissions(); + std::fs::set_permissions(&planted, perms).unwrap(); + } + let planted_st = lstat_at(root.as_raw_fd(), c"planted").unwrap(); + assert_ne!(ctime_of(&planted_st), ctime_of(&made_st)); + let made = MadeFile { + id: file_id(&planted_st), + ctime: ctime_of(&made_st), + pin: None, + }; + + let expected = Some(made.expected()); + let _ = with_hook(plant(dir.path()), || { + link_replacing_with(root.as_raw_fd(), c"f", None, expected, root, c"g", false) + }); + let g = std::fs::read_to_string(dir.path().join("g")).unwrap_or_default(); + assert_ne!(g, "planted\n", "the file at the reused number was linked"); + } + + /// The budget closes the oldest pin once more are held than it allows, + /// and forgets a holder that has closed its own. + #[test] + fn the_budget_closes_the_oldest_pin() { + let mut budget = PinBudget:: { + held: VecDeque::new(), + limit: 2, + }; + let mut closed = Vec::new(); + for key in 0..3 { + budget.note(key, true, |k| closed.push(*k)); + } + assert_eq!(closed, [0]); + budget.note(1, false, |k| closed.push(*k)); + budget.note(3, true, |k| closed.push(*k)); + assert_eq!(closed, [0]); + budget.note(4, true, |k| closed.push(*k)); + assert_eq!(closed, [0, 2]); + } +} diff --git a/pax/modes/read.rs b/pax/modes/read.rs index 5b2164526..15257347d 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -15,12 +15,15 @@ use crate::formats::OptionRecords; use crate::interactive::{InteractivePrompter, RenameResult}; use crate::modes::anchored::{ attrs_withheld, create_replacing, link_replacing_with, make_dir_at, set_attrs_fd, - set_made_node_attrs, unlink_at, AttrPolicy, Attrs, DirAttrs, DirTree, MemberPath, PendingDirs, + set_made_node_attrs, unlink_at, AttrPolicy, Attrs, DirAttrs, DirTree, Expected, MemberPath, + PendingDirs, }; +use crate::modes::pins::{MadeFile, PinBudget}; use crate::modes::select::Selector; use crate::pattern::Pattern; use crate::subst::{substitute_link_target, substitute_name, Substitution}; use plib::madefs::lstat_at; +use std::collections::HashMap; use std::ffi::{CStr, CString}; use std::fs::File; use std::io::Write; @@ -125,9 +128,7 @@ fn extract_members( tree: &DirTree, pending_dirs: &mut PendingDirs, ) -> PaxResult<()> { - let mut link_sets: LinkSets = LinkSets::default(); - let mut made_files = MadeFiles::new(); - let mut pins = PinBudget::new(); + let mut links = Links::new(); let mut selector = Selector::new( &options.patterns, options.exclude, @@ -151,36 +152,28 @@ fn extract_members( if let Some(ref records) = option_records { records.apply(&mut entry); } - link_sets.count_name(&entry); + links.sets.count_name(&entry); if select_member(&mut selector, &mut entry, options, &mut prompter, tree)? { // Per POSIX CONSEQUENCES OF ERRORS: diagnose a per-file failure and // set a non-zero exit, but continue with the next member. Skip any // unconsumed data of the failed entry to realign the reader. // A failure every later member would meet too -- -O's output // gone, end of file on the terminal -- ends the run instead. - let r = extract_entry( - archive, - &entry, - options, - &mut link_sets, - &mut made_files, - tree, - pending_dirs, - ); + let r = extract_entry(archive, &entry, options, &mut links, tree, pending_dirs); report_unless_fatal(&entry, r)?; - } else if let Some(set) = link_sets.find_mut(&entry) { + } else if let Some(set) = links.sets.find_mut(&entry) { let r = fill_link_set(archive, tree, &entry, options, set); report_unless_fatal(&entry, r)?; } archive.skip_data()?; // A set no data can still come for needs its file pinned no longer. - if let Some(set) = link_sets.settled_mut(&entry) { + if let Some(set) = links.sets.settled_mut(&entry) { set.unpin(); } - pins.account(&entry, &mut link_sets); + links.account_set(&entry); // A newc set's data comes with its last name, which -n must still // read even when every pattern has been used by an earlier one. - if selector.is_done() && link_sets.all_settled() { + if selector.is_done() && links.sets.all_settled() { reached_end = false; break; } @@ -332,8 +325,7 @@ fn extract_entry( archive: &mut R, entry: &ArchiveEntry, options: &ReadOptions, - link_sets: &mut LinkSets, - made_files: &mut MadeFiles, + links: &mut Links, tree: &DirTree, pending_dirs: &mut PendingDirs, ) -> PaxResult<()> { @@ -378,7 +370,7 @@ fn extract_entry( // Whatever this member puts at its name replaces what an earlier one made // there; only a regular file or a link to one records it again. - made_files.remove(&member.key()); + links.forget(&member.key()); match entry.entry_type { EntryType::Directory => { let decided = extract_directory(tree, pfd, &member, entry, options)?; @@ -396,13 +388,14 @@ fn extract_entry( archive.skip_data()?; } EntryType::Hardlink => { - extract_hardlink(tree, pfd, &member, entry, options, made_files)?; + extract_hardlink(tree, pfd, &member, entry, options, links)?; archive.skip_data()?; } EntryType::Regular => { - let made = extract_regular(archive, tree, pfd, &member, entry, options, link_sets)?; - if let Some(file) = made { - made_files.insert(member.key(), file); + let sets = &mut links.sets; + let made = extract_regular(archive, tree, pfd, &member, entry, options, sets)?; + if let Some(made) = made { + links.record_made(member.key(), made); } archive.skip_data()?; // Skip padding to block boundary } @@ -505,10 +498,80 @@ fn extract_symlink( Ok(()) } -/// What this run created at each member path (`MemberPath::key`): the -/// identity of the regular file it made there, from the descriptor that made -/// it. -type MadeFiles = std::collections::HashMap, (u64, u64)>; +/// What extraction keeps about the files later members name: the cpio link +/// sets, the file this run made at each member path, and the pins both hold, +/// within one budget. +struct Links { + sets: LinkSets, + /// The file this run made at each member path (`MemberPath::key`) -- a + /// tar link member's target -- known from the descriptor that made it. + made: HashMap, MadeFile>, + pins: PinBudget, +} + +/// What holds a pin in `Links`. +#[derive(PartialEq, Eq, Debug)] +enum PinKey { + /// A cpio link set (`LinkSets::key`), until it is settled. + Set((u64, u64)), + /// The file made at a member path. + Member(Vec), +} + +impl Links { + fn new() -> Self { + Links { + sets: LinkSets::default(), + made: HashMap::new(), + pins: PinBudget::new(), + } + } + + /// Account for the pin of the set `entry` is a name of, after the member. + fn account_set(&mut self, entry: &ArchiveEntry) { + let Some(key) = LinkSets::::key(entry) else { + return; + }; + let Some(set) = self.sets.by_key_mut(key) else { + return; + }; + let pinned = set.pin.is_some(); + self.note(PinKey::Set(key), pinned); + } + + /// Record `made` as the file this run made at the member path `key`. + fn record_made(&mut self, key: Vec, made: MadeFile) { + let pinned = made.is_pinned(); + self.made.insert(key.clone(), made); + self.note(PinKey::Member(key), pinned); + } + + /// Forget what this run made at the member path `key`: something else is + /// being put there. + fn forget(&mut self, key: &[u8]) { + if self.made.remove(key).is_some() { + self.note(PinKey::Member(key.to_vec()), false); + } + } + + /// `PinBudget::note`, closing the oldest pin -- a set's or a file's -- + /// when the budget is over. + fn note(&mut self, key: PinKey, pinned: bool) { + let Links { sets, made, pins } = self; + pins.note(key, pinned, |oldest| match oldest { + PinKey::Set(key) => { + if let Some(set) = sets.by_key_mut(*key) { + set.unpin(); + } + } + PinKey::Member(key) => { + if let Some(made) = made.get_mut(key) { + made.unpin(); + } + } + }); + } +} /// Extract a hard link fn extract_hardlink( @@ -517,7 +580,7 @@ fn extract_hardlink( member: &MemberPath, entry: &ArchiveEntry, options: &ReadOptions, - made_files: &mut MadeFiles, + links: &mut Links, ) -> PaxResult<()> { let name = member.leaf.as_c_str(); let Some(target) = entry.link_target.clone() else { @@ -545,87 +608,28 @@ fn extract_hardlink( // made one: someone who can write that directory can have put another // file at the name since. A target this run did not make is linked by // name. - let expected = made_files.get(&target_member.key()).copied(); + let target_key = target_member.key(); link_replacing_with( target_parent.as_raw_fd(), &target_member.leaf, None, - expected, + links.made.get(&target_key).map(MadeFile::expected), dirfd, name, options.no_clobber, )?; - // This name holds that file now, for a later member linked to it. - if let Some(file) = expected { - made_files.insert(member.key(), file); - } + // This name holds that file now, for a later member linked to it; and + // linking it changed the file's ctime. + let Some(target) = links.made.get_mut(&target_key) else { + return Ok(()); + }; + target.linked(dirfd, name); + let shared = target.share(); + links.record_made(member.key(), shared); Ok(()) } -/// The link sets whose files are pinned (`CreatedSet::pin`), oldest first, -/// and how many may be at once. -/// -/// A newc set waits for its data until c_nlink of its names have been read, -/// and an archive can start any number of sets that never finish: one name -/// each, with a c_nlink of 2 or of 0xffffffff. Pinned until the end, one -/// descriptor each, they would leave none for the members after them. Past -/// the budget the oldest set's pin is closed, and that set falls back to the -/// bare `(st_dev, st_ino)` check every set relies on once its data is in. -struct PinBudget { - /// The keys (`LinkSets::key`) of the sets holding a pin, oldest first. - held: std::collections::VecDeque<(u64, u64)>, - /// How many may be held at once: a quarter of the descriptor limit, and - /// at most 256. - limit: usize, -} - -impl PinBudget { - fn new() -> Self { - let mut lim = libc::rlimit { - rlim_cur: 0, - rlim_max: 0, - }; - let soft = if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut lim) } == 0 { - lim.rlim_cur - } else { - 0 - }; - PinBudget { - held: std::collections::VecDeque::new(), - limit: usize::try_from(soft / 4).map_or(256, |quarter| quarter.min(256)), - } - } - - /// Account for the set `entry` is a name of, after the member: forget it - /// once it holds no pin, note it when it has taken one, and close the - /// oldest pin when that puts the budget over. - fn account(&mut self, entry: &ArchiveEntry, link_sets: &mut LinkSets) { - let Some(key) = LinkSets::::key(entry) else { - return; - }; - let Some(set) = link_sets.by_key_mut(key) else { - return; - }; - let held_at = self.held.iter().position(|&k| k == key); - match (set.pin.is_some(), held_at) { - (false, Some(at)) => { - self.held.remove(at); - } - (true, None) => self.held.push_back(key), - _ => {} - } - while self.held.len() > self.limit { - let Some(oldest) = self.held.pop_front() else { - break; - }; - if let Some(set) = link_sets.by_key_mut(oldest) { - set.unpin(); - } - } - } -} - /// What extraction remembers about a cpio link set. struct CreatedSet { /// The names created for the set so far, as extracted (after -s and -i). @@ -637,7 +641,7 @@ struct CreatedSet { /// name of a set only, so the earlier names are created empty. has_data: bool, /// The file held open while data may still come for it on a later name - /// (`LinkSets::settled_mut`), within the budget (`PinBudget`). Its names + /// (`LinkSets::settled_mut`), within the budget (`Links::pins`). Its names /// can be replaced by other members meanwhile, and a filesystem that /// reuses inode numbers (ext4) then hands this file's number to the next /// file created, which `file` would take for this one. Open, it keeps its @@ -693,14 +697,14 @@ fn extract_regular( entry: &ArchiveEntry, options: &ReadOptions, link_sets: &mut LinkSets, -) -> PaxResult> { +) -> PaxResult> { if let Some(set) = link_sets.find_mut(entry) { join_link_set(archive, tree, dirfd, member, entry, options, set)?; return Ok(None); } let made = extract_file(archive, dirfd, member.leaf.as_c_str(), entry, options)?; - if let Some(file) = made { + if let Some(file) = made.as_ref().map(MadeFile::id) { link_sets.insert(entry, || { let names = vec![member.display.clone()]; CreatedSet::new(names, file, entry.size > 0, dirfd, &member.leaf) @@ -741,7 +745,7 @@ fn join_link_set( src_dir.as_raw_fd(), &src_leaf, None, - Some(set.file), + Some(Expected::id(set.file)), dirfd, name, options.no_clobber, @@ -756,7 +760,8 @@ fn join_link_set( } let holders = set.holders(tree); - let Some(file) = extract_file(archive, dirfd, name, entry, options)? else { + let Some(file) = extract_file(archive, dirfd, name, entry, options)?.map(|made| made.id()) + else { // -k kept what was there; the data is still the earlier names'. return fill_link_set(archive, tree, entry, options, set); }; @@ -792,6 +797,7 @@ fn fill_link_set( let dir = Rc::clone(dir); let (temp, file) = create_temp_file(dir.as_fd(), entry, options)?; let filled = write_file_data(archive, file, entry, options) + .map(|made| made.id()) .and_then(|id| Ok((id, move_names_to(holders, dir.as_fd(), &temp, id)?))); let removed = unlink_at(dir.as_fd(), &temp); let (file, names) = filled?; @@ -820,7 +826,15 @@ fn move_names_to( // even under -k. The link is to the file made, pinned, never to // whatever has been put at its name since. let from = dirfd.as_raw_fd(); - link_replacing_with(from, name, None, Some(file), dir.as_fd(), &leaf, false)?; + link_replacing_with( + from, + name, + None, + Some(Expected::id(file)), + dir.as_fd(), + &leaf, + false, + )?; names.push(path); } Ok(names) @@ -949,7 +963,7 @@ fn extract_file( name: &CStr, entry: &ArchiveEntry, options: &ReadOptions, -) -> PaxResult> { +) -> PaxResult> { let mut opened: Option = None; let created = create_replacing(dirfd, name, options.no_clobber, || { opened = Some(create_file(dirfd, name, entry, options)?); @@ -1014,16 +1028,17 @@ fn write_file_data( mut file: File, entry: &ArchiveEntry, options: &ReadOptions, -) -> PaxResult<(u64, u64)> { +) -> PaxResult { copy_file_data(archive, &mut file, entry.size)?; // Through the descriptor the data was just written to, not by name. set_attrs_fd(file.as_fd(), &attrs_of(entry, options), &policy_of(options))?; - let id = file_id(&file.metadata()?); + // Known once its attributes are set, which change its ctime. + let made = MadeFile::of(file.as_fd())?; // A filesystem that defers writes -- NFS, a quota checked late -- reports // their failure here, and the member is then not extracted after all. crate::blocked_io::close_file(file)?; - Ok(id) + Ok(made) } /// Copy file data from archive to file @@ -1152,18 +1167,6 @@ mod tests { use super::*; use std::os::unix::ffi::OsStringExt; - /// POSIX, ustar Interchange Format: "When the file is restored by a - /// privileged, protection-preserving version of the utility, the user and - /// group databases shall be scanned for these names. If found, the user - /// and group IDs contained within these files shall be used rather than - /// the values contained within the uid and gid fields." The pax `uname` - /// record puts it more strongly still: it "shall override the uid and - /// uname fields in the following header block(s), and any uid extended - /// header record." - /// - /// Extraction chowned by the numeric fields alone, so an archive carried - /// between hosts restored each file to whichever account happened to hold - /// the originating host's uid -- the problem uname exists to solve. /// A link set's earlier names are moved over to the file just made for /// its data by linking that file's name again. Someone who can rename in /// that directory can put another file at the name first, and the set's @@ -1205,9 +1208,11 @@ mod tests { let dir = plib::tmp::TempDir::new().unwrap(); std::fs::write(dir.path().join("f"), "extracted\n").unwrap(); let tree = DirTree::open_path(dir.path()).unwrap(); - let mut made_files = MadeFiles::new(); + let mut links = Links::new(); let target = MemberPath::parse(Path::new("f")).unwrap().unwrap(); - made_files.insert(target.key(), id_at(tree.root(), c"f").unwrap()); + let f = std::fs::File::open(dir.path().join("f")).unwrap(); + links.record_made(target.key(), MadeFile::of(f.as_fd()).unwrap()); + drop(f); let path = dir.path().to_path_buf(); let swap = move |point, _: libc::c_int, name: &CStr| { @@ -1221,14 +1226,7 @@ mod tests { let member = MemberPath::parse(Path::new("g")).unwrap().unwrap(); let options = ReadOptions::default(); let _ = with_hook(swap, || { - extract_hardlink( - &tree, - tree.root(), - &member, - &entry, - &options, - &mut made_files, - ) + extract_hardlink(&tree, tree.root(), &member, &entry, &options, &mut links) }); let linked = std::fs::read_to_string(dir.path().join("g")).unwrap_or_default(); assert_ne!( @@ -1237,6 +1235,18 @@ mod tests { ); } + /// POSIX, ustar Interchange Format: "When the file is restored by a + /// privileged, protection-preserving version of the utility, the user and + /// group databases shall be scanned for these names. If found, the user + /// and group IDs contained within these files shall be used rather than + /// the values contained within the uid and gid fields." The pax `uname` + /// record puts it more strongly still: it "shall override the uid and + /// uname fields in the following header block(s), and any uid extended + /// header record." + /// + /// Extraction chowned by the numeric fields alone, so an archive carried + /// between hosts restored each file to whichever account happened to hold + /// the originating host's uid -- the problem uname exists to solve. #[test] fn test_owner_prefers_the_recorded_name_over_the_numeric_id() { let euid = unsafe { libc::geteuid() }; diff --git a/pax/modes/read/race_tests.rs b/pax/modes/read/race_tests.rs index bfe5ba389..6932c7054 100644 --- a/pax/modes/read/race_tests.rs +++ b/pax/modes/read/race_tests.rs @@ -271,27 +271,17 @@ fn unverified_intermediate_directory_is_not_stamped() { /// attributes -- and says so, rather than dropping them in silence. #[test] fn unverified_directory_member_is_diagnosed() { - use crate::archive::LinkSets; use crate::modes::made::MadeTrust; let tmp = TempDir::new().unwrap(); let tree = DirTree::open_path(tmp.path()).unwrap(); let mut pending = PendingDirs::default(); - let mut link_sets = LinkSets::default(); - let mut made_files = super::MadeFiles::new(); + let mut links = super::Links::new(); let entry = own_member("d", EntryType::Directory, 0o751); let mut archive = Members(Vec::new().into_iter()); let options = preserve_everything(); let r = race_hook::with_dir_trust(MadeTrust::ParentOwnerOnly, || { let pending = &mut pending; - extract_entry( - &mut archive, - &entry, - &options, - &mut link_sets, - &mut made_files, - &tree, - pending, - ) + extract_entry(&mut archive, &entry, &options, &mut links, &tree, pending) }); assert!(r.is_err(), "withholding the attributes went unreported"); @@ -307,28 +297,18 @@ fn unverified_directory_member_is_diagnosed() { /// appended archive -- and gets no attributes then either. #[test] fn unverified_directory_named_twice_is_withheld_both_times() { - use crate::archive::LinkSets; use crate::modes::made::MadeTrust; let tmp = TempDir::new().unwrap(); let tree = DirTree::open_path(tmp.path()).unwrap(); let mut pending = PendingDirs::default(); - let mut link_sets = LinkSets::default(); - let mut made_files = super::MadeFiles::new(); + let mut links = super::Links::new(); let entry = own_member("d", EntryType::Directory, 0o751); let mut archive = Members(Vec::new().into_iter()); let options = preserve_everything(); let results = race_hook::with_dir_trust(MadeTrust::ParentOwnerOnly, || { let mut extract = || { let pending = &mut pending; - extract_entry( - &mut archive, - &entry, - &options, - &mut link_sets, - &mut made_files, - &tree, - pending, - ) + extract_entry(&mut archive, &entry, &options, &mut links, &tree, pending) }; [extract().is_err(), extract().is_err()] }); diff --git a/pax/modes/write.rs b/pax/modes/write.rs index 914700d76..906551f01 100644 --- a/pax/modes/write.rs +++ b/pax/modes/write.rs @@ -655,7 +655,9 @@ fn write_file( }; // Check for hard link - let original = link_tracker.lookup(entry.dev, entry.ino, entry.nlink); + let original = link_tracker + .lookup(entry.dev, entry.ino, entry.nlink) + .cloned(); let later_name = original.is_some(); if let Some(original_path) = original { // The same file met again under the very name it was first archived From 559b9f432ae5cd8fe29321c0aed16e6d2e65b926 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 21:59:04 +0000 Subject: [PATCH 57/71] pax: record every node a link member can name, failed members and -i renames too A tar link member is linked to the file this run made at its target's name (294d5505), but only regular files that extracted completely were recorded; anything else was linked by name, whatever the name held by then. Now recorded too: - symbolic links, FIFOs and devices: set_made_node_attrs_recording hands back the node it pinned and checked (MadeNode::made_file, a duplicate of its O_PATH pin on Linux), even when its attributes then fail; - a regular member whose data or attributes fail after its file is created: write_file_data records the file from its descriptor first and refreshes its ctime on every path; extract_regular records it whatever the outcome; - -i renames: select_member reports the name a member had before the prompt, and a later link member naming it is resolved to the name -i gave it (Links::link_target), where this run made the file. A symbolic link cannot be linked through its self/fd/N entry -- AT_SYMLINK_FOLLOW goes on to what the link names -- so LinkSource links one by name, checked by identity and by the ctime the caller's pin shows now, before and after; the pin keeps its inode number from being reused meanwhile. A pin a caller holds but that is not linked through must still be the file it records. Tests (race_tests): a link member naming a symbolic link member, with a file renamed over the link first, is never linked to that file (before: it was; mutation check: recording nothing fails it); the same link member is linked to the symbolic link made when nothing interferes; a link member naming a regular member whose data could not be read is not linked to a planted file (before: it was); a link member naming a member -i renamed is linked to the renamed file (before: not linked at all). Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 46 ++++++++- pax/modes/made.rs | 22 +++++ pax/modes/pins.rs | 45 ++++++++- pax/modes/read.rs | 179 +++++++++++++++++++++++++---------- pax/modes/read/race_tests.rs | 151 +++++++++++++++++++++++++++++ 5 files changed, 390 insertions(+), 53 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index f149fc100..982a54af1 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -22,6 +22,7 @@ use crate::error::{PaxError, PaxResult}; use crate::modes::made::{self, verify_made_dir, MadeNode, MadeTrust}; +use crate::modes::pins::MadeFile; use plib::madefs::{cvt, fstat, fstatat, lstat_at}; use plib::madefs::{ChainTrust, FoundDir, NamedAnchor, Preserve, SEARCH_ONLY}; use std::cell::RefCell; @@ -1395,6 +1396,13 @@ impl<'a> LinkSource<'a> { } } let src_flags = if follow { 0 } else { libc::AT_SYMLINK_NOFOLLOW }; + // A pin the caller holds, not linked through, must still be the file. + if let Some(pin) = expected.and_then(|e| e.pin) { + let st = fstat(pin.as_raw_fd()).map_err(|_| source_changed())?; + if expected.is_some_and(|e| file_id(&st) != e.id) { + return Err(source_changed()); + } + } if let Some(expected) = expected.filter(|e| e.ctime.is_some()) { // The residual without procfs: a file removed and made again // with this number and ctime between this check and the link. @@ -1441,6 +1449,14 @@ impl<'a> LinkSource<'a> { // A pin the caller holds is the file itself; one opened by name must // show the identity, and the ctime where known, of the file meant. let st = fstat(pin.as_raw_fd()).map_err(|_| source_changed())?; + // A symbolic link cannot be linked through its `self/fd/N` entry: + // `AT_SYMLINK_FOLLOW` goes on through the link to what it names. It + // is linked by name, checked by identity and ctime before and after; + // a pin the caller holds keeps its number from being reused + // meanwhile. + if st.st_mode & libc::S_IFMT == libc::S_IFLNK && !follow { + return Ok(None); + } let is_expected = match pin { PinFd::Borrowed(_) => file_id(&st) == expected.id, PinFd::Owned(_) => expected.matches(&st), @@ -2027,10 +2043,36 @@ pub(crate) fn set_made_node_attrs( made_type: libc::mode_t, attrs: &Attrs, policy: &AttrPolicy, +) -> PaxResult<()> { + set_made_node_attrs_recording(dirfd, name, made_type, attrs, policy, &mut None) +} + +/// `set_made_node_attrs`, leaving in `made` the node as a later name of it +/// is to be linked to it (`MadeFile`) once it is pinned and checked to be the +/// one made -- as it is left, whether or not its attributes all took. +pub(crate) fn set_made_node_attrs_recording( + dirfd: BorrowedFd<'_>, + name: &CStr, + made_type: libc::mode_t, + attrs: &Attrs, + policy: &AttrPolicy, + made: &mut Option, ) -> PaxResult<()> { let node = MadeNode::pin(dirfd, name, made_type)?; + let applied = apply_node_attrs(&node, made_type, attrs, policy); + *made = node.made_file(); + applied +} + +/// The attributes `set_made_node_attrs` gives a node, through its pin. +fn apply_node_attrs( + node: &MadeNode<'_>, + made_type: libc::mode_t, + attrs: &Attrs, + policy: &AttrPolicy, +) -> PaxResult<()> { if node.trust() == MadeTrust::ParentOwnerOnly { - set_node_times(&node, attrs, policy); + set_node_times(node, attrs, policy); return owner_unverified(policy); } @@ -2039,7 +2081,7 @@ pub(crate) fn set_made_node_attrs( if made_type != libc::S_IFLNK { node.chmod(policy.mode(attrs, owner_set) as libc::mode_t)?; } - set_node_times(&node, attrs, policy); + set_node_times(node, attrs, policy); Ok(()) } diff --git a/pax/modes/made.rs b/pax/modes/made.rs index d6b5e90e2..dfd1a1e2a 100644 --- a/pax/modes/made.rs +++ b/pax/modes/made.rs @@ -25,6 +25,7 @@ //! The trust rules and the pinned-inode primitives are cp's, shared through //! `plib::madefs`. +use crate::modes::pins::MadeFile; pub(crate) use plib::madefs::MadeTrust; use plib::madefs::{cvt, fs_owners, fstat, made_by_us, others_can_rename, FsOwners, MadeObject}; #[cfg(target_os = "linux")] @@ -160,6 +161,12 @@ mod linux { }) } + /// The node as a later name of it is to be linked to it: pinned by a + /// duplicate of this pin. + pub(crate) fn made_file(&self) -> Option { + MadeFile::held(self.fd.as_fd()).ok() + } + pub(crate) fn trust(&self) -> MadeTrust { self.trust } @@ -285,6 +292,21 @@ mod other { Ok(MadeNode { held, trust }) } + /// The node as a later name of it is to be linked to it, known by its + /// identity and ctime: nothing here can be linked through a + /// descriptor. + pub(crate) fn made_file(&self) -> Option { + let st = match self.held { + Held::Fd(ref fd) => fstat(fd.as_raw_fd()).ok()?, + Held::Name { + dirfd, name, id, .. + } => lstat_at(dirfd.as_raw_fd(), name) + .ok() + .filter(|st| file_id(st) == id)?, + }; + Some(MadeFile::unpinned(&st)) + } + pub(crate) fn trust(&self) -> MadeTrust { self.trust } diff --git a/pax/modes/pins.rs b/pax/modes/pins.rs index 2f08492b9..ab21d2e55 100644 --- a/pax/modes/pins.rs +++ b/pax/modes/pins.rs @@ -56,16 +56,57 @@ impl MadeFile { }) } + /// A node this run has just made and given its attributes, already held + /// by the `O_PATH` descriptor `pin` (`MadeNode`): pinned by a duplicate + /// of it. + #[cfg(target_os = "linux")] + pub(crate) fn held(pin: BorrowedFd<'_>) -> io::Result { + let st = fstat(pin.as_raw_fd())?; + let dup = unsafe { libc::fcntl(pin.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) }; + Ok(MadeFile { + id: file_id(&st), + ctime: ctime_of(&st), + pin: (dup >= 0).then(|| unsafe { OwnedFd::from_raw_fd(dup) }), + }) + } + + /// A file this run has just made, known only by the status `st` -- where + /// nothing holds it. + #[cfg(not(target_os = "linux"))] + pub(crate) fn unpinned(st: &libc::stat) -> Self { + MadeFile { + id: file_id(st), + ctime: ctime_of(st), + pin: None, + } + } + + /// Take the `ctime` the file open on `fd`, this one, shows now: after this + /// run has written it or set its attributes. + pub(crate) fn refresh(&mut self, fd: BorrowedFd<'_>) { + if let Ok(st) = fstat(fd.as_raw_fd()) { + if file_id(&st) == self.id { + self.ctime = ctime_of(&st); + } + } + } + /// Its `(st_dev, st_ino)`. pub(crate) fn id(&self) -> (u64, u64) { self.id } - /// What `link_replacing_with` is to link: this file. + /// What `link_replacing_with` is to link: this file, with the `ctime` it + /// shows now where it is pinned, and as last left otherwise. pub(crate) fn expected(&self) -> Expected<'_> { + let pinned_ctime = self + .pin + .as_ref() + .and_then(|pin| fstat(pin.as_raw_fd()).ok()) + .map(|st| ctime_of(&st)); Expected { id: self.id, - ctime: Some(self.ctime), + ctime: Some(pinned_ctime.unwrap_or(self.ctime)), pin: self.pin.as_ref().map(|pin| pin.as_fd()), } } diff --git a/pax/modes/read.rs b/pax/modes/read.rs index 15257347d..203a17920 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -15,8 +15,8 @@ use crate::formats::OptionRecords; use crate::interactive::{InteractivePrompter, RenameResult}; use crate::modes::anchored::{ attrs_withheld, create_replacing, link_replacing_with, make_dir_at, set_attrs_fd, - set_made_node_attrs, unlink_at, AttrPolicy, Attrs, DirAttrs, DirTree, Expected, MemberPath, - PendingDirs, + set_made_node_attrs_recording, unlink_at, AttrPolicy, Attrs, DirAttrs, DirTree, Expected, + MemberPath, PendingDirs, }; use crate::modes::pins::{MadeFile, PinBudget}; use crate::modes::select::Selector; @@ -153,7 +153,19 @@ fn extract_members( records.apply(&mut entry); } links.sets.count_name(&entry); - if select_member(&mut selector, &mut entry, options, &mut prompter, tree)? { + let mut renamed_from = None; + let selected = select_member( + &mut selector, + &mut entry, + options, + &mut prompter, + tree, + &mut renamed_from, + )?; + if let Some(before) = renamed_from.filter(|_| selected) { + links.alias(&before, &entry.path); + } + if selected { // Per POSIX CONSEQUENCES OF ERRORS: diagnose a per-file failure and // set a non-zero exit, but continue with the next member. Skip any // unconsumed data of the failed entry to realign the reader. @@ -209,6 +221,7 @@ fn select_member( options: &ReadOptions, prompter: &mut Option, tree: &DirTree, + renamed_from: &mut Option, ) -> PaxResult { let Some(selection) = selector.select(entry) else { return Ok(false); @@ -228,7 +241,9 @@ fn select_member( match p.prompt(&entry.path)? { RenameResult::Skip => return Ok(false), RenameResult::UseOriginal => {} - RenameResult::Rename(new_path) => entry.path = new_path, + RenameResult::Rename(new_path) => { + *renamed_from = Some(std::mem::replace(&mut entry.path, new_path)); + } } } Ok(!(options.update && options.update_final_name && !is_archive_newer(tree, entry))) @@ -384,7 +399,10 @@ fn extract_entry( } } EntryType::Symlink => { - extract_symlink(pfd, name, entry, options)?; + let mut made = None; + let result = extract_symlink(pfd, name, entry, options, &mut made); + links.record(&member, made); + result?; archive.skip_data()?; } EntryType::Hardlink => { @@ -392,19 +410,21 @@ fn extract_entry( archive.skip_data()?; } EntryType::Regular => { - let sets = &mut links.sets; - let made = extract_regular(archive, tree, pfd, &member, entry, options, sets)?; - if let Some(made) = made { - links.record_made(member.key(), made); - } + extract_regular(archive, tree, pfd, &member, entry, options, links)?; archive.skip_data()?; // Skip padding to block boundary } EntryType::BlockDevice | EntryType::CharDevice => { - extract_device(pfd, name, entry, options)?; + let mut made = None; + let result = extract_device(pfd, name, entry, options, &mut made); + links.record(&member, made); + result?; archive.skip_data()?; } EntryType::Fifo => { - extract_fifo(pfd, name, entry, options)?; + let mut made = None; + let result = extract_fifo(pfd, name, entry, options, &mut made); + links.record(&member, made); + result?; archive.skip_data()?; } EntryType::Socket => { @@ -476,6 +496,7 @@ fn extract_symlink( name: &CStr, entry: &ArchiveEntry, options: &ReadOptions, + made: &mut Option, ) -> PaxResult<()> { let target = entry .link_target @@ -493,7 +514,7 @@ fn extract_symlink( })?; if created { - set_made_attrs(dirfd, name, libc::S_IFLNK, entry, options)?; + set_made_attrs(dirfd, name, libc::S_IFLNK, entry, options, made)?; } Ok(()) } @@ -507,6 +528,9 @@ struct Links { /// tar link member's target -- known from the descriptor that made it. made: HashMap, MadeFile>, pins: PinBudget, + /// The name -i gave each member it renamed, by the member path + /// (`MemberPath::key`) it had before. + aliases: HashMap, PathBuf>, } /// What holds a pin in `Links`. @@ -524,6 +548,7 @@ impl Links { sets: LinkSets::default(), made: HashMap::new(), pins: PinBudget::new(), + aliases: HashMap::new(), } } @@ -546,6 +571,29 @@ impl Links { self.note(PinKey::Member(key), pinned); } + /// Note that -i renamed the member named `before` (after -s) to `after`. + fn alias(&mut self, before: &Path, after: &Path) { + if let Ok(Some(before)) = MemberPath::parse(before) { + self.aliases.insert(before.key(), after.to_path_buf()); + } + } + + /// The name a link member's `target` is to be found at: the name -i gave + /// that member, where it gave one. A link member names its target by its + /// name in the archive (after -s), which -i does not rename. + fn link_target(&self, target: PathBuf) -> PathBuf { + let key = MemberPath::parse(&target).ok().flatten().map(|m| m.key()); + key.and_then(|key| self.aliases.get(&key).cloned()) + .unwrap_or(target) + } + + /// Record what this run made for `member`, if anything. + fn record(&mut self, member: &MemberPath, made: Option) { + if let Some(made) = made { + self.record_made(member.key(), made); + } + } + /// Forget what this run made at the member path `key`: something else is /// being put there. fn forget(&mut self, key: &[u8]) { @@ -557,7 +605,9 @@ impl Links { /// `PinBudget::note`, closing the oldest pin -- a set's or a file's -- /// when the budget is over. fn note(&mut self, key: PinKey, pinned: bool) { - let Links { sets, made, pins } = self; + let Links { + sets, made, pins, .. + } = self; pins.note(key, pinned, |oldest| match oldest { PinKey::Set(key) => { if let Some(set) = sets.by_key_mut(*key) { @@ -588,6 +638,7 @@ fn extract_hardlink( "hard link target not found".to_string(), )); }; + let target = links.link_target(target); let Some(target_member) = MemberPath::parse(&target)? else { return Err(PaxError::InvalidHeader( @@ -696,21 +747,34 @@ fn extract_regular( member: &MemberPath, entry: &ArchiveEntry, options: &ReadOptions, - link_sets: &mut LinkSets, -) -> PaxResult> { - if let Some(set) = link_sets.find_mut(entry) { - join_link_set(archive, tree, dirfd, member, entry, options, set)?; - return Ok(None); + links: &mut Links, +) -> PaxResult<()> { + if let Some(set) = links.sets.find_mut(entry) { + return join_link_set(archive, tree, dirfd, member, entry, options, set); } - let made = extract_file(archive, dirfd, member.leaf.as_c_str(), entry, options)?; - if let Some(file) = made.as_ref().map(MadeFile::id) { - link_sets.insert(entry, || { - let names = vec![member.display.clone()]; - CreatedSet::new(names, file, entry.size > 0, dirfd, &member.leaf) - }); + let mut made = None; + let result = extract_file( + archive, + dirfd, + member.leaf.as_c_str(), + entry, + options, + &mut made, + ); + // A file made is recorded for a later link member to name, even when its + // data or attributes then failed: it is at that name all the same. + if let Some(made) = made { + if result.is_ok() { + let file = made.id(); + links.sets.insert(entry, || { + let names = vec![member.display.clone()]; + CreatedSet::new(names, file, entry.size > 0, dirfd, &member.leaf) + }); + } + links.record_made(member.key(), made); } - Ok(made) + result } /// Extract a later name of a link set whose file is already on disk. @@ -760,8 +824,9 @@ fn join_link_set( } let holders = set.holders(tree); - let Some(file) = extract_file(archive, dirfd, name, entry, options)?.map(|made| made.id()) - else { + let mut made = None; + extract_file(archive, dirfd, name, entry, options, &mut made)?; + let Some(file) = made.as_ref().map(MadeFile::id) else { // -k kept what was there; the data is still the earlier names'. return fill_link_set(archive, tree, entry, options, set); }; @@ -796,9 +861,14 @@ fn fill_link_set( }; let dir = Rc::clone(dir); let (temp, file) = create_temp_file(dir.as_fd(), entry, options)?; - let filled = write_file_data(archive, file, entry, options) - .map(|made| made.id()) - .and_then(|id| Ok((id, move_names_to(holders, dir.as_fd(), &temp, id)?))); + let mut made = None; + let filled = write_file_data(archive, file, entry, options, &mut made).and_then(|()| { + let id = made + .as_ref() + .map(MadeFile::id) + .ok_or_else(crate::modes::made::replaced)?; + Ok((id, move_names_to(holders, dir.as_fd(), &temp, id)?)) + }); let removed = unlink_at(dir.as_fd(), &temp); let (file, names) = filled?; removed?; @@ -880,6 +950,7 @@ fn extract_device( name: &CStr, entry: &ArchiveEntry, options: &ReadOptions, + made: &mut Option, ) -> PaxResult<()> { // makedev has different signatures on different platforms: // - Linux: makedev(major: u32, minor: u32) -> u64 @@ -917,7 +988,7 @@ fn extract_device( Err(e) => return Err(e), } - set_made_attrs(dirfd, name, type_bits, entry, options) + set_made_attrs(dirfd, name, type_bits, entry, options, made) } /// Extract a FIFO @@ -926,6 +997,7 @@ fn extract_fifo( name: &CStr, entry: &ArchiveEntry, options: &ReadOptions, + made: &mut Option, ) -> PaxResult<()> { let created = create_replacing(dirfd, name, options.no_clobber, || { let r = unsafe { @@ -952,18 +1024,20 @@ fn extract_fifo( Err(e) => return Err(e), } - set_made_attrs(dirfd, name, libc::S_IFIFO, entry, options) + set_made_attrs(dirfd, name, libc::S_IFIFO, entry, options, made) } -/// Extract a regular file, returning the (st_dev, st_ino) of the file created, -/// or `None` when -k left an existing one in place. +/// Extract a regular file, leaving in `made` the file created -- whether or +/// not its data and attributes then all went in -- or `None` when -k left an +/// existing one in place. fn extract_file( archive: &mut R, dirfd: BorrowedFd<'_>, name: &CStr, entry: &ArchiveEntry, options: &ReadOptions, -) -> PaxResult> { + made: &mut Option, +) -> PaxResult<()> { let mut opened: Option = None; let created = create_replacing(dirfd, name, options.no_clobber, || { opened = Some(create_file(dirfd, name, entry, options)?); @@ -974,9 +1048,9 @@ fn extract_file( // -k: the name already exists, so the member is skipped. Its data is // consumed by the caller's skip_data. debug_assert!(!created); - return Ok(None); + return Ok(()); }; - write_file_data(archive, file, entry, options).map(Some) + write_file_data(archive, file, entry, options, made) } /// Create `name` in `dirfd` exclusively, never following a symlink, with the @@ -1028,17 +1102,23 @@ fn write_file_data( mut file: File, entry: &ArchiveEntry, options: &ReadOptions, -) -> PaxResult { - copy_file_data(archive, &mut file, entry.size)?; - - // Through the descriptor the data was just written to, not by name. - set_attrs_fd(file.as_fd(), &attrs_of(entry, options), &policy_of(options))?; - // Known once its attributes are set, which change its ctime. - let made = MadeFile::of(file.as_fd())?; + made: &mut Option, +) -> PaxResult<()> { + // Known from the descriptor that made it, whatever happens next. + *made = Some(MadeFile::of(file.as_fd())?); + let written = copy_file_data(archive, &mut file, entry.size).and_then(|()| { + // Through the descriptor the data was just written to, not by name. + set_attrs_fd(file.as_fd(), &attrs_of(entry, options), &policy_of(options)) + }); + // Its data and attributes changed its ctime. + if let Some(made) = made.as_mut() { + made.refresh(file.as_fd()); + } + written?; // A filesystem that defers writes -- NFS, a quota checked late -- reports // their failure here, and the member is then not extracted after all. crate::blocked_io::close_file(file)?; - Ok(made) + Ok(()) } /// Copy file data from archive to file @@ -1157,9 +1237,10 @@ fn set_made_attrs( made_type: libc::mode_t, entry: &ArchiveEntry, options: &ReadOptions, + made: &mut Option, ) -> PaxResult<()> { let attrs = attrs_of(entry, options); - set_made_node_attrs(dirfd, name, made_type, &attrs, &policy_of(options)) + set_made_node_attrs_recording(dirfd, name, made_type, &attrs, &policy_of(options), made) } #[cfg(all(test, unix))] @@ -1380,7 +1461,7 @@ mod tests { umask: 0o022, ..Default::default() }; - set_made_attrs(dir.as_fd(), &name, libc::S_IFIFO, &entry, &opts).unwrap(); + set_made_attrs(dir.as_fd(), &name, libc::S_IFIFO, &entry, &opts, &mut None).unwrap(); assert_eq!(mode(), 0o755); // Preserved: exact 0o777 regardless of umask. @@ -1391,7 +1472,7 @@ mod tests { umask: 0o022, ..Default::default() }; - set_made_attrs(dir.as_fd(), &name, libc::S_IFIFO, &entry, &opts).unwrap(); + set_made_attrs(dir.as_fd(), &name, libc::S_IFIFO, &entry, &opts, &mut None).unwrap(); assert_eq!(mode(), 0o777); } diff --git a/pax/modes/read/race_tests.rs b/pax/modes/read/race_tests.rs index 6932c7054..f130aa402 100644 --- a/pax/modes/read/race_tests.rs +++ b/pax/modes/read/race_tests.rs @@ -419,3 +419,154 @@ fn fifo_and_symlink_take_their_attributes() { assert!(md.file_type().is_symlink()); assert_eq!(md.mtime(), 23456); } + +/// An archive of `members` whose data cannot be read: every regular member +/// fails after its file is created. +#[cfg(target_os = "linux")] +struct DataFails(std::vec::IntoIter); + +#[cfg(target_os = "linux")] +impl ArchiveReader for DataFails { + fn read_entry(&mut self) -> PaxResult> { + Ok(self.0.next()) + } + fn read_data(&mut self, _buf: &mut [u8]) -> PaxResult { + Err(PaxError::Io(std::io::Error::other("data unreadable"))) + } + fn skip_data(&mut self) -> PaxResult<()> { + Ok(()) + } +} + +/// A tar link member `g` naming the member `target`. +fn link_member(target: &str) -> ArchiveEntry { + let mut entry = own_member("g", EntryType::Hardlink, 0o644); + entry.link_target = Some(PathBuf::from(target)); + entry +} + +/// Extract `members` in order below `dest` with `archive`, a writer renaming +/// the file `planted` over `target` just before the link to it is made. +#[cfg(target_os = "linux")] +fn extract_linked_while_planted( + dest: &Path, + archive: &mut R, + target: &'static CStr, +) { + let tree = DirTree::open_path(dest).unwrap(); + let mut pending = PendingDirs::default(); + let mut links = super::Links::new(); + let options = ReadOptions::default(); + let path = dest.to_path_buf(); + let hook = move |point, _: libc::c_int, name: &CStr| { + if point == Point::Linking && name == target { + std::fs::write(path.join("planted"), "planted\n").unwrap(); + let target = std::ffi::OsStr::from_bytes(target.to_bytes()); + std::fs::rename(path.join("planted"), path.join(target)).unwrap(); + } + }; + race_hook::with_hook(hook, || { + while let Some(entry) = archive.read_entry().unwrap() { + let _ = extract_entry(archive, &entry, &options, &mut links, &tree, &mut pending); + } + }); +} + +/// A link member naming a symbolic link member is linked to that very link, +/// the one this run made, or not at all -- never to whatever is at its name +/// by then. Only regular files were recorded; a symbolic link was linked by +/// name, whatever the name held. (A symbolic link cannot be linked through +/// its pin, so with its name taken the member fails.) +#[cfg(target_os = "linux")] +#[test] +fn link_member_to_a_symlink_member_links_only_the_link_made() { + let tmp = TempDir::new().unwrap(); + let mut symlink = own_member("s", EntryType::Symlink, 0o777); + symlink.link_target = Some(PathBuf::from("made-target")); + let mut archive = Members(vec![symlink, link_member("s")].into_iter()); + extract_linked_while_planted(tmp.path(), &mut archive, c"s"); + let g = tmp.path().join("g"); + if std::fs::symlink_metadata(&g).is_ok() { + assert_eq!( + std::fs::read_link(&g).ok(), + Some(PathBuf::from("made-target")), + "g is not the symbolic link made" + ); + } +} + +/// Without a writer in the way, the link member is linked to the symbolic +/// link made: by name, checked by identity and ctime. +#[cfg(target_os = "linux")] +#[test] +fn link_member_to_a_symlink_member_is_linked() { + let tmp = TempDir::new().unwrap(); + let tree = DirTree::open_path(tmp.path()).unwrap(); + let mut pending = PendingDirs::default(); + let mut links = super::Links::new(); + let options = ReadOptions::default(); + let mut symlink = own_member("s", EntryType::Symlink, 0o777); + symlink.link_target = Some(PathBuf::from("made-target")); + let mut archive = Members(vec![symlink, link_member("s")].into_iter()); + while let Some(entry) = archive.read_entry().unwrap() { + extract_entry( + &mut archive, + &entry, + &options, + &mut links, + &tree, + &mut pending, + ) + .unwrap(); + } + let g = std::fs::symlink_metadata(tmp.path().join("g")).unwrap(); + let s = std::fs::symlink_metadata(tmp.path().join("s")).unwrap(); + assert_eq!((g.dev(), g.ino()), (s.dev(), s.ino())); +} + +/// A regular member whose data fails after its file is made still leaves +/// that file, and a link member naming it is linked to it -- not to what is +/// at its name by then. +#[cfg(target_os = "linux")] +#[test] +fn link_member_to_a_failed_member_links_the_file_made() { + let tmp = TempDir::new().unwrap(); + let mut file = own_member("f", EntryType::Regular, 0o644); + file.size = 10; + let mut archive = DataFails(vec![file, link_member("f")].into_iter()); + extract_linked_while_planted(tmp.path(), &mut archive, c"f"); + let g = std::fs::read_to_string(tmp.path().join("g")).unwrap_or_default(); + assert_ne!(g, "planted\n", "g was linked to the planted file"); +} + +/// -i renames a member as it is extracted; a later link member still names +/// it by its name in the archive, and is linked to it at the name it was +/// given -- the file this run made -- not to whatever is at the old name. +#[test] +fn link_member_follows_a_target_renamed_by_i() { + let tmp = TempDir::new().unwrap(); + let tree = DirTree::open_path(tmp.path()).unwrap(); + let mut pending = PendingDirs::default(); + let mut links = super::Links::new(); + let options = ReadOptions::default(); + // The member `f`, renamed to `renamed` at the prompt. + let file = own_member("renamed", EntryType::Regular, 0o644); + links.alias(Path::new("f"), Path::new("renamed")); + let mut archive = Members(vec![file, link_member("f")].into_iter()); + while let Some(entry) = archive.read_entry().unwrap() { + let _ = extract_entry( + &mut archive, + &entry, + &options, + &mut links, + &tree, + &mut pending, + ); + } + let g = std::fs::metadata(tmp.path().join("g")).ok(); + let made = std::fs::metadata(tmp.path().join("renamed")).unwrap(); + assert_eq!( + g.map(|g| (g.dev(), g.ino())), + Some((made.dev(), made.ino())) + ); +} From 32ad203783d813cde0fe250250b4e24b1e52c648 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 22:00:26 +0000 Subject: [PATCH 58/71] pax: record a link member's name only when it holds the file it was linked to extract_hardlink recorded its own name as holding the target's file after link_replacing_with returned, but under -k a name already taken is left alone and the call still succeeds. A later link member naming that name was then linked, through the shared pin, to the earlier target rather than to the file the name holds. The name is now recorded only when it holds the target's file. Where -k kept another file there, a later link member naming it is linked to that file by name, as GNU tar and cp -n link to what is there; the comment says so. Test: under -k, a pre-existing g, a link member g naming f, and a link member h naming g: h must be the file g holds (before: h was f). Co-Authored-By: Claude Opus 5.5 --- pax/modes/read.rs | 16 +++++++++++----- pax/modes/read/race_tests.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/pax/modes/read.rs b/pax/modes/read.rs index 203a17920..c7678cc67 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -658,7 +658,8 @@ fn extract_hardlink( // To the file this run extracted at the target's name, pinned, when it // made one: someone who can write that directory can have put another // file at the name since. A target this run did not make is linked by - // name. + // name -- under -k, that includes a file someone put at the name before + // the run, which is what GNU tar and cp -n link to as well. let target_key = target_member.key(); link_replacing_with( target_parent.as_raw_fd(), @@ -669,14 +670,19 @@ fn extract_hardlink( name, options.no_clobber, )?; - // This name holds that file now, for a later member linked to it; and - // linking it changed the file's ctime. + // Linking it changed the file's ctime. And this name holds that file + // now, for a later member linked to it -- unless -k left another file + // there. let Some(target) = links.made.get_mut(&target_key) else { return Ok(()); }; target.linked(dirfd, name); - let shared = target.share(); - links.record_made(member.key(), shared); + let holds_it = lstat_at(dirfd.as_raw_fd(), name) + .is_ok_and(|st| crate::modes::anchored::file_id(&st) == target.id()); + if holds_it { + let shared = target.share(); + links.record_made(member.key(), shared); + } Ok(()) } diff --git a/pax/modes/read/race_tests.rs b/pax/modes/read/race_tests.rs index f130aa402..987adc1f8 100644 --- a/pax/modes/read/race_tests.rs +++ b/pax/modes/read/race_tests.rs @@ -570,3 +570,39 @@ fn link_member_follows_a_target_renamed_by_i() { Some((made.dev(), made.ino())) ); } + +/// Under -k a link member whose name is taken leaves that file alone, and +/// its name is not the file it names: a later link member naming it is +/// linked to the file that was there, not to the earlier member's target. +#[test] +fn link_member_kept_by_k_is_not_recorded_as_its_target() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("g"), "was here\n").unwrap(); + let tree = DirTree::open_path(tmp.path()).unwrap(); + let mut pending = PendingDirs::default(); + let mut links = super::Links::new(); + let options = ReadOptions { + no_clobber: true, + ..Default::default() + }; + let file = own_member("f", EntryType::Regular, 0o644); + let mut h = link_member("g"); + h.path = PathBuf::from("h"); + let mut archive = Members(vec![file, link_member("f"), h].into_iter()); + while let Some(entry) = archive.read_entry().unwrap() { + let _ = extract_entry( + &mut archive, + &entry, + &options, + &mut links, + &tree, + &mut pending, + ); + } + let id = |name: &str| { + let md = std::fs::metadata(tmp.path().join(name)).unwrap(); + (md.dev(), md.ino()) + }; + assert_ne!(id("g"), id("f"), "-k replaced g"); + assert_eq!(id("h"), id("g"), "h is not the file g holds"); +} From fe146bd07e83c2d88b0c969d3014868d1ff8ea8b Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 22:00:43 +0000 Subject: [PATCH 59/71] plib: say that an empty /proc found before procfs is mounted is kept c51a0313's comment said "/proc not mounted yet" is retried. Not quite: where procfs is not mounted, /proc is usually an empty directory on the root filesystem (a chroot or a container), which opens, fails the procfs check, and is kept as "not procfs" for the rest of the process. Only a failure to open or fstatfs it -- a full descriptor table, no /proc at all -- is retried. The comment now says that. Co-Authored-By: Claude Opus 5.5 --- plib/src/madefs.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index 97303457d..8bf931e90 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -1189,9 +1189,11 @@ pub fn chmod_pinned(fd: RawFd, mode: libc::mode_t) -> io::Result<()> { /// the descriptor still reaches its own `fd/N`. It is `O_CLOEXEC`, so nothing exec'd inherits /// it. /// -/// Only a verified answer is kept: the procfs descriptor, or a `/proc` verified to be something -/// else. A failure that may pass -- a descriptor table full (EMFILE, ENFILE), `/proc` not -/// mounted yet -- is returned, and the next call tries again. +/// Only a verified answer is kept: the procfs descriptor, or a `/proc` opened and found to be +/// something else -- an empty directory on the root filesystem of a chroot or container where +/// procfs is not mounted (yet) included, which is then kept for the rest of the process. A +/// failure to open or `fstatfs` it -- a descriptor table full (EMFILE, ENFILE), no `/proc` at +/// all -- is returned, and the next call tries again. #[cfg(target_os = "linux")] pub fn procfs_dir() -> io::Result> { // `None`: `/proc` was opened, and is not procfs. From f986b1b04e808af8f1087c4e80a467dd99c76cfe Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 22:45:53 +0000 Subject: [PATCH 60/71] pax: settle each member's record from what it left, and refuse links to a failed target extract_entry forgot what an earlier member had made at a name at the start of every member, before it was known whether the member would replace that name. A target member whose file could not be made (a non-empty directory in its way), whose node could not be pinned, or which -k skipped as a duplicate -- or a socket member, which makes nothing -- left no record, and a later link member naming it was linked by name, to whatever was at the name by then. Under -k a second copy of a name dropped the first one's pin. Each member now settles its own record once it is done (Links::left): - a file it made is recorded, even if the member then failed; - a member that failed without one leaves a tombstone (Record::Failed), and a link member naming it fails, "link target was not extracted; not linked", rather than link by name; - a member that succeeded without making anything -- -k kept the name, a socket was skipped -- leaves the name, and its record, as they were. A directory is never a link target: it leaves a tombstone unless -k kept the name. A node that was made always yields its MadeFile now, so "succeeded without making anything" cannot hide a node made but not known. Linking by name remains only for names the run never tried to extract (-u, -n, a pattern, absent from the archive). Tests (race_tests): a link member naming a target whose file was blocked by a non-empty directory is not linked to a file planted there (before: it was); under -k, a link member after a duplicate of its target is not linked to a planted file (before: it was). Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 6 ++ pax/modes/pins.rs | 1 + pax/modes/read.rs | 117 ++++++++++++++++++++++++----------- pax/modes/read/race_tests.rs | 65 +++++++++++++++++++ 4 files changed, 154 insertions(+), 35 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 982a54af1..567b3c68f 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -2061,6 +2061,12 @@ pub(crate) fn set_made_node_attrs_recording( let node = MadeNode::pin(dirfd, name, made_type)?; let applied = apply_node_attrs(&node, made_type, attrs, policy); *made = node.made_file(); + // A node made is always known; one that cannot be is a failure, never a + // name left as it was. + if made.is_none() { + applied?; + return Err(PaxError::Io(made::replaced())); + } applied } diff --git a/pax/modes/pins.rs b/pax/modes/pins.rs index ab21d2e55..37063866d 100644 --- a/pax/modes/pins.rs +++ b/pax/modes/pins.rs @@ -62,6 +62,7 @@ impl MadeFile { #[cfg(target_os = "linux")] pub(crate) fn held(pin: BorrowedFd<'_>) -> io::Result { let st = fstat(pin.as_raw_fd())?; + // Without a duplicate it is known by identity and ctime alone. let dup = unsafe { libc::fcntl(pin.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) }; Ok(MadeFile { id: file_id(&st), diff --git a/pax/modes/read.rs b/pax/modes/read.rs index c7678cc67..48625df90 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -383,12 +383,17 @@ fn extract_entry( let _ = std::io::Write::write_all(&mut std::io::stderr().lock(), &line); } - // Whatever this member puts at its name replaces what an earlier one made - // there; only a regular file or a link to one records it again. - links.forget(&member.key()); + // Each arm settles what it left at its name (`Links::left`): a file this + // run made, nothing it can vouch for, or the name as it was. match entry.entry_type { EntryType::Directory => { - let decided = extract_directory(tree, pfd, &member, entry, options)?; + let decided = extract_directory(tree, pfd, &member, entry, options); + // A directory is nothing a link member can be linked to; -k + // leaves the name as it was. + if !matches!(decided, Ok(DirAttrs::Keep)) { + links.left(&member, true, None); + } + let decided = decided?; archive.skip_data()?; match decided { // Its attributes are applied once the subtree exists, if it @@ -401,12 +406,16 @@ fn extract_entry( EntryType::Symlink => { let mut made = None; let result = extract_symlink(pfd, name, entry, options, &mut made); - links.record(&member, made); + links.left(&member, result.is_err(), made); result?; archive.skip_data()?; } EntryType::Hardlink => { - extract_hardlink(tree, pfd, &member, entry, options, links)?; + let result = extract_hardlink(tree, pfd, &member, entry, options, links); + if result.is_err() { + links.left(&member, true, None); + } + result?; archive.skip_data()?; } EntryType::Regular => { @@ -416,14 +425,14 @@ fn extract_entry( EntryType::BlockDevice | EntryType::CharDevice => { let mut made = None; let result = extract_device(pfd, name, entry, options, &mut made); - links.record(&member, made); + links.left(&member, result.is_err(), made); result?; archive.skip_data()?; } EntryType::Fifo => { let mut made = None; let result = extract_fifo(pfd, name, entry, options, &mut made); - links.record(&member, made); + links.left(&member, result.is_err(), made); result?; archive.skip_data()?; } @@ -524,15 +533,25 @@ fn extract_symlink( /// within one budget. struct Links { sets: LinkSets, - /// The file this run made at each member path (`MemberPath::key`) -- a - /// tar link member's target -- known from the descriptor that made it. - made: HashMap, MadeFile>, + /// What this run left at each member path (`MemberPath::key`) it tried + /// to extract -- a tar link member's target. + made: HashMap, Record>, pins: PinBudget, /// The name -i gave each member it renamed, by the member path /// (`MemberPath::key`) it had before. aliases: HashMap, PathBuf>, } +/// What this run left at a member path it tried to extract. +enum Record { + /// The file it made there, known from the descriptor that made it. + File(MadeFile), + /// Nothing it can vouch for: the member failed before a file of this + /// run's was verified at its name, which may hold anything now. A link + /// member naming it fails rather than link whatever that is. + Failed, +} + /// What holds a pin in `Links`. #[derive(PartialEq, Eq, Debug)] enum PinKey { @@ -567,10 +586,43 @@ impl Links { /// Record `made` as the file this run made at the member path `key`. fn record_made(&mut self, key: Vec, made: MadeFile) { let pinned = made.is_pinned(); - self.made.insert(key.clone(), made); + self.made.insert(key.clone(), Record::File(made)); self.note(PinKey::Member(key), pinned); } + /// Settle what a member at `member`, which `failed` or not, left at its + /// name: the file it made (`made`), even if it then failed; nothing this + /// run can vouch for, when it failed without one; and, when it succeeded + /// without making one -- -k kept the name, a socket was skipped -- the + /// name is as it was, and so is its record. + fn left(&mut self, member: &MemberPath, failed: bool, made: Option) { + match made { + Some(made) => self.record_made(member.key(), made), + None if failed => self.tombstone(member.key()), + None => {} + } + } + + /// Record that the member at `key` left nothing this run can vouch for. + fn tombstone(&mut self, key: Vec) { + self.made.insert(key.clone(), Record::Failed); + self.note(PinKey::Member(key), false); + } + + /// What a link member is to be linked to at the member path `key`: the + /// file this run made there; `None` for a name this run never tried to + /// extract, which is linked by name; an error for one it tried and left + /// nothing it can vouch for at. + fn target(&self, key: &[u8]) -> PaxResult>> { + match self.made.get(key) { + Some(Record::File(made)) => Ok(Some(made.expected())), + Some(Record::Failed) => Err(PaxError::Io(std::io::Error::other( + "link target was not extracted; not linked", + ))), + None => Ok(None), + } + } + /// Note that -i renamed the member named `before` (after -s) to `after`. fn alias(&mut self, before: &Path, after: &Path) { if let Ok(Some(before)) = MemberPath::parse(before) { @@ -587,15 +639,7 @@ impl Links { .unwrap_or(target) } - /// Record what this run made for `member`, if anything. - fn record(&mut self, member: &MemberPath, made: Option) { - if let Some(made) = made { - self.record_made(member.key(), made); - } - } - - /// Forget what this run made at the member path `key`: something else is - /// being put there. + /// Forget what this run made at the member path `key`. fn forget(&mut self, key: &[u8]) { if self.made.remove(key).is_some() { self.note(PinKey::Member(key.to_vec()), false); @@ -615,7 +659,7 @@ impl Links { } } PinKey::Member(key) => { - if let Some(made) = made.get_mut(key) { + if let Some(Record::File(made)) = made.get_mut(key) { made.unpin(); } } @@ -665,15 +709,16 @@ fn extract_hardlink( target_parent.as_raw_fd(), &target_member.leaf, None, - links.made.get(&target_key).map(MadeFile::expected), + links.target(&target_key)?, dirfd, name, options.no_clobber, )?; // Linking it changed the file's ctime. And this name holds that file // now, for a later member linked to it -- unless -k left another file - // there. - let Some(target) = links.made.get_mut(&target_key) else { + // there. Linked by name, it holds what the name held. + let Some(Record::File(target)) = links.made.get_mut(&target_key) else { + links.forget(&member.key()); return Ok(()); }; target.linked(dirfd, name); @@ -756,7 +801,12 @@ fn extract_regular( links: &mut Links, ) -> PaxResult<()> { if let Some(set) = links.sets.find_mut(entry) { - return join_link_set(archive, tree, dirfd, member, entry, options, set); + let result = join_link_set(archive, tree, dirfd, member, entry, options, set); + match result { + Err(_) => links.left(member, true, None), + Ok(()) => links.forget(&member.key()), + } + return result; } let mut made = None; @@ -770,16 +820,13 @@ fn extract_regular( ); // A file made is recorded for a later link member to name, even when its // data or attributes then failed: it is at that name all the same. - if let Some(made) = made { - if result.is_ok() { - let file = made.id(); - links.sets.insert(entry, || { - let names = vec![member.display.clone()]; - CreatedSet::new(names, file, entry.size > 0, dirfd, &member.leaf) - }); - } - links.record_made(member.key(), made); + if let Some(file) = made.as_ref().map(MadeFile::id).filter(|_| result.is_ok()) { + links.sets.insert(entry, || { + let names = vec![member.display.clone()]; + CreatedSet::new(names, file, entry.size > 0, dirfd, &member.leaf) + }); } + links.left(member, result.is_err(), made); result } diff --git a/pax/modes/read/race_tests.rs b/pax/modes/read/race_tests.rs index 987adc1f8..a78dde0ee 100644 --- a/pax/modes/read/race_tests.rs +++ b/pax/modes/read/race_tests.rs @@ -606,3 +606,68 @@ fn link_member_kept_by_k_is_not_recorded_as_its_target() { assert_ne!(id("g"), id("f"), "-k replaced g"); assert_eq!(id("h"), id("g"), "h is not the file g holds"); } + +/// Extract `archive` below `dest` under `options`, a writer putting the +/// file `planted` at `target` just before any link to it is made. +#[cfg(target_os = "linux")] +fn extract_with_plant_at_link( + dest: &Path, + archive: &mut R, + options: &ReadOptions, + target: &'static str, +) { + let tree = DirTree::open_path(dest).unwrap(); + let mut pending = PendingDirs::default(); + let mut links = super::Links::new(); + let path = dest.to_path_buf(); + let hook = move |point, _: libc::c_int, name: &CStr| { + if point == Point::Linking && name.to_bytes() == target.as_bytes() { + let at = path.join(target); + if at.is_dir() { + std::fs::remove_dir_all(&at).unwrap(); + } + std::fs::write(path.join("planted"), "planted\n").unwrap(); + std::fs::rename(path.join("planted"), &at).unwrap(); + } + }; + race_hook::with_hook(hook, || { + while let Some(entry) = archive.read_entry().unwrap() { + let _ = extract_entry(archive, &entry, options, &mut links, &tree, &mut pending); + } + }); +} + +/// A target member whose file could not be made -- a non-empty directory in +/// its way -- leaves no file of this run's at its name, and a link member +/// naming it fails: linked by name it took whatever was put there by then. +#[cfg(target_os = "linux")] +#[test] +fn link_member_to_a_target_that_failed_is_refused() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join("a")).unwrap(); + std::fs::write(tmp.path().join("a/inside"), "x").unwrap(); + let file = own_member("a", EntryType::Regular, 0o644); + let mut archive = Members(vec![file, link_member("a")].into_iter()); + extract_with_plant_at_link(tmp.path(), &mut archive, &ReadOptions::default(), "a"); + let g = std::fs::read_to_string(tmp.path().join("g")).unwrap_or_default(); + assert_ne!(g, "planted\n", "g was linked to what was put at the name"); +} + +/// Under -k a second member of a name already extracted is skipped, and +/// leaves the first one's file -- and its pin -- in place: a link member +/// naming it is linked to that file. +#[cfg(target_os = "linux")] +#[test] +fn link_member_after_a_k_duplicate_links_the_first_file() { + let tmp = TempDir::new().unwrap(); + let file = own_member("a", EntryType::Regular, 0o644); + let again = own_member("a", EntryType::Regular, 0o644); + let mut archive = Members(vec![file, again, link_member("a")].into_iter()); + let options = ReadOptions { + no_clobber: true, + ..Default::default() + }; + extract_with_plant_at_link(tmp.path(), &mut archive, &options, "a"); + let g = std::fs::read_to_string(tmp.path().join("g")).unwrap_or_default(); + assert_ne!(g, "planted\n", "g was linked to what was put at the name"); +} From 61efa31b929f98f9f5f3dae9a3d5e46f8ea5dd5c Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 22:50:01 +0000 Subject: [PATCH 61/71] pax: hold a cpio link set's file as a MadeFile, linked by pin or identity and ctime A cpio link set knew its file by (st_dev, st_ino) alone. Its later names were linked to a holder name checked by that identity (Expected::id); the first name's pin -- opened by name, O_RDONLY, by pin_file -- could be closed by the budget or on settling, after which an inode number reused for someone else's file at a holder name passed. The files a newc data-carrying name or a fill put in place were never recorded. CreatedSet now holds the set's file as a MadeFile: its identity, ctime and the pin reopened from the descriptor that made it. Every link to it -- a later name joining the set, the earlier names moved over to the file carrying the data -- passes its expected(), so it is linked through the pin, or by name only where identity and ctime still match, and each link refreshes the ctime. When the set's file is replaced by a join or a fill, every name of the set is recorded as holding the new file. This also does F7: with the made file's own pin held by the set, pin_file -- an O_RDONLY open of the set file by name -- is gone, and so is its test that the open never adopted a terminal; an O_PATH reopen through procfs cannot. Test (race_tests): a later name of a set whose pin is closed, with a file at the earlier name that has the set file's number (simulated) but a later ctime, is not linked to it (before: it was). Co-Authored-By: Claude Opus 5.5 --- pax/modes/pins.rs | 12 ++- pax/modes/read.rs | 145 ++++++++++++++++++----------------- pax/modes/read/race_tests.rs | 82 +++++++++++++++----- 3 files changed, 145 insertions(+), 94 deletions(-) diff --git a/pax/modes/pins.rs b/pax/modes/pins.rs index 37063866d..f114dfb50 100644 --- a/pax/modes/pins.rs +++ b/pax/modes/pins.rs @@ -73,7 +73,7 @@ impl MadeFile { /// A file this run has just made, known only by the status `st` -- where /// nothing holds it. - #[cfg(not(target_os = "linux"))] + #[cfg(any(test, not(target_os = "linux")))] pub(crate) fn unpinned(st: &libc::stat) -> Self { MadeFile { id: file_id(st), @@ -126,6 +126,16 @@ impl MadeFile { } } + /// The same file, known by identity and ctime alone, for a record that + /// holds no pin of its own. + pub(crate) fn known(&self) -> Self { + MadeFile { + id: self.id, + ctime: self.ctime, + pin: None, + } + } + /// The same file, known again for another of its names: a pin of its own /// where this one has one and it can be duplicated. pub(crate) fn share(&self) -> Self { diff --git a/pax/modes/read.rs b/pax/modes/read.rs index 48625df90..7e4e9a95e 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -175,6 +175,9 @@ fn extract_members( report_unless_fatal(&entry, r)?; } else if let Some(set) = links.sets.find_mut(&entry) { let r = fill_link_set(archive, tree, &entry, options, set); + if r.is_ok() { + links.record_set(&entry); + } report_unless_fatal(&entry, r)?; } archive.skip_data()?; @@ -579,7 +582,7 @@ impl Links { let Some(set) = self.sets.by_key_mut(key) else { return; }; - let pinned = set.pin.is_some(); + let pinned = set.file.is_pinned(); self.note(PinKey::Set(key), pinned); } @@ -639,6 +642,25 @@ impl Links { .unwrap_or(target) } + /// Record the file of the set `entry` is a name of for every name the set + /// has now: a join or a fill has just put it there. + fn record_set(&mut self, entry: &ArchiveEntry) { + let Some(set) = LinkSets::::key(entry).and_then(|k| self.sets.by_key_mut(k)) + else { + return; + }; + let file = set.file.known(); + let keys: Vec> = set + .names + .iter() + .filter_map(|path| MemberPath::parse(path).ok().flatten()) + .map(|member| member.key()) + .collect(); + for key in keys { + self.record_made(key, file.known()); + } + } + /// Forget what this run made at the member path `key`. fn forget(&mut self, key: &[u8]) { if self.made.remove(key).is_some() { @@ -736,43 +758,34 @@ fn extract_hardlink( struct CreatedSet { /// The names created for the set so far, as extracted (after -s and -i). names: Vec, - /// (st_dev, st_ino) of the file they share on disk -- not the archive's - /// c_dev/c_ino. - file: (u64, u64), + /// The file they share on disk (`MadeFile`): its (st_dev, st_ino) -- not + /// the archive's c_dev/c_ino -- and ctime, and its pin, the descriptor + /// that made it reopened, held while data may still come for it on a + /// later name (`LinkSets::settled_mut`), within the budget + /// (`Links::pins`). Its names can be replaced by other members meanwhile, + /// and a filesystem that reuses inode numbers (ext4) then hands this + /// file's number to the next file created. Pinned, it keeps its number; + /// unpinned, its ctime tells it from that file. + file: MadeFile, /// Whether that file has its contents yet. newc stores them with the last /// name of a set only, so the earlier names are created empty. has_data: bool, - /// The file held open while data may still come for it on a later name - /// (`LinkSets::settled_mut`), within the budget (`Links::pins`). Its names - /// can be replaced by other members meanwhile, and a filesystem that - /// reuses inode numbers (ext4) then hands this file's number to the next - /// file created, which `file` would take for this one. Open, it keeps its - /// number. - pin: Option, } impl CreatedSet { - /// The set as created at `name` below `dirfd`: pinned when created empty, - /// until the extract loop finds no data can still come for it (`unpin`). - fn new( - names: Vec, - file: (u64, u64), - has_data: bool, - dirfd: BorrowedFd<'_>, - name: &CStr, - ) -> Self { - let pin = (!has_data).then(|| pin_file(dirfd, name, file)).flatten(); + /// The set of `file`, as created at `names`: pinned, until the extract + /// loop finds no data can still come for it (`unpin`). + fn new(names: Vec, file: MadeFile, has_data: bool) -> Self { CreatedSet { names, file, has_data, - pin, } } /// Close the pin once no data can still come for the set. fn unpin(&mut self) { - self.pin = None; + self.file.unpin(); } /// The names that still hold the set's file, each with the directory and @@ -781,7 +794,7 @@ impl CreatedSet { fn holders(&self, tree: &DirTree) -> Vec<((Rc, CString), PathBuf)> { self.names .iter() - .filter_map(|path| holding_name(tree, path, self.file).map(|h| (h, path.clone()))) + .filter_map(|path| holding_name(tree, path, self.file.id()).map(|h| (h, path.clone()))) .collect() } } @@ -804,7 +817,7 @@ fn extract_regular( let result = join_link_set(archive, tree, dirfd, member, entry, options, set); match result { Err(_) => links.left(member, true, None), - Ok(()) => links.forget(&member.key()), + Ok(()) => links.record_set(entry), } return result; } @@ -820,12 +833,22 @@ fn extract_regular( ); // A file made is recorded for a later link member to name, even when its // data or attributes then failed: it is at that name all the same. - if let Some(file) = made.as_ref().map(MadeFile::id).filter(|_| result.is_ok()) { - links.sets.insert(entry, || { - let names = vec![member.display.clone()]; - CreatedSet::new(names, file, entry.size > 0, dirfd, &member.leaf) - }); - } + // The first name of a cpio link set: the set holds the file's pin, and + // the name's record knows it by identity and ctime. + let starts_set = result.is_ok() + && LinkSets::::key(entry) + .is_some_and(|key| links.sets.by_key_mut(key).is_none()); + let made = match made { + Some(file) if starts_set => { + let known = file.known(); + links.sets.insert(entry, || { + let names = vec![member.display.clone()]; + CreatedSet::new(names, file, entry.size > 0) + }); + Some(known) + } + made => made, + }; links.left(member, result.is_err(), made); result } @@ -854,7 +877,7 @@ fn join_link_set( let holder = set .names .iter() - .find_map(|path| holding_name(tree, path, set.file)); + .find_map(|path| holding_name(tree, path, set.file.id())); if let Some((src_dir, src_leaf)) = holder { // To the set's file, pinned: the holder was found holding it, but // its name can be given another file before the link is made. @@ -862,14 +885,15 @@ fn join_link_set( src_dir.as_raw_fd(), &src_leaf, None, - Some(Expected::id(set.file)), + Some(set.file.expected()), dirfd, name, options.no_clobber, )?; + set.file.linked(dirfd, name); // -k leaves an existing name alone, and that name is no part of // the set. - if id_at(dirfd, name) == Some(set.file) { + if id_at(dirfd, name) == Some(set.file.id()) { set.names.push(member.display.clone()); } return Ok(()); @@ -879,15 +903,15 @@ fn join_link_set( let holders = set.holders(tree); let mut made = None; extract_file(archive, dirfd, name, entry, options, &mut made)?; - let Some(file) = made.as_ref().map(MadeFile::id) else { + let Some(mut file) = made else { // -k kept what was there; the data is still the earlier names'. return fill_link_set(archive, tree, entry, options, set); }; - let mut names = move_names_to(holders, dirfd, name, file)?; + let mut names = move_names_to(holders, dirfd, name, &mut file)?; names.push(member.display.clone()); // Created empty when no earlier name survived to link to: the data is // then still to come, on a later name. - *set = CreatedSet::new(names, file, entry.size > 0, dirfd, name); + *set = CreatedSet::new(names, file, entry.size > 0); Ok(()) } @@ -916,21 +940,14 @@ fn fill_link_set( let (temp, file) = create_temp_file(dir.as_fd(), entry, options)?; let mut made = None; let filled = write_file_data(archive, file, entry, options, &mut made).and_then(|()| { - let id = made - .as_ref() - .map(MadeFile::id) - .ok_or_else(crate::modes::made::replaced)?; - Ok((id, move_names_to(holders, dir.as_fd(), &temp, id)?)) + let mut made = made.ok_or_else(crate::modes::made::replaced)?; + let names = move_names_to(holders, dir.as_fd(), &temp, &mut made)?; + Ok((made, names)) }); let removed = unlink_at(dir.as_fd(), &temp); let (file, names) = filled?; removed?; - *set = CreatedSet { - names, - file, - has_data: true, - pin: None, - }; + *set = CreatedSet::new(names, file, true); Ok(()) } @@ -941,7 +958,7 @@ fn move_names_to( holders: Vec<((Rc, CString), PathBuf)>, dirfd: BorrowedFd<'_>, name: &CStr, - file: (u64, u64), + file: &mut MadeFile, ) -> PaxResult> { let mut names = Vec::with_capacity(holders.len() + 1); for ((dir, leaf), path) in holders { @@ -953,11 +970,12 @@ fn move_names_to( from, name, None, - Some(Expected::id(file)), + Some(file.expected()), dir.as_fd(), &leaf, false, )?; + file.linked(dir.as_fd(), &leaf); names.push(path); } Ok(names) @@ -970,20 +988,6 @@ fn holding_name(tree: &DirTree, path: &Path, file: (u64, u64)) -> Option<(Rc, name: &CStr, file: (u64, u64)) -> Option { - let flags = - libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_NOCTTY | libc::O_CLOEXEC; - let fd = unsafe { libc::openat(dirfd.as_raw_fd(), name.as_ptr(), flags) }; - if fd < 0 { - return None; - } - let file_held = std::fs::File::from(unsafe { OwnedFd::from_raw_fd(fd) }); - (file_id(&file_held.metadata().ok()?) == file).then(|| file_held.into()) -} - /// (st_dev, st_ino) of a name below `dirfd`, not following a symlink. fn id_at(dirfd: BorrowedFd<'_>, name: &CStr) -> Option<(u64, u64)> { lstat_at(dirfd.as_raw_fd(), name) @@ -991,12 +995,6 @@ fn id_at(dirfd: BorrowedFd<'_>, name: &CStr) -> Option<(u64, u64)> { .map(|st| crate::modes::anchored::file_id(&st)) } -/// (st_dev, st_ino) of an open file. -fn file_id(meta: &std::fs::Metadata) -> (u64, u64) { - use std::os::unix::fs::MetadataExt; - (meta.dev(), meta.ino()) -} - /// Extract a block or character device (requires root privileges) fn extract_device( dirfd: BorrowedFd<'_>, @@ -1314,7 +1312,8 @@ mod tests { std::fs::write(dir.path().join("new"), "data\n").unwrap(); std::fs::write(dir.path().join("old"), "").unwrap(); let tree = DirTree::open_path(dir.path()).unwrap(); - let made = id_at(tree.root(), c"new").unwrap(); + let new_st = lstat_at(tree.root().as_raw_fd(), c"new").unwrap(); + let mut made = MadeFile::unpinned(&new_st); let root = Rc::new(tree.root().try_clone_to_owned().unwrap()); let holders = vec![((Rc::clone(&root), c"old".to_owned()), PathBuf::from("old"))]; @@ -1325,7 +1324,9 @@ mod tests { std::fs::rename(path.join("planted"), path.join("new")).unwrap(); } }; - let moved = with_hook(swap, || move_names_to(holders, root.as_fd(), c"new", made)); + let moved = with_hook(swap, || { + move_names_to(holders, root.as_fd(), c"new", &mut made) + }); assert!(moved.is_err(), "linked to the file put in its place"); assert_eq!(std::fs::read(dir.path().join("old")).unwrap(), b""); } diff --git a/pax/modes/read/race_tests.rs b/pax/modes/read/race_tests.rs index a78dde0ee..0e96045b3 100644 --- a/pax/modes/read/race_tests.rs +++ b/pax/modes/read/race_tests.rs @@ -352,27 +352,6 @@ fn made_directories_take_their_mode_and_an_existing_one_keeps_its_own() { assert!(dest.join("e/kept").exists()); } -/// A terminal found where a link set's first name was made is not pinned -- -/// and must not become the controlling terminal of a pax that has none in -/// the open before the identity check refuses it. -#[cfg(target_os = "linux")] -#[test] -fn pin_file_never_adopts_a_terminal() { - if !race_hook::in_new_session() { - race_hook::rerun_in_new_session( - "modes::read::race_tests::pin_file_never_adopts_a_terminal", - ); - return; - } - let (_master, pts, slave) = race_hook::open_pty(); - assert!(!race_hook::has_controlling_tty()); - assert!(pin_file(pts.as_fd(), &slave, (0, 0)).is_none()); - assert!( - !race_hook::has_controlling_tty(), - "pinning the file made it the controlling terminal" - ); -} - /// A directory this run made for one member, renamed by someone else to the /// name of a later member, is not that member's directory: it is met as one /// found existing, and in a shared destination keeps its own attributes @@ -671,3 +650,64 @@ fn link_member_after_a_k_duplicate_links_the_first_file() { let g = std::fs::read_to_string(tmp.path().join("g")).unwrap_or_default(); assert_ne!(g, "planted\n", "g was linked to what was put at the name"); } + +/// A later name of a cpio link set is linked to the set's file, found at an +/// earlier name. Once the set's pin is closed, a file of someone else's at +/// that name with the set file's inode number -- the number reused, +/// simulated by giving the record the planted file's number -- is told +/// apart by its ctime, and not linked. +#[cfg(target_os = "linux")] +#[test] +fn link_set_name_is_not_linked_to_a_file_with_a_reused_number() { + use crate::modes::pins::{ctime_of, MadeFile}; + let tmp = TempDir::new().unwrap(); + let tree = DirTree::open_path(tmp.path()).unwrap(); + let root = tree.root(); + std::fs::write(tmp.path().join("a"), "set\n").unwrap(); + let made_st = plib::madefs::lstat_at(root.as_raw_fd(), c"a").unwrap(); + std::fs::write(tmp.path().join("planted"), "planted\n").unwrap(); + // A later clock tick for the planted file, as a file made after the + // set's was removed would have. + let planted = tmp.path().join("planted"); + let mut planted_st = plib::madefs::lstat_at(root.as_raw_fd(), c"planted").unwrap(); + for _ in 0..200 { + if ctime_of(&planted_st) != ctime_of(&made_st) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + let perms = std::fs::metadata(&planted).unwrap().permissions(); + std::fs::set_permissions(&planted, perms).unwrap(); + planted_st = plib::madefs::lstat_at(root.as_raw_fd(), c"planted").unwrap(); + } + std::fs::rename(&planted, tmp.path().join("a")).unwrap(); + // The record: the planted file's number, the set file's ctime. + let mut reused = made_st; + reused.st_dev = planted_st.st_dev; + reused.st_ino = planted_st.st_ino; + let mut set = CreatedSet { + names: vec![PathBuf::from("a")], + file: MadeFile::unpinned(&reused), + has_data: true, + }; + let entry = ArchiveEntry { + nlink: 2, + ..own_member("b", EntryType::Regular, 0o644) + }; + let member = MemberPath::parse(Path::new("b")).unwrap().unwrap(); + let mut archive = Members(Vec::new().into_iter()); + let options = ReadOptions::default(); + let _ = join_link_set( + &mut archive, + &tree, + root, + &member, + &entry, + &options, + &mut set, + ); + let b = std::fs::read_to_string(tmp.path().join("b")).unwrap_or_default(); + assert_ne!( + b, "planted\n", + "the later name was linked to the planted file" + ); +} From 314b9ff0b27600b950c187ee827626458db6257b Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 22:52:36 +0000 Subject: [PATCH 62/71] pax: check a symbolic-link link source before linking it, and hold its pin until checked LinkSource::pin returned early for a symbolic link -- which cannot be linked through its self/fd/N entry -- before checking its identity, and dropped the pin it had just opened. The by-name path then checked identity and ctime only where a ctime was known, so a caller passing Expected::id let a link to the wrong file be made and only removed again afterwards; and check_linked compared the identity alone. pin() now checks the pinned source against what is expected first, then hands a symbolic link's pin to the by-name path (Pinning::ByName), which holds it until the link is checked: check_linked requires the new name to show the identity and ctime the pin shows, or removes it and fails. Holding a pin keeps a number in use on a local filesystem only. On NFS another client can remove the file and the server give its number to another file whatever this client holds, and FUSE filesystems need not have stable inode numbers; there the identity and ctime checks are all there is. The comments that said a pin keeps the number unconditionally now say this (anchored.rs LinkSource::pin, pins.rs, CreatedSet::file). Test: a symbolic link whose identity is not the one expected is refused before any link is made -- the race_hook Linked point is never reached (before: a link was made, then removed). Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 124 +++++++++++++++++++++++++++++++++++------- pax/modes/pins.rs | 8 +++ pax/modes/read.rs | 5 +- 3 files changed, 114 insertions(+), 23 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 567b3c68f..3ac900f5b 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -1375,9 +1375,26 @@ enum LinkSource<'a> { from_name: &'a CStr, flags: libc::c_int, expected: Option<(u64, u64)>, + /// A pin of the source, checked to be the file meant, held while it + /// is linked by name -- a symbolic link, which cannot be linked + /// through its pin -- and checked against the link made. + #[cfg(target_os = "linux")] + held: Option>, }, } +/// What `LinkSource::pin` found. +#[cfg(target_os = "linux")] +enum Pinning<'a> { + /// The source, linked through its pin. + Through(LinkSource<'a>), + /// The source's pin, checked; the source is a symbolic link, linked by + /// name while the pin is held. + ByName(PinFd<'a>), + /// No verified procfs: nothing can be linked through a descriptor. + Unavailable, +} + impl<'a> LinkSource<'a> { /// `from_name` in `from_dir`, followed if `follow`, as `expected` knows /// it: the caller's own pin of it, where it holds one and the platform @@ -1389,10 +1406,14 @@ impl<'a> LinkSource<'a> { follow: bool, expected: Option>, ) -> PaxResult { + #[cfg(target_os = "linux")] + let mut held = None; #[cfg(target_os = "linux")] if let Some(expected) = expected { - if let Some(pinned) = Self::pin(from_dir, from_name, follow, expected)? { - return Ok(pinned); + match Self::pin(from_dir, from_name, follow, expected)? { + Pinning::Through(pinned) => return Ok(pinned), + Pinning::ByName(pin) => held = Some(pin), + Pinning::Unavailable => {} } } let src_flags = if follow { 0 } else { libc::AT_SYMLINK_NOFOLLOW }; @@ -1417,22 +1438,25 @@ impl<'a> LinkSource<'a> { from_name, flags, expected: expected.map(|e| e.id), + #[cfg(target_os = "linux")] + held, }) } - /// The source as a descriptor to link through: the caller's pin, or one - /// opened here with `O_PATH` -- following a symbolic link exactly when - /// `linkat` would -- and required to be the file `expected`. `None` - /// without a verified procfs, where no descriptor can be linked. + /// The source as a descriptor: the caller's pin, or one opened here with + /// `O_PATH` -- following a symbolic link exactly when `linkat` would -- + /// and required to be the file `expected` before anything else. Linked + /// through, unless it is a symbolic link; `Unavailable` without a + /// verified procfs. #[cfg(target_os = "linux")] fn pin( from_dir: libc::c_int, from_name: &CStr, follow: bool, expected: Expected<'a>, - ) -> PaxResult> { + ) -> PaxResult> { let Ok(proc_dir) = made::procfs_dir() else { - return Ok(None); + return Ok(Pinning::Unavailable); }; let pin = match expected.pin { Some(pin) => PinFd::Borrowed(pin), @@ -1449,14 +1473,6 @@ impl<'a> LinkSource<'a> { // A pin the caller holds is the file itself; one opened by name must // show the identity, and the ctime where known, of the file meant. let st = fstat(pin.as_raw_fd()).map_err(|_| source_changed())?; - // A symbolic link cannot be linked through its `self/fd/N` entry: - // `AT_SYMLINK_FOLLOW` goes on through the link to what it names. It - // is linked by name, checked by identity and ctime before and after; - // a pin the caller holds keeps its number from being reused - // meanwhile. - if st.st_mode & libc::S_IFMT == libc::S_IFLNK && !follow { - return Ok(None); - } let is_expected = match pin { PinFd::Borrowed(_) => file_id(&st) == expected.id, PinFd::Owned(_) => expected.matches(&st), @@ -1464,7 +1480,18 @@ impl<'a> LinkSource<'a> { if !is_expected { return Err(source_changed()); } - Ok(Some(LinkSource::Pinned { proc_dir, pin })) + // A symbolic link cannot be linked through its `self/fd/N` entry: + // `AT_SYMLINK_FOLLOW` goes on through the link to what it names. It + // is linked by name, checked before and after, while the pin is + // held: on a local filesystem that keeps its number from being + // reused meanwhile. Not on NFS, where another client can remove the + // file and the server give its number to another whatever this + // client holds, nor on a FUSE filesystem without stable inode + // numbers: there the checks are all there is. + if st.st_mode & libc::S_IFMT == libc::S_IFLNK && !follow { + return Ok(Pinning::ByName(pin)); + } + Ok(Pinning::Through(LinkSource::Pinned { proc_dir, pin })) } /// The source's `(st_dev, st_ino)`: the pinned inode's, or the one the @@ -1500,14 +1527,34 @@ impl<'a> LinkSource<'a> { /// After `link_to` made `name`: a pinned source can only have linked the /// file it pins. A name, resolved again, may have linked another file: - /// unless the link is the file expected (when given), remove it again - /// and fail. The residual: a writer of the destination who renames the - /// link away before this check keeps it. + /// unless the link is the file expected (when given) -- and, where a pin + /// of it is held, shows the identity and ctime the pin shows -- remove it + /// again and fail. The residual: a writer of the destination who renames + /// the link away before this check keeps it. fn check_linked(&self, dirfd: BorrowedFd<'_>, name: &CStr) -> PaxResult<()> { match self { #[cfg(target_os = "linux")] LinkSource::Pinned { .. } => Ok(()), - LinkSource::Name { expected, .. } => linked_expected(dirfd, name, *expected), + LinkSource::Name { expected, .. } => { + linked_expected(dirfd, name, *expected)?; + #[cfg(target_os = "linux")] + if let LinkSource::Name { + held: Some(pin), .. + } = self + { + let as_pinned = fstat(pin.as_raw_fd()) + .ok() + .map(|st| (file_id(&st), crate::modes::pins::ctime_of(&st))); + let as_linked = lstat_at(dirfd.as_raw_fd(), name) + .ok() + .map(|st| (file_id(&st), crate::modes::pins::ctime_of(&st))); + if as_pinned.is_none() || as_pinned != as_linked { + unlink_at(dirfd, name)?; + return Err(source_changed()); + } + } + Ok(()) + } } } } @@ -2488,6 +2535,41 @@ mod tests { assert_eq!(id(c"d"), target, "d does not hold the link's target"); } + /// A symbolic link to be linked by name is checked to be the one meant + /// before any link is made. It was let through to the by-name link + /// before its identity was checked, and only the check after the link + /// removed a link to the wrong one. + #[cfg(target_os = "linux")] + #[test] + fn test_a_symlink_source_is_checked_before_it_is_linked() { + use crate::modes::race_hook::{with_hook, Point}; + let dir = plib::tmp::TempDir::new().unwrap(); + std::os::unix::fs::symlink("t", dir.path().join("l")).unwrap(); + std::fs::write(dir.path().join("other"), "x").unwrap(); + let tree = DirTree::open_path(dir.path()).unwrap(); + let root = tree.root(); + let other = file_id(&lstat_at(root.as_raw_fd(), c"other").unwrap()); + + let linked = std::rc::Rc::new(std::cell::Cell::new(0)); + let count = std::rc::Rc::clone(&linked); + let hook = move |point, _: libc::c_int, _: &CStr| { + if point == Point::Linked { + count.set(count.get() + 1); + } + }; + let expected = Some(Expected::id(other)); + let r = with_hook(hook, || { + link_replacing_with(root.as_raw_fd(), c"l", None, expected, root, c"g", false) + }); + assert!(r.is_err()); + assert_eq!( + linked.get(), + 0, + "a link was made before the source was checked" + ); + assert!(lstat_at(root.as_raw_fd(), c"g").is_err()); + } + /// -k leaves an existing directory entirely alone, and says nothing: one /// this run made with an owner it could not verify (`Standing::Unverified`, /// NFS or FUSE) too. Its attributes were withheld and diagnosed even under diff --git a/pax/modes/pins.rs b/pax/modes/pins.rs index f114dfb50..bb96263e2 100644 --- a/pax/modes/pins.rs +++ b/pax/modes/pins.rs @@ -22,6 +22,14 @@ //! later does not share -- unless it is made within the same tick of the //! coarse clock the kernel stamps `ctime` from (a few milliseconds): the //! residual of that fallback. +//! +//! A pin keeps a number in use on a local filesystem. Not on NFS, where +//! another client can remove the file and the server hand its number to +//! another whatever this client holds, nor on a FUSE filesystem without +//! stable inode numbers: there a pin linked through is still the file +//! itself, but a pin that only holds a number while a name is linked (a +//! symbolic link, `LinkSource`) leaves the identity and ctime checks as all +//! there is. use crate::modes::anchored::{file_id, Expected}; use plib::madefs::{fstat, lstat_at}; diff --git a/pax/modes/read.rs b/pax/modes/read.rs index 7e4e9a95e..b81cb583b 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -764,8 +764,9 @@ struct CreatedSet { /// later name (`LinkSets::settled_mut`), within the budget /// (`Links::pins`). Its names can be replaced by other members meanwhile, /// and a filesystem that reuses inode numbers (ext4) then hands this - /// file's number to the next file created. Pinned, it keeps its number; - /// unpinned, its ctime tells it from that file. + /// file's number to the next file created. Pinned, it keeps its number + /// (on a local filesystem: `pins`); unpinned, its ctime tells it from + /// that file. file: MadeFile, /// Whether that file has its contents yet. newc stores them with the last /// name of a set only, so the earlier names are created empty. From c352390a44dec49231702787b52dc35bb1dd810a Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 23:00:11 +0000 Subject: [PATCH 63/71] pax, plib: pin a made file only where a later name could need it User decision: a pin -- an O_PATH reopen through procfs per file, and a descriptor held -- is taken only where it is needed, not for every file made. Extracting 50,000 one-byte files from a tar, three runs each: 0.58-0.59 s with a pin for every file (314b9ff0), 0.52-0.53 s now; 0.51 s before pins existed (c51a0313). The remaining difference is one more fstat per file, for the file's identity and ctime. A pin is taken where a later name can be linked to the file while someone else could replace it at its name: - copy mode: a file with more than one name (nlink > 1); - read mode, cpio: a member whose file has more than one name; - read mode, tar and pax, whose later link members cannot be known ahead in a streamed archive: where anyone but the user pax runs as can create, rename or remove entries in the member's directory. Every file not pinned is still known by identity and ctime (Making carries the decision to wherever the file is made). The directory question reuses the rule ChainTrust follows, through a new plib::madefs::only_the_user_writes_in: write permission of the user's private group counts as the user's, an ACL letting anyone else write as theirs, and the directory must be the effective user's own -- root working in another user's tree does not count it as root's here, since that user could replace the file. It is asked once per directory. The simpler others_can_rename, tried first, counted every group-writable directory as others', so under umask 002 every file was still pinned. CopiedLinks::record now ignores a file with one name before touching the budget: it was counted against the pins though the tracker never keeps it (F4). Tests: a tar member is pinned in a directory others can write and not in a private one, a cpio member only when its file has other names (before: every one pinned); a copy whose file has one name takes no pin (mutation check: dropping the nlink guard fails it). Co-Authored-By: Claude Opus 5.5 --- pax/modes/anchored.rs | 17 +++++-- pax/modes/copy.rs | 28 ++++++++-- pax/modes/made.rs | 6 +-- pax/modes/pins.rs | 40 ++++++++++++--- pax/modes/read.rs | 99 +++++++++++++++++++++++++++--------- pax/modes/read/race_tests.rs | 68 +++++++++++++++++++++++++ plib/src/madefs.rs | 16 ++++++ 7 files changed, 231 insertions(+), 43 deletions(-) diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index 3ac900f5b..46bd69a18 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -22,7 +22,7 @@ use crate::error::{PaxError, PaxResult}; use crate::modes::made::{self, verify_made_dir, MadeNode, MadeTrust}; -use crate::modes::pins::MadeFile; +use crate::modes::pins::Making; use plib::madefs::{cvt, fstat, fstatat, lstat_at}; use plib::madefs::{ChainTrust, FoundDir, NamedAnchor, Preserve, SEARCH_ONLY}; use std::cell::RefCell; @@ -2091,7 +2091,14 @@ pub(crate) fn set_made_node_attrs( attrs: &Attrs, policy: &AttrPolicy, ) -> PaxResult<()> { - set_made_node_attrs_recording(dirfd, name, made_type, attrs, policy, &mut None) + set_made_node_attrs_recording( + dirfd, + name, + made_type, + attrs, + policy, + &mut Making::new(false), + ) } /// `set_made_node_attrs`, leaving in `made` the node as a later name of it @@ -2103,14 +2110,14 @@ pub(crate) fn set_made_node_attrs_recording( made_type: libc::mode_t, attrs: &Attrs, policy: &AttrPolicy, - made: &mut Option, + made: &mut Making, ) -> PaxResult<()> { let node = MadeNode::pin(dirfd, name, made_type)?; let applied = apply_node_attrs(&node, made_type, attrs, policy); - *made = node.made_file(); + made.file = node.made_file(made.pin); // A node made is always known; one that cannot be is a failure, never a // name left as it was. - if made.is_none() { + if made.file.is_none() { applied?; return Err(PaxError::Io(made::replaced())); } diff --git a/pax/modes/copy.rs b/pax/modes/copy.rs index 80e85d778..d0f1d61c7 100644 --- a/pax/modes/copy.rs +++ b/pax/modes/copy.rs @@ -196,6 +196,10 @@ impl CopiedLinks { member: &Path, made: Option, ) { + // A file with one name has no later name to be linked to it. + if nlink <= 1 { + return; + } let pinned = made.as_ref().is_some_and(MadeFile::is_pinned); self.names .record(dev, ino, nlink, (member.to_path_buf(), made)); @@ -832,7 +836,7 @@ fn copy_file( let (dev, ino, nlink) = (metadata.dev(), metadata.ino(), metadata.nlink() as u32); if let Some((link_target, copy)) = link_tracker.names.lookup_mut(dev, ino, nlink) { let Some(target) = MemberPath::parse(link_target)? else { - return do_copy_file(entry, dirfd, name, metadata, options).map(|_| ()); + return do_copy_file(entry, dirfd, name, metadata, options, false).map(|_| ()); }; let target_dir = tree.parent_of(&target, false)?; // Resolved one component at a time from the destination anchor, the @@ -857,7 +861,7 @@ fn copy_file( return Ok(()); } - let copy = do_copy_file(entry, dirfd, name, metadata, options)?; + let copy = do_copy_file(entry, dirfd, name, metadata, options, nlink > 1)?; // Only a copy that exists can be linked to by the file's later names. link_tracker.record((dev, ino), nlink, member, copy); Ok(()) @@ -878,6 +882,7 @@ fn do_copy_file( name: &CStr, metadata: &ftw::Metadata, options: &CopyOptions, + pin: bool, ) -> PaxResult> { // From the descriptor of the directory the walk found it in, and re-checked // against the (dev, ino) the walk saw, rather than re-resolving the whole @@ -925,7 +930,7 @@ fn do_copy_file( set_attrs_fd(dest_file.as_fd(), &attrs_of(metadata), &policy_of(options))?; // Known once its attributes are set, which change its ctime. - let copy = MadeFile::of(dest_file.as_fd())?; + let copy = MadeFile::of(dest_file.as_fd(), pin)?; // A filesystem that defers writes reports their failure on close. crate::blocked_io::close_file(dest_file)?; Ok(Some(copy)) @@ -1094,6 +1099,23 @@ mod tests { ); } + /// A file with one name has no later name to be linked to it: its copy is + /// not remembered, and takes nothing from the budget of pins. + #[cfg(target_os = "linux")] + #[test] + fn test_a_copy_with_one_name_takes_no_pin() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("f"); + fs::write(&path, "x").unwrap(); + let file = fs::File::open(&path).unwrap(); + let made = MadeFile::of(file.as_fd(), true).unwrap(); + assert!(made.is_pinned()); + let mut links = CopiedLinks::new(); + links.record((1, 2), 1, Path::new("f"), Some(made)); + assert_eq!(links.pins.held(), 0); + assert!(links.names.lookup(1, 2, 2).is_none()); + } + #[test] fn test_copy_file() { let src_dir = TempDir::new().unwrap(); diff --git a/pax/modes/made.rs b/pax/modes/made.rs index dfd1a1e2a..f259f0650 100644 --- a/pax/modes/made.rs +++ b/pax/modes/made.rs @@ -163,8 +163,8 @@ mod linux { /// The node as a later name of it is to be linked to it: pinned by a /// duplicate of this pin. - pub(crate) fn made_file(&self) -> Option { - MadeFile::held(self.fd.as_fd()).ok() + pub(crate) fn made_file(&self, pin: bool) -> Option { + MadeFile::held(self.fd.as_fd(), pin).ok() } pub(crate) fn trust(&self) -> MadeTrust { @@ -295,7 +295,7 @@ mod other { /// The node as a later name of it is to be linked to it, known by its /// identity and ctime: nothing here can be linked through a /// descriptor. - pub(crate) fn made_file(&self) -> Option { + pub(crate) fn made_file(&self, _pin: bool) -> Option { let st = match self.held { Held::Fd(ref fd) => fstat(fd.as_raw_fd()).ok()?, Held::Name { diff --git a/pax/modes/pins.rs b/pax/modes/pins.rs index bb96263e2..ac4dce0ec 100644 --- a/pax/modes/pins.rs +++ b/pax/modes/pins.rs @@ -53,14 +53,15 @@ pub(crate) struct MadeFile { impl MadeFile { /// The file open on `fd` -- for writing, or `O_PATH` -- which this run - /// has just made and given its attributes: pinned by a descriptor of its - /// own where a verified procfs can reopen it. - pub(crate) fn of(fd: BorrowedFd<'_>) -> io::Result { + /// has just made and given its attributes; where `pin` asks for it, + /// pinned by a descriptor of its own where a verified procfs can reopen + /// it (`Making::pin`). + pub(crate) fn of(fd: BorrowedFd<'_>, pin: bool) -> io::Result { let st = fstat(fd.as_raw_fd())?; Ok(MadeFile { id: file_id(&st), ctime: ctime_of(&st), - pin: reopen_path(fd), + pin: if pin { reopen_path(fd) } else { None }, }) } @@ -68,10 +69,14 @@ impl MadeFile { /// by the `O_PATH` descriptor `pin` (`MadeNode`): pinned by a duplicate /// of it. #[cfg(target_os = "linux")] - pub(crate) fn held(pin: BorrowedFd<'_>) -> io::Result { + pub(crate) fn held(pin: BorrowedFd<'_>, keep: bool) -> io::Result { let st = fstat(pin.as_raw_fd())?; // Without a duplicate it is known by identity and ctime alone. - let dup = unsafe { libc::fcntl(pin.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) }; + let dup = if keep { + unsafe { libc::fcntl(pin.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) } + } else { + -1 + }; Ok(MadeFile { id: file_id(&st), ctime: ctime_of(&st), @@ -173,6 +178,21 @@ impl MadeFile { } } +/// What a member makes, as it is made: whether its file is to be pinned, and +/// the file once it is made (`MadeFile`). +pub(crate) struct Making { + /// Whether a later name could be linked to it while someone else could + /// replace it at its name: only then is a pin worth its cost. + pub(crate) pin: bool, + pub(crate) file: Option, +} + +impl Making { + pub(crate) fn new(pin: bool) -> Self { + Making { pin, file: None } + } +} + /// A status's `ctime`, seconds and nanoseconds. pub(crate) fn ctime_of(st: &libc::stat) -> (i64, i64) { // Casts needed: the field types differ between platforms. @@ -228,6 +248,12 @@ impl PinBudget { } } + /// How many holders hold a pin. + #[cfg(all(test, target_os = "linux"))] + pub(crate) fn held(&self) -> usize { + self.held.len() + } + /// Account for `key`'s holder, which `pinned` says holds a pin now or not: /// note it when it has taken one, forget it once it holds none, and when /// that puts the budget over, close the oldest holder's pin (`unpin`). @@ -285,7 +311,7 @@ mod tests { let (dir, tree) = made_and_planted(); let root = tree.root(); let f = std::fs::File::open(dir.path().join("f")).unwrap(); - let made = MadeFile::of(f.as_fd()).unwrap(); + let made = MadeFile::of(f.as_fd(), true).unwrap(); assert!(made.is_pinned()); drop(f); diff --git a/pax/modes/read.rs b/pax/modes/read.rs index b81cb583b..958849939 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -9,7 +9,7 @@ //! Read mode implementation - extract archive contents -use crate::archive::{ArchiveEntry, ArchiveReader, EntryType, LinkSets}; +use crate::archive::{ArchiveEntry, ArchiveReader, EntryType, LinkSets, SourceHeader}; use crate::error::{PaxError, PaxResult}; use crate::formats::OptionRecords; use crate::interactive::{InteractivePrompter, RenameResult}; @@ -18,7 +18,7 @@ use crate::modes::anchored::{ set_made_node_attrs_recording, unlink_at, AttrPolicy, Attrs, DirAttrs, DirTree, Expected, MemberPath, PendingDirs, }; -use crate::modes::pins::{MadeFile, PinBudget}; +use crate::modes::pins::{MadeFile, Making, PinBudget}; use crate::modes::select::Selector; use crate::pattern::Pattern; use crate::subst::{substitute_link_target, substitute_name, Substitution}; @@ -388,6 +388,8 @@ fn extract_entry( // Each arm settles what it left at its name (`Links::left`): a file this // run made, nothing it can vouch for, or the name as it was. + let pin = links.wants_pin(entry, &parent); + links.pin_member = pin; match entry.entry_type { EntryType::Directory => { let decided = extract_directory(tree, pfd, &member, entry, options); @@ -407,9 +409,9 @@ fn extract_entry( } } EntryType::Symlink => { - let mut made = None; + let mut made = Making::new(pin); let result = extract_symlink(pfd, name, entry, options, &mut made); - links.left(&member, result.is_err(), made); + links.left(&member, result.is_err(), made.file); result?; archive.skip_data()?; } @@ -426,16 +428,16 @@ fn extract_entry( archive.skip_data()?; // Skip padding to block boundary } EntryType::BlockDevice | EntryType::CharDevice => { - let mut made = None; + let mut made = Making::new(pin); let result = extract_device(pfd, name, entry, options, &mut made); - links.left(&member, result.is_err(), made); + links.left(&member, result.is_err(), made.file); result?; archive.skip_data()?; } EntryType::Fifo => { - let mut made = None; + let mut made = Making::new(pin); let result = extract_fifo(pfd, name, entry, options, &mut made); - links.left(&member, result.is_err(), made); + links.left(&member, result.is_err(), made.file); result?; archive.skip_data()?; } @@ -508,7 +510,7 @@ fn extract_symlink( name: &CStr, entry: &ArchiveEntry, options: &ReadOptions, - made: &mut Option, + made: &mut Making, ) -> PaxResult<()> { let target = entry .link_target @@ -543,6 +545,11 @@ struct Links { /// The name -i gave each member it renamed, by the member path /// (`MemberPath::key`) it had before. aliases: HashMap, PathBuf>, + /// Whether the member being extracted is to pin what it makes + /// (`wants_pin`), as decided for it. + pin_member: bool, + /// The directory last asked about in `wants_pin`, and the answer. + last_dir: Option<(Rc, bool)>, } /// What this run left at a member path it tried to extract. @@ -571,9 +578,33 @@ impl Links { made: HashMap::new(), pins: PinBudget::new(), aliases: HashMap::new(), + pin_member: false, + last_dir: None, } } + /// Whether the file a member makes is to be pinned (`MadeFile`): only where + /// a later member could name it while someone else could replace it at + /// its name. A cpio member, where its file has other names. A tar or pax + /// member, whose later link members cannot be known ahead in a streamed + /// archive, where anyone but the user pax runs as can create, rename or + /// remove entries in `parent`, its directory + /// (`plib::madefs::only_the_user_writes_in`, asked once per directory). + /// Every other file is known by identity and ctime alone. + fn wants_pin(&mut self, entry: &ArchiveEntry, parent: &Rc) -> bool { + if matches!(entry.source_header, Some(SourceHeader::Cpio { .. })) { + return entry.nlink > 1; + } + if let Some((dir, answer)) = &self.last_dir { + if Rc::ptr_eq(dir, parent) { + return *answer; + } + } + let answer = !plib::madefs::only_the_user_writes_in(parent.as_raw_fd()); + self.last_dir = Some((Rc::clone(parent), answer)); + answer + } + /// Account for the pin of the set `entry` is a name of, after the member. fn account_set(&mut self, entry: &ArchiveEntry) { let Some(key) = LinkSets::::key(entry) else { @@ -823,15 +854,16 @@ fn extract_regular( return result; } - let mut made = None; + let mut making = Making::new(links.pin_member); let result = extract_file( archive, dirfd, member.leaf.as_c_str(), entry, options, - &mut made, + &mut making, ); + let made = making.file; // A file made is recorded for a later link member to name, even when its // data or attributes then failed: it is at that name all the same. // The first name of a cpio link set: the set holds the file's pin, and @@ -902,9 +934,10 @@ fn join_link_set( } let holders = set.holders(tree); - let mut made = None; + // A set's file has other names: pinned. + let mut made = Making::new(true); extract_file(archive, dirfd, name, entry, options, &mut made)?; - let Some(mut file) = made else { + let Some(mut file) = made.file else { // -k kept what was there; the data is still the earlier names'. return fill_link_set(archive, tree, entry, options, set); }; @@ -939,9 +972,9 @@ fn fill_link_set( }; let dir = Rc::clone(dir); let (temp, file) = create_temp_file(dir.as_fd(), entry, options)?; - let mut made = None; + let mut made = Making::new(true); let filled = write_file_data(archive, file, entry, options, &mut made).and_then(|()| { - let mut made = made.ok_or_else(crate::modes::made::replaced)?; + let mut made = made.file.ok_or_else(crate::modes::made::replaced)?; let names = move_names_to(holders, dir.as_fd(), &temp, &mut made)?; Ok((made, names)) }); @@ -1002,7 +1035,7 @@ fn extract_device( name: &CStr, entry: &ArchiveEntry, options: &ReadOptions, - made: &mut Option, + made: &mut Making, ) -> PaxResult<()> { // makedev has different signatures on different platforms: // - Linux: makedev(major: u32, minor: u32) -> u64 @@ -1049,7 +1082,7 @@ fn extract_fifo( name: &CStr, entry: &ArchiveEntry, options: &ReadOptions, - made: &mut Option, + made: &mut Making, ) -> PaxResult<()> { let created = create_replacing(dirfd, name, options.no_clobber, || { let r = unsafe { @@ -1088,7 +1121,7 @@ fn extract_file( name: &CStr, entry: &ArchiveEntry, options: &ReadOptions, - made: &mut Option, + made: &mut Making, ) -> PaxResult<()> { let mut opened: Option = None; let created = create_replacing(dirfd, name, options.no_clobber, || { @@ -1154,16 +1187,16 @@ fn write_file_data( mut file: File, entry: &ArchiveEntry, options: &ReadOptions, - made: &mut Option, + made: &mut Making, ) -> PaxResult<()> { // Known from the descriptor that made it, whatever happens next. - *made = Some(MadeFile::of(file.as_fd())?); + made.file = Some(MadeFile::of(file.as_fd(), made.pin)?); let written = copy_file_data(archive, &mut file, entry.size).and_then(|()| { // Through the descriptor the data was just written to, not by name. set_attrs_fd(file.as_fd(), &attrs_of(entry, options), &policy_of(options)) }); // Its data and attributes changed its ctime. - if let Some(made) = made.as_mut() { + if let Some(made) = made.file.as_mut() { made.refresh(file.as_fd()); } written?; @@ -1289,7 +1322,7 @@ fn set_made_attrs( made_type: libc::mode_t, entry: &ArchiveEntry, options: &ReadOptions, - made: &mut Option, + made: &mut Making, ) -> PaxResult<()> { let attrs = attrs_of(entry, options); set_made_node_attrs_recording(dirfd, name, made_type, &attrs, &policy_of(options), made) @@ -1347,7 +1380,7 @@ mod tests { let mut links = Links::new(); let target = MemberPath::parse(Path::new("f")).unwrap().unwrap(); let f = std::fs::File::open(dir.path().join("f")).unwrap(); - links.record_made(target.key(), MadeFile::of(f.as_fd()).unwrap()); + links.record_made(target.key(), MadeFile::of(f.as_fd(), true).unwrap()); drop(f); let path = dir.path().to_path_buf(); @@ -1516,7 +1549,15 @@ mod tests { umask: 0o022, ..Default::default() }; - set_made_attrs(dir.as_fd(), &name, libc::S_IFIFO, &entry, &opts, &mut None).unwrap(); + set_made_attrs( + dir.as_fd(), + &name, + libc::S_IFIFO, + &entry, + &opts, + &mut Making::new(false), + ) + .unwrap(); assert_eq!(mode(), 0o755); // Preserved: exact 0o777 regardless of umask. @@ -1527,7 +1568,15 @@ mod tests { umask: 0o022, ..Default::default() }; - set_made_attrs(dir.as_fd(), &name, libc::S_IFIFO, &entry, &opts, &mut None).unwrap(); + set_made_attrs( + dir.as_fd(), + &name, + libc::S_IFIFO, + &entry, + &opts, + &mut Making::new(false), + ) + .unwrap(); assert_eq!(mode(), 0o777); } diff --git a/pax/modes/read/race_tests.rs b/pax/modes/read/race_tests.rs index 0e96045b3..0faed4f44 100644 --- a/pax/modes/read/race_tests.rs +++ b/pax/modes/read/race_tests.rs @@ -711,3 +711,71 @@ fn link_set_name_is_not_linked_to_a_file_with_a_reused_number() { "the later name was linked to the planted file" ); } + +/// Whether the file a member at `name` made below `dest` was recorded pinned. +#[cfg(target_os = "linux")] +fn recorded_pinned(dest: &Path, entry: ArchiveEntry) -> bool { + let tree = DirTree::open_path(dest).unwrap(); + let mut pending = PendingDirs::default(); + let mut links = super::Links::new(); + let key = MemberPath::parse(&entry.path).unwrap().unwrap().key(); + let mut archive = Members(vec![entry].into_iter()); + while let Some(entry) = archive.read_entry().unwrap() { + extract_entry( + &mut archive, + &entry, + &ReadOptions::default(), + &mut links, + &tree, + &mut pending, + ) + .unwrap(); + } + let set_pinned = links + .sets + .by_key_mut((0, 0)) + .is_some_and(|set| set.file.is_pinned()); + match links.made.get(&key) { + Some(super::Record::File(made)) => made.is_pinned() || set_pinned, + _ => panic!("nothing recorded"), + } +} + +/// A pin costs a reopen through procfs per file; it is held only where a +/// later member could name the file and someone else could replace it at +/// its name meanwhile. A tar member is pinned where others can rename in its +/// directory, a cpio member where its file has other names; every other is +/// known by identity and ctime alone. +#[cfg(target_os = "linux")] +#[test] +fn a_made_file_is_pinned_only_where_it_could_be_needed() { + use crate::archive::SourceHeader; + use crate::formats::cpio::CpioFormat; + let tmp = TempDir::new().unwrap(); + let private = tmp.path().join("private"); + let open = tmp.path().join("open"); + std::fs::create_dir(&private).unwrap(); + std::fs::create_dir(&open).unwrap(); + std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap(); + + let tar = |name| own_member(name, EntryType::Regular, 0o644); + assert!( + !recorded_pinned(&private, tar("t")), + "tar, private directory" + ); + assert!(recorded_pinned(&open, tar("t")), "tar, others can rename"); + + let cpio = |name, nlink| ArchiveEntry { + nlink, + source_header: Some(SourceHeader::Cpio { + format: CpioFormat::Newc, + }), + ..own_member(name, EntryType::Regular, 0o644) + }; + assert!(!recorded_pinned(&open, cpio("c1", 1)), "cpio, one name"); + assert!( + recorded_pinned(&private, cpio("c2", 2)), + "cpio, other names" + ); +} diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index 8bf931e90..cd505ca61 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -685,6 +685,22 @@ fn trusted_owner( } } +/// Whether nobody but the effective user itself can create, rename or remove entries in the +/// directory open on `fd`: the rule `ChainTrust` follows (`only_the_user_writes`) -- write +/// permission for the user's private group counts as the user's, an ACL letting anyone else +/// write as theirs -- with the directory the effective user's own. Root working in another +/// user's tree does not count it as root's here: that user can replace what is in it. +/// +/// pax pins a file it makes for later names to be linked to only where this is false. +pub fn only_the_user_writes_in(fd: RawFd) -> bool { + let euid = unsafe { libc::geteuid() }; + let Ok(st) = fstat(fd) else { + return false; + }; + st.st_uid == euid + && only_the_user_writes(dir_writers(&st, euid, None, ChainStart::Anchor), Some(fd)) +} + /// `only_the_user_writes` for the directory open on `fd`, worked out at once: for tests. #[cfg(test)] fn nobody_else_can_create(fd: RawFd) -> io::Result { From bdadd11b027e901f0de3c11c2178626fc456e7c7 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 23:01:21 +0000 Subject: [PATCH 64/71] pax -rw: do not remember a copy -k kept, so its later names are not linked to it -k skips a copy whose destination name is taken, and the ordinary path does so before copying, remembering nothing. But a name taken at the moment the copy was made -- after that check saw nothing there -- made do_copy_file return no copy, and CopiedLinks recorded the name with no file. The source's later names were then linked to that name by name, to whatever someone had put there. CopiedLinks::record now ignores a copy that was not made, as the ordinary -k path does; the later names are copied themselves. The tracker holds only copies actually made, so its entry is now (PathBuf, MadeFile) rather than an Option. Test: recording a -k copy that was not made leaves nothing to link later names to (before: the name was remembered with no file). Co-Authored-By: Claude Opus 5.5 --- pax/modes/copy.rs | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/pax/modes/copy.rs b/pax/modes/copy.rs index d0f1d61c7..0937929ba 100644 --- a/pax/modes/copy.rs +++ b/pax/modes/copy.rs @@ -172,10 +172,9 @@ impl CopyWalk<'_> { } /// The first copy of each multiply-linked file: the member path it was made -/// at, and the copy made there (`MadeFile`, pinned within the budget) -- -/// `None` where -k kept a file already at that name. +/// at, and the copy made there (`MadeFile`, pinned within the budget). struct CopiedLinks { - names: HardLinkTracker<(PathBuf, Option)>, + names: HardLinkTracker<(PathBuf, MadeFile)>, pins: PinBudget<(u64, u64)>, } @@ -188,7 +187,10 @@ impl CopiedLinks { } /// Record the first copy of the source file `(dev, ino)`, `made` at - /// `member`, within the budget of pins. + /// `member`, within the budget of pins. Nothing is recorded where -k + /// kept a file already at the name (`made` is `None`) -- found there + /// before the copy, or at the moment it was made: its later names are + /// then copied themselves, not linked to that file. fn record( &mut self, (dev, ino): (u64, u64), @@ -197,15 +199,15 @@ impl CopiedLinks { made: Option, ) { // A file with one name has no later name to be linked to it. - if nlink <= 1 { + let Some(made) = made.filter(|_| nlink > 1) else { return; - } - let pinned = made.as_ref().is_some_and(MadeFile::is_pinned); + }; + let pinned = made.is_pinned(); self.names .record(dev, ino, nlink, (member.to_path_buf(), made)); let names = &mut self.names; self.pins.note((dev, ino), pinned, |key| { - if let Some((_, Some(made))) = names.by_key_mut(*key) { + if let Some((_, made)) = names.by_key_mut(*key) { made.unpin(); } }); @@ -849,15 +851,13 @@ fn copy_file( target_dir.as_raw_fd(), &target.leaf, None, - copy.as_ref().map(MadeFile::expected), + Some(copy.expected()), dirfd, name, options.no_clobber, )?; // Linking a name changed its ctime. - if let Some(copy) = copy { - copy.linked(dirfd, name); - } + copy.linked(dirfd, name); return Ok(()); } @@ -1116,6 +1116,17 @@ mod tests { assert!(links.names.lookup(1, 2, 2).is_none()); } + /// Under -k a copy that finds its name taken at the moment it is made -- + /// the check before it saw nothing there -- makes nothing, and its later + /// names are not linked to whatever holds that name: nothing is + /// remembered, as when -k finds the name taken beforehand. + #[test] + fn test_a_copy_kept_by_k_is_not_remembered() { + let mut links = CopiedLinks::new(); + links.record((1, 2), 2, Path::new("f"), None); + assert!(links.names.lookup(1, 2, 2).is_none()); + } + #[test] fn test_copy_file() { let src_dir = TempDir::new().unwrap(); From b601dbc36d6fee3dcb209195460f489972564392 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 23:02:44 +0000 Subject: [PATCH 65/71] pax -i: end a rename once a later member of the same name keeps its own The name -i gave a member was remembered for the rest of the run, keyed by the member's own name, and a link member naming that name was always resolved to it. A later member of the same name, extracted at its own name, is the one a link member then refers to -- and a later rename of it is the name to use. Links::named, called for every selected member, records the rename when -i gave one and otherwise drops any earlier rename of that name; a later rename overrides an earlier one. Test: a rename of f is resolved, ends when a later f keeps its own name, and a later rename overrides (mutation check: not dropping the earlier rename fails it). Co-Authored-By: Claude Opus 5.5 --- pax/modes/read.rs | 26 ++++++++++++++++++++------ pax/modes/read/race_tests.rs | 23 ++++++++++++++++++++++- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/pax/modes/read.rs b/pax/modes/read.rs index 958849939..09b72c2af 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -162,8 +162,8 @@ fn extract_members( tree, &mut renamed_from, )?; - if let Some(before) = renamed_from.filter(|_| selected) { - links.alias(&before, &entry.path); + if selected { + links.named(renamed_from.as_deref(), &entry.path); } if selected { // Per POSIX CONSEQUENCES OF ERRORS: diagnose a per-file failure and @@ -657,10 +657,24 @@ impl Links { } } - /// Note that -i renamed the member named `before` (after -s) to `after`. - fn alias(&mut self, before: &Path, after: &Path) { - if let Ok(Some(before)) = MemberPath::parse(before) { - self.aliases.insert(before.key(), after.to_path_buf()); + /// Note the name a selected member is extracted at, `path`: -i gave it + /// that name in place of `renamed_from` (its name after -s), or it keeps + /// its own. A link member names the latest member of a name in the + /// archive: renamed, that one is at `path`; not renamed, it is at its + /// own name, and an earlier member's rename no longer applies. + fn named(&mut self, renamed_from: Option<&Path>, path: &Path) { + let key = |p: &Path| MemberPath::parse(p).ok().flatten().map(|m| m.key()); + match renamed_from { + Some(before) => { + if let Some(before) = key(before) { + self.aliases.insert(before, path.to_path_buf()); + } + } + None => { + if let Some(own) = key(path) { + self.aliases.remove(&own); + } + } } } diff --git a/pax/modes/read/race_tests.rs b/pax/modes/read/race_tests.rs index 0faed4f44..c5261c523 100644 --- a/pax/modes/read/race_tests.rs +++ b/pax/modes/read/race_tests.rs @@ -530,7 +530,7 @@ fn link_member_follows_a_target_renamed_by_i() { let options = ReadOptions::default(); // The member `f`, renamed to `renamed` at the prompt. let file = own_member("renamed", EntryType::Regular, 0o644); - links.alias(Path::new("f"), Path::new("renamed")); + links.named(Some(Path::new("f")), Path::new("renamed")); let mut archive = Members(vec![file, link_member("f")].into_iter()); while let Some(entry) = archive.read_entry().unwrap() { let _ = extract_entry( @@ -779,3 +779,24 @@ fn a_made_file_is_pinned_only_where_it_could_be_needed() { "cpio, other names" ); } + +/// A later member of the name -i renamed an earlier one away from, kept at +/// its own name, is the one a link member naming that name refers to: the +/// earlier rename no longer applies. +#[test] +fn a_later_member_of_a_renamed_name_ends_the_rename() { + let mut links = super::Links::new(); + links.named(Some(Path::new("f")), Path::new("renamed")); + assert_eq!( + links.link_target(PathBuf::from("f")), + PathBuf::from("renamed") + ); + links.named(None, Path::new("f")); + assert_eq!(links.link_target(PathBuf::from("f")), PathBuf::from("f")); + // A later rename of the name overrides an earlier one. + links.named(Some(Path::new("f")), Path::new("again")); + assert_eq!( + links.link_target(PathBuf::from("f")), + PathBuf::from("again") + ); +} From 76be69418e756fd24ba5fe8c821101186c6fcae5 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 23:14:40 +0000 Subject: [PATCH 66/71] plib: trust a directory only root can write for every user, not for root alone Two cp-chaintrust tests failed on macOS only: a_named_directory_reached_through_a_link_is_found_in_the_links_directory and a_named_path_is_trusted_only_through_links_the_user_vouches_for. Their temporary directories are under /var/folders, and /var is a symbolic link in /, which is root's. A named path is judged by the directories holding the links it follows, and for any effective user but root a directory not the user's own counted as one others may write: / took the whole path's trust away. So on macOS, cp -p and pax -p e by an ordinary user into an existing tree under /tmp or /var/folders always left the directories found alone and exited 1. The same held anywhere a found-directory chain or a named path went through a directory of root's -- on Linux, /lib on a merged-/usr system, or /proc/self. The rule change (trusted_owner, which dir_writers follows): while a chain has not gone into another user's tree (no above_owner), a directory may be its owner's alone when its owner is the effective user -- as before -- or root, for every effective user. Whether it is then root's alone is judged exactly as for the user's own: no other write permission, group write only for root's private group, and no ACL letting anyone else write. Nobody but root can create entries in such a directory, and root needs no trick to change anything. Unchanged: inside the tree of the one user root works for (above_owner set), every directory must still be that user's; and a root-owned directory never becomes that "other user" (link_as records the chain's owner only for a uid that is neither the effective user nor root). Test (Linux): / as the anchor for a user other than root, and the named path /proc/self -- a link in /proc, root's 0555 -- now trust what is found (before: LeaveAlone for both). The two macOS tests are correct as they stand under this rule: / is root's 0755, the only directory holding a link on their paths; plib checks clean for x86_64-apple-darwin. Co-Authored-By: Claude Opus 5.5 --- plib/src/madefs.rs | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index cd505ca61..793433ff1 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -444,7 +444,10 @@ impl ChainTrust { let st = fstat(dir.as_raw_fd())?; let writers = dir_writers(&st, euid, above_owner, start); let owner = match writers { - DirWriters::Owner { uid } | DirWriters::OwnerAndGroup { uid, .. } if uid != euid => { + // Root's own directory, trusted for every user, is no other user's tree. + DirWriters::Owner { uid } | DirWriters::OwnerAndGroup { uid, .. } + if uid != euid && uid != 0 => + { Some(uid) } _ => above_owner, @@ -618,6 +621,10 @@ enum DirWriters { /// A directory may be its owner's alone only where its owner is someone the trust can rest /// with: /// - the effective user, while the chain has gone into no other user's directories; +/// - root, for every effective user, on the same terms: nobody but root can create entries in +/// a directory only root can write, and root needs no trick to change anything. Every path +/// starts in root's directories -- `/`, and on macOS the `/var` and `/tmp` links it holds -- +/// and a user's own tree below them is as safe as root's are; /// - for root, on Linux only (`ROOT_WORKS_FOR_USERS`), one user other than root, the one the /// copy or extraction is for -- root extracting into `/home/alice`, which alice owns, with /// the directories a umask of 002 left below it. That user is the owner of the first directory in the chain not root's own, and @@ -627,7 +634,7 @@ enum DirWriters { /// given since, which proves nothing about who could create entries in it. /// /// Anyone else, a sticky directory included, counts as others. So does a non-root effective -/// user meeting a directory not theirs: nobody but root acts for another user. +/// user meeting a directory neither theirs nor root's: nobody but root acts for another user. /// /// Not the rule `others_can_rename` follows, on purpose. That one asks whether someone could /// have renamed something over a directory the caller has just made, and the sticky bit stops @@ -680,7 +687,7 @@ fn trusted_owner( match above_owner { Some(owner) => uid == owner, None => { - uid == euid || (works_for_users && euid == 0 && uid != 0 && start == ChainStart::Anchor) + uid == euid || uid == 0 || (works_for_users && euid == 0 && start == ChainStart::Anchor) } } } @@ -1801,6 +1808,32 @@ mod tests { assert_eq!(below.found_dir(&*root_fd, mode), FoundDir::LeaveAlone); } + /// A directory only root can write -- root's, granting nobody else write -- is one nobody + /// but root can create entries in, and root needs no trick to change anything: it is + /// trusted whoever the user is. It was trusted only for root, so for any other user a + /// directory found in `/` took nothing asked for, and so did every named path through a + /// link root's own directory holds -- on macOS, every one through `/var` or `/tmp`. + #[cfg(target_os = "linux")] + #[test] + fn a_directory_only_root_writes_is_trusted_for_every_user() { + let mode = Preserve { + mode: true, + owner: false, + }; + // `/` as the anchor, for a user other than root. + let slash = Rc::new(std::fs::File::open("/").unwrap()); + let st = super::fstat(slash.as_raw_fd()).unwrap(); + assert_eq!((st.st_uid, st.st_mode & 0o022), (0, 0), "/ is root's alone"); + let as_user = ChainTrust::link_as(None, &slash, ChainStart::Anchor, 4242).unwrap(); + assert_eq!(as_user.found_dir(&*slash, mode), FoundDir::AsRequested); + + // `/proc/self`: a link in /proc (root's, 0555) to the process's own directory. + let held = Rc::new(std::fs::File::open("/proc/self").unwrap()); + let named = ChainTrust::named(Path::new("/proc/self"), &held).unwrap(); + assert_eq!(named.named_dir(mode), FoundDir::AsRequested); + assert_eq!(named.hands.found_dir(&*held, mode), FoundDir::AsRequested); + } + /// A directory the user names is trusted as named -- unless its name's last component is a /// symbolic link in a directory others can write, or its name no longer holds it. #[test] From d003e2390a75c6fec7e54216fd4f9b3c0b2e2e50 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 23:16:01 +0000 Subject: [PATCH 67/71] pax: share one ctime among every record of a made file, so link chains link Every record of a file this run made -- the target member's, and one per link member to it (MadeFile::share), or per name of a cpio set (MadeFile::known) -- held its own copy of the file's ctime, and a link refreshed only the record it was made through. But a link changes the inode's ctime for every name. Since c352390a a file in a directory only the user writes is not pinned, and a link to it checks its ctime: tar members f, a -> f, b -> f, c -> a failed on c, "source file changed before it could be linked", unless the links fell in one clock tick -- and with multigrain ctime (Linux 6.13 and later) they never do. MadeFile's ctime is now one shared cell (Rc>): share() and known() hand out the same cell, so a refresh through any record -- a link made, data written, the pin closed -- is seen by all of them. Test (race_tests): f, a -> f, b -> f, c -> a, each link in a later clock tick, are all linked to f (before: c failed as above). Co-Authored-By: Claude Opus 5.5 --- pax/modes/pins.rs | 28 +++++++++++-------- pax/modes/read/race_tests.rs | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/pax/modes/pins.rs b/pax/modes/pins.rs index ac4dce0ec..78f780de7 100644 --- a/pax/modes/pins.rs +++ b/pax/modes/pins.rs @@ -33,10 +33,12 @@ use crate::modes::anchored::{file_id, Expected}; use plib::madefs::{fstat, lstat_at}; +use std::cell::Cell; use std::collections::VecDeque; use std::ffi::CStr; use std::io; use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd}; +use std::rc::Rc; /// A file this run made, as a later name of it is to be linked to it. #[derive(Debug)] @@ -45,8 +47,10 @@ pub(crate) struct MadeFile { id: (u64, u64), /// Its `ctime` as this run last left it: changed by everything this run /// does to it, a link included, and by nothing anyone else can make it - /// equal to on a file of their own. - ctime: (i64, i64), + /// equal to on a file of their own. Shared by every record of the file + /// (`share`, `known`): a link made through one changes the file's ctime + /// for all of them. + ctime: Rc>, /// The file itself, held open with `O_PATH`, while the budget allows. pin: Option, } @@ -60,7 +64,7 @@ impl MadeFile { let st = fstat(fd.as_raw_fd())?; Ok(MadeFile { id: file_id(&st), - ctime: ctime_of(&st), + ctime: Rc::new(Cell::new(ctime_of(&st))), pin: if pin { reopen_path(fd) } else { None }, }) } @@ -79,7 +83,7 @@ impl MadeFile { }; Ok(MadeFile { id: file_id(&st), - ctime: ctime_of(&st), + ctime: Rc::new(Cell::new(ctime_of(&st))), pin: (dup >= 0).then(|| unsafe { OwnedFd::from_raw_fd(dup) }), }) } @@ -90,7 +94,7 @@ impl MadeFile { pub(crate) fn unpinned(st: &libc::stat) -> Self { MadeFile { id: file_id(st), - ctime: ctime_of(st), + ctime: Rc::new(Cell::new(ctime_of(st))), pin: None, } } @@ -100,7 +104,7 @@ impl MadeFile { pub(crate) fn refresh(&mut self, fd: BorrowedFd<'_>) { if let Ok(st) = fstat(fd.as_raw_fd()) { if file_id(&st) == self.id { - self.ctime = ctime_of(&st); + self.ctime.set(ctime_of(&st)); } } } @@ -120,7 +124,7 @@ impl MadeFile { .map(|st| ctime_of(&st)); Expected { id: self.id, - ctime: Some(pinned_ctime.unwrap_or(self.ctime)), + ctime: Some(pinned_ctime.unwrap_or(self.ctime.get())), pin: self.pin.as_ref().map(|pin| pin.as_fd()), } } @@ -134,7 +138,7 @@ impl MadeFile { pub(crate) fn unpin(&mut self) { if let Some(pin) = self.pin.take() { if let Ok(st) = fstat(pin.as_raw_fd()) { - self.ctime = ctime_of(&st); + self.ctime.set(ctime_of(&st)); } } } @@ -144,7 +148,7 @@ impl MadeFile { pub(crate) fn known(&self) -> Self { MadeFile { id: self.id, - ctime: self.ctime, + ctime: Rc::clone(&self.ctime), pin: None, } } @@ -158,7 +162,7 @@ impl MadeFile { }); MadeFile { id: self.id, - ctime: self.ctime, + ctime: Rc::clone(&self.ctime), pin, } } @@ -172,7 +176,7 @@ impl MadeFile { } if let Ok(st) = lstat_at(dirfd.as_raw_fd(), name) { if file_id(&st) == self.id { - self.ctime = ctime_of(&st); + self.ctime.set(ctime_of(&st)); } } } @@ -349,7 +353,7 @@ mod tests { assert_ne!(ctime_of(&planted_st), ctime_of(&made_st)); let made = MadeFile { id: file_id(&planted_st), - ctime: ctime_of(&made_st), + ctime: Rc::new(Cell::new(ctime_of(&made_st))), pin: None, }; diff --git a/pax/modes/read/race_tests.rs b/pax/modes/read/race_tests.rs index c5261c523..a9225bec9 100644 --- a/pax/modes/read/race_tests.rs +++ b/pax/modes/read/race_tests.rs @@ -800,3 +800,57 @@ fn a_later_member_of_a_renamed_name_ends_the_rename() { PathBuf::from("again") ); } + +/// Every link to a file changes its ctime, so every record of that file must +/// see the change: a chain of link members -- f, a -> f, b -> f, c -> a -- +/// each made in a later clock tick, links them all. Each record held its own +/// copy of the ctime, and the link for c, made through a's record, found it +/// stale and failed: "source file changed before it could be linked". +#[test] +fn a_chain_of_link_members_is_linked() { + let tmp = TempDir::new().unwrap(); + let tree = DirTree::open_path(tmp.path()).unwrap(); + let mut pending = PendingDirs::default(); + let mut links = super::Links::new(); + let named = |name: &str, target: &str| { + let mut entry = link_member(target); + entry.path = PathBuf::from(name); + entry + }; + let members = vec![ + own_member("f", EntryType::Regular, 0o644), + named("a", "f"), + named("b", "f"), + named("c", "a"), + ]; + let mut archive = Members(members.into_iter()); + // Each link in a later tick of the clock ctime is stamped from. + let hook = |point, _: libc::c_int, _: &CStr| { + if point == Point::Linking { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + }; + let results: Vec<_> = race_hook::with_hook(hook, || { + let mut results = Vec::new(); + while let Some(entry) = archive.read_entry().unwrap() { + let r = extract_entry( + &mut archive, + &entry, + &ReadOptions::default(), + &mut links, + &tree, + &mut pending, + ); + results.push(r.map_err(|e| e.to_string())); + } + results + }); + assert!(results.iter().all(Result::is_ok), "{results:?}"); + let id = |name: &str| { + let md = std::fs::metadata(tmp.path().join(name)).unwrap(); + (md.dev(), md.ino()) + }; + for name in ["a", "b", "c"] { + assert_eq!(id(name), id("f"), "{name}"); + } +} From d793a0375750bd27689cedf1bfec83559e16b47b Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 23:17:12 +0000 Subject: [PATCH 68/71] pax: leave a tombstone where a link member's name was taken right after the link extract_hardlink records its name as holding the target's file only when it does once linked; under -k a name left alone keeps its record. But without -k, a name taken by someone else right after the link kept the record of whatever earlier member was there, as if that file still were. It now leaves a tombstone there (Record::Failed), as a member that leaves nothing this run can vouch for does: a later link member naming it fails rather than act on either file. Under -k the name, and its record, are left as they were. Test (race_tests): f, g, and a link member g -> f whose name is taken right after the link -- through the race_hook Linked point, now also reached by extract_hardlink once its link is made -- leaves g a tombstone (before: g's earlier record). Co-Authored-By: Claude Opus 5.5 --- pax/modes/read.rs | 10 +++++++++ pax/modes/read/race_tests.rs | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/pax/modes/read.rs b/pax/modes/read.rs index 09b72c2af..a55e34bad 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -781,6 +781,12 @@ fn extract_hardlink( name, options.no_clobber, )?; + #[cfg(test)] + crate::modes::race_hook::reached( + crate::modes::race_hook::Point::Linked, + dirfd.as_raw_fd(), + name, + ); // Linking it changed the file's ctime. And this name holds that file // now, for a later member linked to it -- unless -k left another file // there. Linked by name, it holds what the name held. @@ -794,6 +800,10 @@ fn extract_hardlink( if holds_it { let shared = target.share(); links.record_made(member.key(), shared); + } else if !options.no_clobber { + // Linked, then taken at once: nothing this run can vouch for is + // there. Under -k the name was left as it was, and so is its record. + links.tombstone(member.key()); } Ok(()) diff --git a/pax/modes/read/race_tests.rs b/pax/modes/read/race_tests.rs index a9225bec9..539d70091 100644 --- a/pax/modes/read/race_tests.rs +++ b/pax/modes/read/race_tests.rs @@ -854,3 +854,44 @@ fn a_chain_of_link_members_is_linked() { assert_eq!(id(name), id("f"), "{name}"); } } + +/// A link member whose name, once linked, no longer holds the file -- taken +/// by someone else at once -- leaves nothing this run can vouch for at its +/// name: not the record of an earlier member there. +#[cfg(target_os = "linux")] +#[test] +fn a_link_member_whose_name_is_taken_at_once_leaves_a_tombstone() { + let tmp = TempDir::new().unwrap(); + let tree = DirTree::open_path(tmp.path()).unwrap(); + let mut pending = PendingDirs::default(); + let mut links = super::Links::new(); + let mut archive = Members( + vec![ + own_member("f", EntryType::Regular, 0o644), + own_member("g", EntryType::Regular, 0o644), + link_member("f"), + ] + .into_iter(), + ); + let path = tmp.path().to_path_buf(); + let hook = move |point, _: libc::c_int, name: &CStr| { + if point == Point::Linked && name == c"g" { + std::fs::write(path.join("planted"), "planted\n").unwrap(); + std::fs::rename(path.join("planted"), path.join("g")).unwrap(); + } + }; + race_hook::with_hook(hook, || { + while let Some(entry) = archive.read_entry().unwrap() { + let _ = extract_entry( + &mut archive, + &entry, + &ReadOptions::default(), + &mut links, + &tree, + &mut pending, + ); + } + }); + let g = MemberPath::parse(Path::new("g")).unwrap().unwrap().key(); + assert!(matches!(links.made.get(&g), Some(super::Record::Failed))); +} From 6f58e3541e12ef5717d12d2881d495c2ee12d142 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 23:18:47 +0000 Subject: [PATCH 69/71] pax -i: end a rename when a later member of the name is turned away too b601dbc3 ended an earlier -i rename when a later member of the same name was extracted at its own name. A later member turned away -- by a pattern, -u, or -i's skip -- is still the latest member of that name, which a link member naming it refers to; it was not extracted, so the earlier rename no longer applies either, and the link member is linked by name as for any member never extracted. Links::turned_away drops the rename. Renames are kept under a member's name after -s (and --strip-components), and a pattern or -u can turn a member away before -s is applied, so its name is taken from the archive and substituted again here. This runs only while there are renames, that is under -i. Test: a rename of f ends when a later f is turned away, and when a later member whose name -s makes f is (mutation check: not dropping it fails the test). Co-Authored-By: Claude Opus 5.5 --- pax/modes/read.rs | 17 +++++++++++++++++ pax/modes/read/race_tests.rs | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/pax/modes/read.rs b/pax/modes/read.rs index a55e34bad..9bd2fc108 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -153,6 +153,9 @@ fn extract_members( records.apply(&mut entry); } links.sets.count_name(&entry); + // Under -i, a member turned away still ends an earlier rename of its + // name (`Links::turned_away`). + let original = (!links.aliases.is_empty()).then(|| entry.path.clone()); let mut renamed_from = None; let selected = select_member( &mut selector, @@ -164,6 +167,8 @@ fn extract_members( )?; if selected { links.named(renamed_from.as_deref(), &entry.path); + } else if let Some(original) = original { + links.turned_away(original, options); } if selected { // Per POSIX CONSEQUENCES OF ERRORS: diagnose a per-file failure and @@ -678,6 +683,18 @@ impl Links { } } + /// A member at `original` (its name in the archive) was turned away -- by + /// a pattern, -u, or -i's skip. It is still the latest member of its + /// name, which a link member naming it refers to, and it was not + /// extracted: an earlier rename of the name no longer applies. Its name + /// after -s (and --strip-components) is what renames are kept under. + fn turned_away(&mut self, original: PathBuf, options: &ReadOptions) { + let mut named = ArchiveEntry::new(original, EntryType::Regular); + if rename_member(&mut named, &options.substitutions, options.strip_components) { + self.named(None, &named.path); + } + } + /// The name a link member's `target` is to be found at: the name -i gave /// that member, where it gave one. A link member names its target by its /// name in the archive (after -s), which -i does not rename. diff --git a/pax/modes/read/race_tests.rs b/pax/modes/read/race_tests.rs index 539d70091..7f7ec6e3e 100644 --- a/pax/modes/read/race_tests.rs +++ b/pax/modes/read/race_tests.rs @@ -895,3 +895,22 @@ fn a_link_member_whose_name_is_taken_at_once_leaves_a_tombstone() { let g = MemberPath::parse(Path::new("g")).unwrap().unwrap().key(); assert!(matches!(links.made.get(&g), Some(super::Record::Failed))); } + +/// A later member of a name -i renamed, turned away -- by a pattern, -u, or +/// -i's skip -- is still the latest member of that name: the earlier rename +/// no longer applies to a link member naming it. The name is taken after -s. +#[test] +fn a_member_turned_away_ends_an_earlier_rename() { + let mut links = super::Links::new(); + links.named(Some(Path::new("f")), Path::new("renamed")); + links.turned_away(PathBuf::from("f"), &ReadOptions::default()); + assert_eq!(links.link_target(PathBuf::from("f")), PathBuf::from("f")); + + links.named(Some(Path::new("f")), Path::new("renamed")); + let options = ReadOptions { + substitutions: vec![crate::subst::Substitution::parse(",^x$,f,").unwrap()], + ..Default::default() + }; + links.turned_away(PathBuf::from("x"), &options); + assert_eq!(links.link_target(PathBuf::from("f")), PathBuf::from("f")); +} From 77fab0d7e954c3f71b621f6500d74662d01d6e20 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 23:27:24 +0000 Subject: [PATCH 70/71] pax -i: do not report a turned-away member's -s rename again 6f58e354 found a turned-away member's name after -s with rename_member, which reports a `p` substitution's rename on standard error. A member -i's skip turned away, after -s had already renamed and reported it, reported its rename a second time; one a pattern or -u turned away, which -s never saw, reported a rename that was never made. Links::turned_away now substitutes without reporting, as a link target is (substitute_link_target), and strips --strip-components the same way. Test: -r -i -s ',x,y,p' over x1 (renamed at the prompt) and x2 (skipped) reports x2 >> y2 once (before: twice); with the pattern x1, x2 is never reported. Co-Authored-By: Claude Opus 5.5 --- pax/modes/read.rs | 16 ++++++++++++---- pax/tests/subst/mod.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/pax/modes/read.rs b/pax/modes/read.rs index 9bd2fc108..ec0199bea 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -687,11 +687,19 @@ impl Links { /// a pattern, -u, or -i's skip. It is still the latest member of its /// name, which a link member naming it refers to, and it was not /// extracted: an earlier rename of the name no longer applies. Its name - /// after -s (and --strip-components) is what renames are kept under. + /// after -s (and --strip-components) is what renames are kept under, + /// substituted here without reporting it: a `p` substitution reports a + /// member's rename where -s renames the member, if it gets that far. fn turned_away(&mut self, original: PathBuf, options: &ReadOptions) { - let mut named = ArchiveEntry::new(original, EntryType::Regular); - if rename_member(&mut named, &options.substitutions, options.strip_components) { - self.named(None, &named.path); + let named = + substitute_link_target(&options.substitutions, &original).and_then( + |name| match options.strip_components { + 0 => Some(name), + n => strip_leading_components(&name, n), + }, + ); + if let Some(named) = named { + self.named(None, &named); } } diff --git a/pax/tests/subst/mod.rs b/pax/tests/subst/mod.rs index 8835bbefe..bf7bdee5b 100644 --- a/pax/tests/subst/mod.rs +++ b/pax/tests/subst/mod.rs @@ -539,3 +539,41 @@ fn test_subst_global_caret_anchors_once() { assert_eq!(list_substituted(b"aa", b",^a,,g", "C"), b"a\n"); assert_eq!(list_substituted(b"aab", b",^a,X,g", "C"), b"Xab\n"); } + +/// A `p` substitution reports each member's rename once. Under -i a member +/// skipped at the prompt, after -s has renamed it, ends any earlier -i rename +/// of its name -- and must not report its -s rename a second time doing so. +#[test] +fn test_subst_p_reports_a_member_skipped_under_i_once() { + let temp = TempDir::new().unwrap(); + let src = temp.path().join("src"); + fs::create_dir(&src).unwrap(); + fs::write(src.join("x1"), "1\n").unwrap(); + fs::write(src.join("x2"), "2\n").unwrap(); + let archive = temp.path().join("a.tar"); + let out = run_pax_in_dir(&["-w", "-f", archive.to_str().unwrap(), "x1", "x2"], &src); + assert!(out.status.success()); + let dest = temp.path().join("dest"); + fs::create_dir(&dest).unwrap(); + + // x1 (as y1) renamed to z at the prompt; x2 (as y2) skipped. + let args = ["-r", "-i", "-s", ",x,y,p", "-f", archive.to_str().unwrap()]; + let (out, _) = run_pax_on_tty(&args, &dest, b"z\n\n", std::time::Duration::from_secs(10)) + .expect("pax -r -i did not finish"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(stderr.matches("x2 >> y2").count(), 1, "{stderr}"); + assert_eq!(stderr.matches("x1 >> y1").count(), 1, "{stderr}"); + assert!(dest.join("z").exists()); + assert!(!dest.join("y2").exists()); + + // x2 turned away by a pattern, before -s ever saw it: nothing to report. + let dest = temp.path().join("dest2"); + fs::create_dir(&dest).unwrap(); + let mut args = args.to_vec(); + args.push("x1"); + let (out, _) = run_pax_on_tty(&args, &dest, b"z\n", std::time::Duration::from_secs(10)) + .expect("pax -r -i x1 did not finish"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(stderr.matches("x2 >> y2").count(), 0, "{stderr}"); + assert!(dest.join("z").exists()); +} From 0c8a106e0217e38a2fb81749ab71082549211c73 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Fri, 9 Oct 2026 23:28:49 +0000 Subject: [PATCH 71/71] plib, pax: document the residuals of trusting root's directories and of shared ctime From the audit of 76be6941..6f58e354, each where the rule it qualifies is described: - dir_writers: a root:root group-writable directory counts as root's alone when group root is root's private group, which reads the group's member list, not every account's primary gid -- on RHEL and Fedora, sync, shutdown, halt and operator have primary gid 0 and can write it, and it is now trusted for every user; - dir_writers: on macOS an ACL on a directory of root's is not read, as on the user's own; - ChainTrust::named: a link root's directory holds counts as vouched for, so a named path through it into another user's tree is judged by the link's holder alone; - MadeFile::linked: unpinned, the shared ctime is refreshed by name (lstat and an identity check); in a directory others can write, a file made again at the name with the same inode number in between would give its ctime to every record of the file. Co-Authored-By: Claude Opus 5.5 --- pax/modes/pins.rs | 8 ++++++++ plib/src/madefs.rs | 13 +++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/pax/modes/pins.rs b/pax/modes/pins.rs index 78f780de7..d844938bc 100644 --- a/pax/modes/pins.rs +++ b/pax/modes/pins.rs @@ -170,6 +170,14 @@ impl MadeFile { /// A name has just been linked to it, which changed its `ctime`. Pinned, /// the pin shows that when it is closed; unpinned, it is read from `name` /// in `dirfd` if that holds the file. + /// + /// Residual: unpinned, the read is by name -- an `lstat` and a check of + /// the identity. In a directory others can write, a file removed and + /// another made at `name` with the same inode number between the link + /// and the `lstat` would give its ctime to the shared cell, and so to + /// every record of this file: a later link to it would then be refused, + /// or, while that other file is still at a name of this one, made to + /// that file instead. pub(crate) fn linked(&mut self, dirfd: BorrowedFd<'_>, name: &CStr) { if self.pin.is_some() { return; diff --git a/plib/src/madefs.rs b/plib/src/madefs.rs index 793433ff1..dcdab9373 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -511,7 +511,10 @@ impl ChainTrust { /// /// Residual: a relative path starts from the working directory, which is trusted as the user /// named it -- by running there -- and so are the directories of an absolute path above its - /// first link: only the directories holding links are judged. + /// first link: only the directories holding links are judged. Since root's own directories + /// are trusted for every user (`dir_writers`), so is a link root's directory holds: a named + /// path through such a link into another user's tree is judged by the link's holder alone, + /// not by that user's directories it leads through. pub fn named(path: &Path, dir: &Rc) -> io::Result { let Some(holders) = link_holders(path, dir.as_raw_fd()) else { return Ok(NamedAnchor { @@ -624,7 +627,13 @@ enum DirWriters { /// - root, for every effective user, on the same terms: nobody but root can create entries in /// a directory only root can write, and root needs no trick to change anything. Every path /// starts in root's directories -- `/`, and on macOS the `/var` and `/tmp` links it holds -- -/// and a user's own tree below them is as safe as root's are; +/// and a user's own tree below them is as safe as root's are. Residuals of trusting root's: +/// - a `root:root` directory with group write counts as root's alone when group `root` is +/// root's private group, and that check reads the group's member list, not every account's +/// primary group: on RHEL and Fedora, `sync`, `shutdown`, `halt` and `operator` have +/// primary gid 0 and can write such a directory, which is now trusted for every user; +/// - on macOS an ACL is never read (`acls_let_others_write`), so one granting someone else +/// write on a directory of root's goes unseen, as it does on the user's own; /// - for root, on Linux only (`ROOT_WORKS_FOR_USERS`), one user other than root, the one the /// copy or extraction is for -- root extracting into `/home/alice`, which alice owns, with /// the directories a umask of 002 left below it. That user is the owner of the first directory in the chain not root's own, and