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/NONPOSIX.md b/NONPOSIX.md index cfa4900d7..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 @@ -228,6 +232,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 +653,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/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 7778d9572..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 { @@ -759,14 +780,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 +796,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 +846,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)), }, @@ -1072,8 +1095,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 +1136,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, @@ -1201,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(), }; @@ -1461,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, } @@ -1478,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, } @@ -1512,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, } @@ -1626,7 +1671,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/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) { 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/pax/archive.rs b/pax/archive.rs index 3eaa49080..8ed2bbbd8 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,62 @@ 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 HardLinkTracker { +impl Default for HardLinkTracker { + fn default() -> Self { + HardLinkTracker { + stored: HashMap::new(), + } + } +} + +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<&T> { if nlink <= 1 { return None; } - self.stored.get(&(dev, ino)).cloned() + self.stored.get(&(dev, ino)) } - /// Note that a file's first name has been stored, as `stored`: the archive - /// member path when writing, the destination path when copying. + /// `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 + /// 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); } } } @@ -446,6 +469,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. @@ -464,7 +518,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)) } @@ -482,9 +536,29 @@ 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); + } + } + + /// 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) + } + + /// 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 +569,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, }); } } @@ -524,6 +601,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 { @@ -557,6 +635,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] @@ -581,15 +693,18 @@ 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"))); + assert_eq!( + links.lookup(1, 9, 2).map(PathBuf::as_path), + Some(Path::new("a")) + ); } } } diff --git a/pax/formats/cpio.rs b/pax/formats/cpio.rs index 950a90b72..67e72862d 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,82 @@ 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, +} + +/// 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. +/// +/// 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, + 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, stored: u64| { + if value <= max { + return (value, false); + } + substitutes.push(Substituted { + field, + format, + value, + stored, + }); + (stored, true) + }; + 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; + } + 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 +1380,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 +1399,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 +1498,96 @@ 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. 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_refused_by_odc_and_binary() { + fn test_wide_owner_ids_are_stored_as_the_overflow_id() { 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), 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, 65534), ("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, 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")]); + + 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] diff --git a/pax/modes/anchored.rs b/pax/modes/anchored.rs index dcc2adf27..46bd69a18 100644 --- a/pax/modes/anchored.rs +++ b/pax/modes/anchored.rs @@ -21,8 +21,10 @@ //! always created fresh rather than written through. use crate::error::{PaxError, PaxResult}; -use crate::modes::made::{self, cvt, verify_made_dir, MadeNode, MadeTrust}; -use plib::madefs::{ChainTrust, FoundDir, Preserve}; +use crate::modes::made::{self, verify_made_dir, MadeNode, MadeTrust}; +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; use std::collections::{HashMap, HashSet}; use std::ffi::{CStr, CString, OsStr}; @@ -146,27 +148,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. @@ -182,7 +166,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 @@ -196,7 +181,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 @@ -205,6 +193,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 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,9 +215,19 @@ 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 + /// 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 @@ -240,8 +240,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 { @@ -254,9 +262,18 @@ 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) }); + let named = if preserving { + Some(ChainTrust::named(path, &root)?) + } else { + None + }; Ok(DirTree { - root_trust: ChainTrust::anchor(root.as_raw_fd())?, + root_trust: match &named { + Some(named) => named.hands.clone(), + None => ChainTrust::anchor(&root)?, + }, + _named: named, root, chain: RefCell::new(Chain::default()), max_levels: cached_levels_budget(), @@ -265,6 +282,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()), }) } @@ -294,7 +312,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())); } } @@ -308,7 +326,7 @@ impl DirTree { Ok((fd, trust)) => Some(LastParent { dirs: member.dirs.clone(), fd: Rc::clone(fd), - trust: *trust, + trust: trust.clone(), }), Err(_) => None, }; @@ -332,7 +350,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()); @@ -356,19 +375,19 @@ impl DirTree { .or_insert(mtime_of(&st)); } } - trust = match self.standing(id, key) { - Standing::Implicit | Standing::Made => ChainTrust::made(next.as_raw_fd())?, - _ => trust.found(next.as_raw_fd())?, - }; let next = Rc::new(next); + trust = match self.standing(&st, key) { + Standing::Implicit | Standing::Made => ChainTrust::made(&next)?, + _ => trust.found(&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)), } } @@ -379,12 +398,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 @@ -429,6 +455,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); @@ -438,6 +465,33 @@ 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`). + /// + /// 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) { + 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 @@ -462,7 +516,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))); } @@ -485,10 +539,19 @@ 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 { - 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 @@ -500,7 +563,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); @@ -685,32 +748,47 @@ 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_raw_fd()) + .ok() + .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, policy)?, + Standing::Implicit | Standing::Made => (true, true), + Standing::Ordinary => (found_dir_with_mode(trust, &fd, policy)?, false), + }; + 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) }; - if search_only { - return set_attrs_search_only(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 Ok(st) = fstat(fd.as_raw_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, 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( @@ -799,7 +877,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 @@ -831,6 +914,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 { @@ -841,10 +953,11 @@ 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, + left_as: LeftAs::of(&st), })) } } @@ -968,17 +1081,23 @@ 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(id, &member.key()) { + return match tree.standing(&st, &member.key()) { Standing::Replaced => Err(PaxError::Io(made::replaced())), Standing::Implicit => { 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)), @@ -1017,7 +1136,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; @@ -1040,7 +1159,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. @@ -1111,6 +1232,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. @@ -1119,24 +1273,18 @@ 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 .`). -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. +/// diagnostic. Identity is (dev, ino): the source's is the pinned inode's, or +/// `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 .`). +/// +/// `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. /// /// `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 @@ -1145,17 +1293,21 @@ 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, - follow: bool, - expected: Option<(u64, u64)>, + follow: Option<(u64, u64)>, + expected: Option>, dirfd: BorrowedFd<'_>, name: &CStr, no_clobber: bool, ) -> PaxResult { - let source = LinkSource::new(from_dir, from_name, follow, expected)?; + #[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.is_some(), expected)?; let link = || source.link_to(dirfd, name); match link() { @@ -1167,16 +1319,34 @@ pub(crate) fn link_replacing_with( if no_clobber { return Ok(false); } - 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); - let link = follow - .then(|| fstatat(from_dir, from_name, libc::AT_SYMLINK_NOFOLLOW)) - .flatten(); - if [resolved, link] - .iter() + #[cfg(test)] + crate::modes::race_hook::reached( + crate::modes::race_hook::Point::LinkExists, + dirfd.as_raw_fd(), + 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.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 .`): the link the caller examined, by the + // identity it saw, not by its name resolved again. + if [resolved, follow] + .into_iter() .flatten() - .any(|src| file_id(src) == file_id(&dst)) + .any(|src| src == file_id(&dst)) { return Ok(true); } @@ -1194,7 +1364,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: 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. Name { @@ -1202,22 +1375,61 @@ 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`: 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")] + 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 }; + // 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. + 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 }; @@ -1225,34 +1437,71 @@ impl<'a> LinkSource<'a> { from_dir, from_name, flags, - expected, + expected: expected.map(|e| e.id), + #[cfg(target_os = "linux")] + held, }) } - /// 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: 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: (u64, u64), - ) -> PaxResult> { + expected: Expected<'a>, + ) -> PaxResult> { let Ok(proc_dir) = made::procfs_dir() else { - return Ok(None); + return Ok(Pinning::Unavailable); }; - 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_fd()).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 })) + // 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 + /// 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_raw_fd()).ok().map(|st| file_id(&st)), + LinkSource::Name { expected, .. } => *expected, + } } /// Make `name` in `dirfd` a hard link to the source. @@ -1273,19 +1522,56 @@ 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 /// 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(()) + } + } + } +} + +/// 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(), } } } @@ -1313,7 +1599,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)?; @@ -1423,7 +1712,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 @@ -1469,7 +1761,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()), @@ -1540,7 +1832,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); } } @@ -1795,10 +2090,49 @@ 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 Making::new(false), + ) +} + +/// `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 Making, ) -> PaxResult<()> { let node = MadeNode::pin(dirfd, name, made_type)?; + let applied = apply_node_attrs(&node, made_type, attrs, policy); + 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.file.is_none() { + applied?; + return Err(PaxError::Io(made::replaced())); + } + 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); } @@ -1807,7 +2141,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(()) } @@ -1833,26 +2167,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::*; @@ -1863,7 +2177,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 { @@ -1992,14 +2306,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/" ); @@ -2007,11 +2326,275 @@ 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 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); + } + } + + /// 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(&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| { + 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", + 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(); + 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(Expected::id(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"); + } + + /// 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 + /// -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(&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 = lstat_at(tree.root().as_raw_fd(), 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 @@ -2025,7 +2608,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(_))); @@ -2039,10 +2622,14 @@ 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") + )); - assert!(tree.standing(r, &r_key) == Standing::Replaced); - assert!(tree.standing(u, &u_key) == Standing::Unverified); + 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); } /// Every site that enters, merges into or stamps a directory consults the @@ -2056,7 +2643,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")); @@ -2153,6 +2740,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, @@ -2233,7 +2855,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, + None, + 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(), @@ -2251,7 +2882,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, + None, + 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 694dc7608..0937929ba 100644 --- a/pax/modes/copy.rs +++ b/pax/modes/copy.rs @@ -17,15 +17,17 @@ 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, stat_at, 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, + 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::lstat_at; 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}; @@ -100,13 +102,15 @@ 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, 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 { Some(InteractivePrompter::new()?) } else { @@ -117,7 +121,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)); } @@ -167,6 +171,49 @@ 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). +struct CopiedLinks { + names: HardLinkTracker<(PathBuf, MadeFile)>, + 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. 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), + nlink: u32, + member: &Path, + made: Option, + ) { + // A file with one name has no later name to be linked to it. + let Some(made) = made.filter(|_| nlink > 1) else { + return; + }; + 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((_, made)) = names.by_key_mut(*key) { + made.unpin(); + } + }); + } +} + /// State the three traversal callbacks share. /// /// The destination side is untouched by this: every leaf is still resolved @@ -178,10 +225,20 @@ 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. A source directory found in here is one being copied *into*. + /// 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 + /// 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 @@ -267,12 +324,20 @@ impl CopyWalk<'_> { // 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())) - { + // + // 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 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 @@ -327,7 +392,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); } @@ -365,11 +430,46 @@ 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) } + /// 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_ids`). + fn record_made_file(&self, dirfd: BorrowedFd<'_>, name: &CStr) { + if self.options.substitutions.is_empty() && !self.options.interactive { + return; + } + if let Ok(dir) = lstat_at(dirfd.as_raw_fd(), 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()) }; + 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())) + } + /// Create the destination directory and arrange for its attributes to be /// applied once its contents exist. /// @@ -400,7 +500,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); } @@ -423,8 +523,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", @@ -465,6 +564,10 @@ 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.dest_ids + .borrow_mut() + .insert((metadata.dev(), metadata.ino())); self.print_verbose(src); self.descend(member, metadata) } @@ -560,9 +663,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()) }; - stat_at(dir, entry.file_name()).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. @@ -677,7 +787,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(); @@ -690,17 +800,14 @@ 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 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), - Some((metadata.dev(), metadata.ino())), + follow, + Some(Expected::id((metadata.dev(), metadata.ino()))), dirfd, name, options.no_clobber, @@ -729,44 +836,54 @@ 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) { - let Some(target) = MemberPath::parse(&link_target)? else { - return do_copy_file(entry, dirfd, name, metadata, options); + 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, false).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, + None, + Some(copy.expected()), dirfd, name, options.no_clobber, )?; + // Linking a name changed its ctime. + copy.linked(dirfd, name); return Ok(()); } - 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); + link_tracker.record((dev, ino), nlink, member, copy); Ok(()) } /// 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 +/// 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<()> { + 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 // source path. Whether the walk dereferenced this entry is observable from @@ -803,7 +920,7 @@ fn do_copy_file( let Some(mut dest_file) = opened else { debug_assert!(!created); - return Ok(()); + return Ok(None); }; copy_contents(&mut src_file, &mut dest_file, metadata.size())?; @@ -812,9 +929,11 @@ 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(), pin)?; // 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. @@ -944,6 +1063,70 @@ 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" + ); + } + + /// 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()); + } + + /// 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(); diff --git a/pax/modes/made.rs b/pax/modes/made.rs index c9e0c96d2..f259f0650 100644 --- a/pax/modes/made.rs +++ b/pax/modes/made.rs @@ -25,8 +25,9 @@ //! 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::{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 +58,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 +68,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 +150,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, @@ -177,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, pin: bool) -> Option { + MadeFile::held(self.fd.as_fd(), pin).ok() + } + pub(crate) fn trust(&self) -> MadeTrust { self.trust } @@ -212,7 +202,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 +238,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 +268,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()); } @@ -301,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, _pin: bool) -> 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 } @@ -352,7 +358,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 +404,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/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..d844938bc --- /dev/null +++ b/pax/modes/pins.rs @@ -0,0 +1,395 @@ +// +// 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. +//! +//! 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}; +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)] +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. 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, +} + +impl MadeFile { + /// The file open on `fd` -- for writing, or `O_PATH` -- which this run + /// 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: Rc::new(Cell::new(ctime_of(&st))), + pin: if pin { reopen_path(fd) } else { None }, + }) + } + + /// 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<'_>, keep: bool) -> io::Result { + let st = fstat(pin.as_raw_fd())?; + // Without a duplicate it is known by identity and ctime alone. + 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: Rc::new(Cell::new(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(any(test, not(target_os = "linux")))] + pub(crate) fn unpinned(st: &libc::stat) -> Self { + MadeFile { + id: file_id(st), + ctime: Rc::new(Cell::new(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.set(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, 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(pinned_ctime.unwrap_or(self.ctime.get())), + 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.set(ctime_of(&st)); + } + } + } + + /// 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: Rc::clone(&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 { + 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: Rc::clone(&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. + /// + /// 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; + } + if let Ok(st) = lstat_at(dirfd.as_raw_fd(), name) { + if file_id(&st) == self.id { + self.ctime.set(ctime_of(&st)); + } + } + } +} + +/// 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. + #[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)), + } + } + + /// 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`). + 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(), true).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: Rc::new(Cell::new(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/race_hook.rs b/pax/modes/race_hook.rs index 5b3dac323..8e43782ac 100644 --- a/pax/modes/race_hook.rs +++ b/pax/modes/race_hook.rs @@ -30,11 +30,14 @@ 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, + /// A link to the source, already pinned, found the destination `name` + /// taken. + LinkExists, } type Hook = Box; diff --git a/pax/modes/read.rs b/pax/modes/read.rs index f16ef7741..ec0199bea 100644 --- a/pax/modes/read.rs +++ b/pax/modes/read.rs @@ -9,18 +9,21 @@ //! 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}; 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_with, make_dir_at, set_attrs_fd, + set_made_node_attrs_recording, unlink_at, AttrPolicy, Attrs, DirAttrs, DirTree, Expected, + MemberPath, PendingDirs, }; +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}; +use plib::madefs::lstat_at; +use std::collections::HashMap; use std::ffi::{CStr, CString}; use std::fs::File; use std::io::Write; @@ -125,7 +128,7 @@ fn extract_members( tree: &DirTree, pending_dirs: &mut PendingDirs, ) -> PaxResult<()> { - let mut link_sets: LinkSets = LinkSets::default(); + let mut links = Links::new(); let mut selector = Selector::new( &options.patterns, options.exclude, @@ -149,22 +152,48 @@ fn extract_members( if let Some(ref records) = option_records { records.apply(&mut entry); } - if select_member(&mut selector, &mut entry, options, &mut prompter, tree)? { + 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, + &mut entry, + options, + &mut prompter, + tree, + &mut renamed_from, + )?; + 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 // 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, 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); + if r.is_ok() { + links.record_set(&entry); + } 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) = links.sets.settled_mut(&entry) { + set.unpin(); + } + 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.values().all(|set| set.has_data) { + if selector.is_done() && links.sets.all_settled() { reached_end = false; break; } @@ -200,6 +229,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); @@ -219,7 +249,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))) @@ -316,7 +348,7 @@ fn extract_entry( archive: &mut R, entry: &ArchiveEntry, options: &ReadOptions, - link_sets: &mut LinkSets, + links: &mut Links, tree: &DirTree, pending_dirs: &mut PendingDirs, ) -> PaxResult<()> { @@ -359,9 +391,19 @@ fn extract_entry( let _ = std::io::Write::write_all(&mut std::io::stderr().lock(), &line); } + // 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)?; + 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 @@ -372,23 +414,36 @@ fn extract_entry( } } EntryType::Symlink => { - extract_symlink(pfd, name, entry, options)?; + let mut made = Making::new(pin); + let result = extract_symlink(pfd, name, entry, options, &mut made); + links.left(&member, result.is_err(), made.file); + result?; archive.skip_data()?; } EntryType::Hardlink => { - extract_hardlink(tree, pfd, name, entry, options)?; + 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 => { - extract_regular(archive, tree, pfd, &member, entry, options, link_sets)?; + 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 = Making::new(pin); + let result = extract_device(pfd, name, entry, options, &mut made); + links.left(&member, result.is_err(), made.file); + result?; archive.skip_data()?; } EntryType::Fifo => { - extract_fifo(pfd, name, entry, options)?; + let mut made = Making::new(pin); + let result = extract_fifo(pfd, name, entry, options, &mut made); + links.left(&member, result.is_err(), made.file); + result?; archive.skip_data()?; } EntryType::Socket => { @@ -460,6 +515,7 @@ fn extract_symlink( name: &CStr, entry: &ArchiveEntry, options: &ReadOptions, + made: &mut Making, ) -> PaxResult<()> { let target = entry .link_target @@ -477,24 +533,248 @@ 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(()) } +/// 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, + /// 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>, + /// 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. +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 { + /// 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(), + 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 { + return; + }; + let Some(set) = self.sets.by_key_mut(key) else { + return; + }; + let pinned = set.file.is_pinned(); + 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(), 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 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); + } + } + } + } + + /// 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, + /// 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 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); + } + } + + /// 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 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() { + 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(Record::File(made)) = made.get_mut(key) { + made.unpin(); + } + } + }); + } +} + /// Extract a hard link fn extract_hardlink( tree: &DirTree, dirfd: BorrowedFd<'_>, - name: &CStr, + member: &MemberPath, entry: &ArchiveEntry, options: &ReadOptions, + links: &mut Links, ) -> 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(), )); }; + let target = links.link_target(target); let Some(target_member) = MemberPath::parse(&target)? else { return Err(PaxError::InvalidHeader( @@ -510,13 +790,46 @@ 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 -- 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(), &target_member.leaf, + None, + links.target(&target_key)?, dirfd, 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. + let Some(Record::File(target)) = links.made.get_mut(&target_key) else { + links.forget(&member.key()); + return Ok(()); + }; + target.linked(dirfd, name); + 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); + } 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(()) } @@ -525,45 +838,44 @@ 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 + /// (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. 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, } impl CreatedSet { - /// The set as created at `name` below `dirfd`: pinned while the data is - /// still to come on a later name. - 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.file.unpin(); + } + /// 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. 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() } } @@ -580,19 +892,47 @@ fn extract_regular( member: &MemberPath, entry: &ArchiveEntry, options: &ReadOptions, - link_sets: &mut LinkSets, + links: &mut Links, ) -> PaxResult<()> { - if let Some(set) = link_sets.find_mut(entry) { - return join_link_set(archive, tree, dirfd, member, entry, options, set); + if let Some(set) = links.sets.find_mut(entry) { + let result = join_link_set(archive, tree, dirfd, member, entry, options, set); + match result { + Err(_) => links.left(member, true, None), + Ok(()) => links.record_set(entry), + } + return result; } - if let Some(file) = extract_file(archive, dirfd, member.leaf.as_c_str(), entry, options)? { - link_sets.insert(entry, || { - let names = vec![member.display.clone()]; - CreatedSet::new(names, file, entry.size > 0, dirfd, &member.leaf) - }); - } - Ok(()) + let mut making = Making::new(links.pin_member); + let result = extract_file( + archive, + dirfd, + member.leaf.as_c_str(), + entry, + options, + &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 + // 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 } /// Extract a later name of a link set whose file is already on disk. @@ -619,18 +959,23 @@ 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 { - 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, + None, + 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(()); @@ -638,15 +983,18 @@ fn join_link_set( } let holders = set.holders(tree); - let Some(file) = extract_file(archive, dirfd, name, entry, options)? else { + // 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.file 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)?; + 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(()) } @@ -673,32 +1021,44 @@ 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)?))); + let mut made = Making::new(true); + let filled = write_file_data(archive, file, entry, options, &mut made).and_then(|()| { + 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)) + }); 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(()) } /// 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: &mut MadeFile, ) -> 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, + None, + Some(file.expected()), + dir.as_fd(), + &leaf, + false, + )?; + file.linked(dir.as_fd(), &leaf); names.push(path); } Ok(names) @@ -711,29 +1071,11 @@ 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)> { - stat_at(dirfd, name).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()) + lstat_at(dirfd.as_raw_fd(), name) + .ok() + .map(|st| crate::modes::anchored::file_id(&st)) } /// Extract a block or character device (requires root privileges) @@ -742,6 +1084,7 @@ fn extract_device( name: &CStr, entry: &ArchiveEntry, options: &ReadOptions, + made: &mut Making, ) -> PaxResult<()> { // makedev has different signatures on different platforms: // - Linux: makedev(major: u32, minor: u32) -> u64 @@ -779,7 +1122,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 @@ -788,6 +1131,7 @@ fn extract_fifo( name: &CStr, entry: &ArchiveEntry, options: &ReadOptions, + made: &mut Making, ) -> PaxResult<()> { let created = create_replacing(dirfd, name, options.no_clobber, || { let r = unsafe { @@ -814,18 +1158,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 Making, +) -> PaxResult<()> { let mut opened: Option = None; let created = create_replacing(dirfd, name, options.no_clobber, || { opened = Some(create_file(dirfd, name, entry, options)?); @@ -836,9 +1182,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 @@ -890,16 +1236,23 @@ fn write_file_data( mut file: File, entry: &ArchiveEntry, options: &ReadOptions, -) -> PaxResult<(u64, u64)> { - 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()?); + made: &mut Making, +) -> PaxResult<()> { + // Known from the descriptor that made it, whatever happens next. + 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.file.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(id) + Ok(()) } /// Copy file data from archive to file @@ -936,10 +1289,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. @@ -1016,9 +1371,10 @@ fn set_made_attrs( made_type: libc::mode_t, entry: &ArchiveEntry, options: &ReadOptions, + made: &mut Making, ) -> 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))] @@ -1026,6 +1382,77 @@ mod tests { use super::*; use std::os::unix::ffi::OsStringExt; + /// 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 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"))]; + + 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", &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""); + } + + /// 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 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(), true).unwrap()); + drop(f); + + 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 links) + }); + 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" + ); + } + /// 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 @@ -1171,7 +1598,15 @@ 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 Making::new(false), + ) + .unwrap(); assert_eq!(mode(), 0o755); // Preserved: exact 0o777 regardless of umask. @@ -1182,7 +1617,15 @@ 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 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 dbef9d3e2..7f7ec6e3e 100644 --- a/pax/modes/read/race_tests.rs +++ b/pax/modes/read/race_tests.rs @@ -271,25 +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 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, - &tree, - pending, - ) + extract_entry(&mut archive, &entry, &options, &mut links, &tree, pending) }); assert!(r.is_err(), "withholding the attributes went unreported"); @@ -305,26 +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 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, - &tree, - pending, - ) + extract_entry(&mut archive, &entry, &options, &mut links, &tree, pending) }; [extract().is_err(), extract().is_err()] }); @@ -368,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 @@ -435,3 +398,519 @@ 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.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( + &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())) + ); +} + +/// 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"); +} + +/// 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"); +} + +/// 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" + ); +} + +/// 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" + ); +} + +/// 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") + ); +} + +/// 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}"); + } +} + +/// 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))); +} + +/// 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")); +} diff --git a/pax/modes/write.rs b/pax/modes/write.rs index ce0a61a93..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 @@ -721,7 +723,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 { diff --git a/pax/tests/copy/mod.rs b/pax/tests/copy/mod.rs index fb01a51ce..b33a29ff9 100644 --- a/pax/tests/copy/mod.rs +++ b/pax/tests/copy/mod.rs @@ -1222,3 +1222,83 @@ 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:?}" + ); + } +} + +/// 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); +} diff --git a/pax/tests/cpio/mod.rs b/pax/tests/cpio/mod.rs index 57e1ef4c1..1e21bc3dd 100644 --- a/pax/tests/cpio/mod.rs +++ b/pax/tests/cpio/mod.rs @@ -801,3 +801,189 @@ 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"))); +} + +/// 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")); + } +} diff --git a/pax/tests/security/existing_dir.rs b/pax/tests/security/existing_dir.rs index d0b099dad..5e6d924b8 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,64 @@ 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}"); + } +} + +/// 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] @@ -248,7 +312,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 +338,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]); @@ -312,3 +378,107 @@ 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() { + // 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}"); + } + } +} + +/// 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"); + } +} 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()); +} diff --git a/plib/Cargo.toml b/plib/Cargo.toml index 1325d47c4..286598453 100644 --- a/plib/Cargo.toml +++ b/plib/Cargo.toml @@ -18,12 +18,22 @@ 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. [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 f5f50d13a..dcdab9373 100644 --- a/plib/src/madefs.rs +++ b/plib/src/madefs.rs @@ -19,15 +19,34 @@ #[cfg(target_os = "linux")] use gettextrs::gettext; -use std::ffi::CStr; +use std::cell::OnceCell; +use std::collections::HashMap; +use std::ffi::{CStr, CString, OsStr, OsString}; +use std::io; #[cfg(target_os = "linux")] -use std::ffi::CString; +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::fs::File; -use std::io; -use std::os::fd::RawFd; +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`). +/// +/// 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, 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")] -use std::os::fd::{AsRawFd, FromRawFd}; +pub const SEARCH_ONLY: libc::c_int = libc::O_PATH; +#[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")))] +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)] @@ -156,7 +175,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 @@ -178,6 +198,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)] @@ -203,76 +235,804 @@ 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`); 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 /// 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`). +/// +/// 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); + +/// 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 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 { + /// 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_owned(|| self.hands.0.uid, requested), + None => requested.where_trusted(), + } + } +} + +/// 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)] -pub struct ChainTrust { - /// Whether directories found existing in this one may take what was asked for. - entries_safe: bool, +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`, + /// `made`, `unlocated`). + 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. + 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 + /// 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, + 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 st = fstat(dir.as_raw_fd())?; + let writers = dir_writers(&st, euid, above_owner, start); + let owner = match writers { + // 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, + }; + let dir: Weak = Rc::downgrade(dir); + let dir: Weak = dir; + Ok(ChainTrust(Rc::new(Link { + above, + writers, + uid: Some(st.st_uid), + owner, + 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_in(anchor_fd)?, - }) + pub fn anchor(dir: &Rc) -> io::Result { + Self::link(None, dir, ChainStart::Anchor) + } + + /// 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(Rc::new(Link { + above: None, + writers: DirWriters::Others, + uid: None, + owner: None, + 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_in(dir_fd)?, - }) + pub fn found(&self, dir: &Rc) -> io::Result { + 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, ChainStart::Made) } - /// 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_in(dir_fd)?, + /// 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 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. + /// + /// 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. 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 { + hands: Self::unlocated(), + through_link: Some(Self::unlocated()), + _holders: Vec::new(), + }); + }; + let Some((first, rest)) = holders.split_first() else { + return Ok(NamedAnchor { + hands: Self::anchor(dir)?, + through_link: None, + _holders: holders, + }); + }; + let mut in_holders = Self::anchor(first)?; + for holder in rest { + in_holders = in_holders.found(holder)?; + } + Ok(NamedAnchor { + hands: in_holders.found(dir)?, + through_link: Some(in_holders), + _holders: holders, }) } - /// 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 { - if !requested.mode && !requested.owner { - FoundDir::TimesOnly - } else if self.entries_safe { - FoundDir::AsRequested - } else { - FoundDir::LeaveAlone + /// 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, + } + } + + /// 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 } } -/// `nobody_else_can_create` for the directory open on `fd`. -fn nobody_else_can_create_in(fd: RawFd) -> io::Result { - let euid = unsafe { libc::geteuid() }; - Ok(nobody_else_can_create(&fstat(fd)?, euid)) +impl Link { + /// 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); + 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() + } +} + +/// Who can create entries in a directory, as far as its `fstat` shows. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum DirWriters { + /// 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, + /// 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 }, } -/// 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. +/// `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`. /// -/// 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. -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). +/// 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. 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 +/// 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 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 +/// 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, + 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 = parent.st_mode as u32; - parent.st_uid == euid && mode & 0o022 == 0 + let mode = st.st_mode as u32; + let uid = st.st_uid; + 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 } + } else { + DirWriters::OwnerAndGroup { + uid, + gid: st.st_gid, + } + } +} + +/// 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 || uid == 0 || (works_for_users && euid == 0 && start == ChainStart::Anchor) + } + } +} + +/// 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 { + let euid = unsafe { libc::geteuid() }; + Ok(only_the_user_writes( + dir_writers(&fstat(fd)?, euid, None, ChainStart::Anchor), + Some(fd), + )) +} + +/// 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`, 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 +/// 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. +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::Owner { .. } => acls_allow(false), + DirWriters::OwnerAndGroup { uid, gid } => is_private_group(gid, uid) && acls_allow(true), + } +} + +/// 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. +/// +/// 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 { + 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 +/// 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. +/// +/// 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)) + }; + for name in NFS4_OR_CIFS { + match read(name) { + Err(e) if absent(&e) => {} + _ => return true, + } + } + if !group_writable { + return false; + } + match read(c"system.posix_acl_access") { + Ok(xattr) => acl_names_others(&xattr), + Err(e) => !absent(&e), + } +} + +/// 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`.) 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]; + 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 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. +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) + }) +} + +/// 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 (`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 +/// an ACL that lets others write is seen too (`nobody_else_can_create`); elsewhere no group is +/// private. +/// +/// Residuals: +/// - 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. +/// - 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. 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)); + 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); + *answers + .entry((gid, euid)) + .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) }; +} + +/// 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. 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()) +} + +/// `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; + }; + let Some((group_name, member_uids)) = group_entry(gid) else { + return false; + }; + let user = UserEntry { + uid: euid, + gid: user_gid, + name: user_name.as_bytes(), + }; + 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 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` (`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 +/// `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: &UserEntry<'_>, + group_name: &[u8], + member_uids: impl IntoIterator>, +) -> bool { + user.gid == gid + && group_name == user.name + && member_uids + .into_iter() + .all(|member| member == Some(user.uid)) } /// Check a directory the caller has just made with `mkdirat` in `parent_fd` and then opened as @@ -311,53 +1071,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. @@ -403,23 +1123,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. @@ -484,8 +1210,49 @@ 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 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` 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 { +pub fn procfs_dir() -> io::Result> { + // `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`): the descriptor, or `None` for a +/// `/proc` that is something else. +#[cfg(target_os = "linux")] +fn open_procfs() -> io::Result> { const PROC_SUPER_MAGIC: u32 = 0x9fa0; let fd = unsafe { libc::open( @@ -496,16 +1263,14 @@ 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()); } // `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`. @@ -549,12 +1314,17 @@ 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, + 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, + 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; use std::os::unix::fs::{MetadataExt, PermissionsExt}; + use std::path::Path; + use std::rc::Rc; /// A parent directory of `uid` with permission bits `mode`. fn parent(uid: u32, mode: libc::mode_t) -> libc::stat { @@ -568,17 +1338,425 @@ mod tests { /// entries beside it. #[test] fn who_can_create_in_a_parent() { + 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!(nobody_else_can_create(&parent(US, 0o755), US)); - assert!(nobody_else_can_create(&parent(0, 0o755), 0)); + 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!(!nobody_else_can_create(&parent(US, 0o1777), US)); - assert!(!nobody_else_can_create(&parent(0, 0o1777), 0)); - // Group or other write permission. - assert!(!nobody_else_can_create(&parent(US, 0o775), US)); - assert!(!nobody_else_can_create(&parent(US, 0o757), US)); + 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_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!(!nobody_else_can_create(&parent(OTHER, 0o755), US)); + assert_eq!(dir_writers(&parent(OTHER, 0o755), US), Others); + 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 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() }; + 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/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); + 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 (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, below root's own directory. + let home = as_root(None, &slash); + 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(&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(&*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(&*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 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(&*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. + // 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 { + 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. + // 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() }; + 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); + } + + #[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. + 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 + /// 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 -- on Linux only. + assert_eq!( + dir_writers(&parent(US, 0o755), 0, None, ChainStart::Anchor), + if ROOT_WORKS_FOR_USERS { + Owner { uid: US } + } else { + Others + } + ); + 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] + fn what_makes_a_group_private() { + 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, &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, &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, &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, &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] + 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])); + } + + /// 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_name, user_gid)) = user_entry(euid) else { + return; + }; + let Some((group_name, members)) = group_entry(user_gid) else { + assert!(!is_private_group(user_gid, euid)); + return; + }; + 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); + } + + /// 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 @@ -589,9 +1767,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(); } @@ -607,24 +1785,225 @@ 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, 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(&*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(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); + let made_x = ChainTrust::made(&x_fd).unwrap(); + 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(&*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(&*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] + 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(&*held, 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(&*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(&*held, 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(&*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: 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_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, 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(&child, mode) == FoundDir::AsRequested, + open, + "{perm:o}" + ); + drop(held); + assert_eq!( + again.found_dir(&child, 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 @@ -854,6 +2233,67 @@ mod tests { assert_eq!(made_by_us(dir(US, 3), Some(US), US), None); } + /// `/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); + } + + /// 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] diff --git a/plib/src/testing.rs b/plib/src/testing.rs index 7a78ef36c..a37aae390 100644 --- a/plib/src/testing.rs +++ b/plib/src/testing.rs @@ -741,6 +741,50 @@ 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::lookup_by_uid(euid).ok()??.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)) +} + +/// 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/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 dfd638ace..89fe8ea59 100644 --- a/tree/common/copy.rs +++ b/tree/common/copy.rs @@ -11,10 +11,13 @@ 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, NamedAnchor, Preserve, SEARCH_ONLY, +}; use std::{ cell::RefCell, collections::{HashMap, HashSet}, @@ -221,6 +224,277 @@ 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, 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 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`). + 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. +/// +/// Nor is it the run's own once anything but cp has changed it since cp last did: its +/// 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. 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>); + +/// 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)` 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, left) = id_and_status(md); + let path = path.to_path_buf(); + 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, left) = id_and_status(md); + self.0 + .get(&id) + .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, left) = id_and_status(md); + if let Some(at) = self.0.get_mut(&id).filter(|at| at.path == path) { + at.left = left; + } + } +} + +#[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 { + /// 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, + /// Whether this run made it, here (`MadeDirs`): what cp changes on it is then noted. + own: bool, +} + +/// 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`). +/// +/// 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, HeldAnchor)> { + let unlocated = || Ok((ChainTrust::unlocated(), None)); + let Some(name) = target.file_name() else { + return unlocated(); + }; + 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 Ok(path) = CString::new(path.as_os_str().as_bytes()) else { + return unlocated(); + }; + let flags = SEARCH_ONLY | libc::O_DIRECTORY | libc::O_CLOEXEC; + match open_fd_at(libc::AT_FDCWD, &path, flags) { + Ok(fd) => Some(Rc::new(fd)), + Err(_) => return unlocated(), + } + } else { + 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 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 unlocated(); + } + 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. +/// +/// 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: &Rc, + fd: &Rc, + id: (u64, u64), + target: &Path, + 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 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), + }; + // Asked while the anchor `parent_anchor` opened is still held. + let finish = DirFinish::Found(chain.found_dir(&**fd, requested)); + 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: &Rc, + requested: Preserve, +) -> io::Result<(DirFinish, ChainTrust)> { + let finish = DirFinish::Found(requested.where_trusted()); + Ok((finish, ChainTrust::made(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() @@ -257,15 +531,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. @@ -490,32 +755,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) } @@ -1093,7 +1363,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 +1372,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!( @@ -1723,48 +1995,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::in_cwd(target_arg, 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::in_dir(target_arg, dir, trust)?, + &mut run.created_files, + &mut run.made_dirs, inode_map, prompt_fn, ) @@ -1774,6 +2053,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. @@ -1783,33 +2064,43 @@ 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()) { - (Some(dir), Some(name)) => ( - dir, - name, - operand.parent().unwrap_or(Path::new("")).to_path_buf(), - ), - _ => ( - ftw::FileDescriptor::cwd(), - 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(ftw::FileDescriptor::cwd()), + trust, + 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, + 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 { 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(), @@ -1831,6 +2122,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<'_>, @@ -1843,6 +2137,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, @@ -1870,6 +2165,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<()> @@ -1887,20 +2183,35 @@ 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, + 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*. 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); @@ -1920,7 +2231,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 @@ -2004,16 +2317,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 @@ -2058,6 +2365,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 '{}': {}", @@ -2076,6 +2388,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!( @@ -2083,7 +2396,20 @@ where target.display() ))); } - Ok((fd, md)) + 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(), @@ -2091,14 +2417,31 @@ where &target, ) .map_err(|e| made_dir_open_error(&target, e)) - .and_then(|(fd, trust)| { - let fd = ftw::FileDescriptor::from(fd); + .and_then(|(fd, made)| { + let fd = Rc::new(ftw::FileDescriptor::from(fd)); let md = fd_metadata(fd.as_raw_fd())?; - made_dirs.borrow_mut().insert((md.dev(), md.ino()), trust); - Ok((fd, md)) + let own = made == MadeTrust::Full; + let trust = if own { + made_by_run.borrow_mut().record(&md, &target); + ChainTrust::made(&fd)? + } else { + // Owned like its parent only, it may be someone else's: it + // hands on what a directory found would. + let (_, trust) = found_dir_trust( + hands, + target_dirfd, + &fd, + (md.dev(), md.ino()), + &target, + requested, + )?; + trust + }; + report_copied(cfg, source.path().as_inner(), &target, true); + Ok((fd, md, DirFinish::Made(made), trust, own)) }), }; - let (new_target_dirfd, new_target_md) = match opened { + let (new_target_dirfd, new_target_md, finish, trust, own) = match opened { Ok(pair) => pair, Err(e) => { if cfg.continue_on_error { @@ -2122,7 +2465,12 @@ where copied.record(SourceState::of(source_md)); } - target_dirfd_stack_borrowed.push(Rc::new(new_target_dirfd)); + target_dirfd_stack_borrowed.push(DestDir { + fd: new_target_dirfd, + hands: OperandTrust::Chain(trust), + finish: Some(finish), + own, + }); target_dir_path_borrowed.push(target_filename); true @@ -2171,11 +2519,21 @@ 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), + own, + .. + }) = target_dir + { + #[cfg(test)] + if own { + BEFORE_FINISHING_OWN.with(|hook| hook.get().map(|hook| hook(fd.as_raw_fd()))); + } let finished = finish_dir( - target_dir.as_raw_fd(), + fd.as_raw_fd(), &source, - &made_dirs.borrow(), + finish, cfg.preserve, umask, &dir_path, @@ -2187,6 +2545,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(()) @@ -2255,7 +2621,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 { @@ -2269,13 +2635,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); @@ -2289,7 +2657,8 @@ where cfg, source, &new_target, - &mut created_files, + trust, + &mut run, inode_map.as_deref_mut(), prompt_fn, ) { @@ -2464,6 +2833,398 @@ 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(&*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(&*parent, mode), super::FoundDir::LeaveAlone); + } + + /// 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`). + #[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 { + 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, + } + } + + /// 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 + /// 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 { @@ -2493,7 +3254,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/common/pinned.rs b/tree/common/pinned.rs index 7e21f1c38..ac2b4d693 100644 --- a/tree/common/pinned.rs +++ b/tree/common/pinned.rs @@ -91,6 +91,14 @@ impl PinnedEntry { &self.display_parent } + /// 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 { + names_dot_or_dotdot(self.name.to_bytes()) + } + /// `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) @@ -180,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 @@ -287,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. 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/mv.rs b/tree/mv.rs index 6857b1c9b..f786eafe2 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,27 @@ 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. + 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 +477,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/parents.rs b/tree/parents.rs index 26ef511b4..9005111ab 100644 --- a/tree/parents.rs +++ b/tree/parents.rs @@ -16,11 +16,12 @@ //! 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, CopyConfig, InodeMap, 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::collections::HashSet; +use plib::madefs::NamedAnchor; use std::ffi::CString; use std::fs::File; use std::io; @@ -28,10 +29,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, @@ -68,22 +70,52 @@ 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, 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, preserve: bool, verbose: bool, -) -> io::Result<(Vec, File)> { + made_dirs: &mut MadeDirs, +) -> io::Result { 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 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)?); + // 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((made, dest_dir)); + return Ok(Walked { + made, + own, + held, + anchor, + trust, + }); }; let start = if source.is_absolute() { "/" } else { "." }; let mut src_dir = open_dir_at(libc::AT_FDCWD, &cstring(start.as_bytes())?, 0)?; 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. @@ -101,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 @@ -129,32 +177,74 @@ 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); + let next_dest = Rc::new(File::from(opened)); + trust = if made_trust == MadeTrust::Full { + made_dirs.record(&next_dest.metadata()?, &dest_path); + 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)? + }; made.push(MadeDir { - dest: next_dest.try_clone()?, + dest: Rc::clone(&next_dest), 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) + )) + })?; + let found = Rc::new(found); + trust = if made_dirs.made_at(&found.metadata()?, &dest_path) { + own.push((Rc::clone(&found), dest_path.clone())); + ChainTrust::made(&found)? + } else { + trust.found(&found)? + }; + found }; + held.push(Rc::clone(&next_dest)); + levels.push((Rc::clone(&next_dest), trust.clone())); src_dir = next_src; dest_dir = next_dest; } - Ok((made, dest_dir)) + Ok(Walked { + made, + own, + held, + anchor, + 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<(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 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, } /// GNU `cp -v --parents` for a directory made on the way: `source -> dest`, unquoted, unlike @@ -198,19 +288,84 @@ 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)); + 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; } - }; + continue; + } + } + let walked = make_parents( + source, + target, + cfg.preserve, + cfg.verbose.is_some(), + &mut run.made_dirs, + ); + let Walked { + made, + own, + held, + anchor: _anchor, + trust, + } = match walked { + Ok(walked) => walked, + Err(e) => { + eprintln!("cp: {}", error_string(&e)); + ok = false; + 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(_))) @@ -222,8 +377,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, ) { @@ -239,6 +394,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 } diff --git a/tree/remove_moved.rs b/tree/remove_moved.rs index 09aca07cc..efc4377ba 100644 --- a/tree/remove_moved.rs +++ b/tree/remove_moved.rs @@ -49,10 +49,30 @@ 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 { 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 +168,62 @@ 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()); + } + + /// `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/rm.rs b/tree/rm.rs index 7e7b74646..6adfad858 100644 --- a/tree/rm.rs +++ b/tree/rm.rs @@ -9,20 +9,20 @@ 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; use std::{ - cell::Cell, - ffi::{CStr, CString}, + cell::{Cell, RefCell}, + 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 @@ -93,13 +100,15 @@ 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 diagnostics show names (`display_cleaned`). +fn report_removed(cfg: &RmConfig, is_dir: bool, path: &Path) { if cfg.args.verbose { + let name = display_cleaned(path); let msg = if is_dir { - gettext!("removed directory '{}'", name) + gettext!("removed directory {}", name) } else { - gettext!("removed '{}'", name) + gettext!("removed {}", name) }; report_verbose(&msg); } @@ -112,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)); @@ -121,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(), ))); } } @@ -130,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) ) } } @@ -165,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) { @@ -186,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; @@ -220,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()) } }; @@ -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,35 +287,35 @@ 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)); @@ -337,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)); @@ -346,19 +349,33 @@ fn rm_directory(cfg: &RmConfig, filepath: &Path) -> 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 { @@ -391,22 +449,23 @@ 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) { 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)); @@ -414,44 +473,54 @@ fn remove_walked(cfg: &RmConfig, entry: &ftw::Entry) -> 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(()); } - 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!( - "cannot remove directory '{}': {}", - entry.path().clean_trailing_slashes(), - error_string(&e) - ); - eprintln!("rm: {}", err_str); - return Err(()); + // 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_shown(entry), error_string(&e)) + } else { + gettext!( + "cannot remove directory {}: {}", + entry_shown(entry), + error_string(&e) + ) + }; + eprintln!("rm: {}", err_str); + return Err(()); } else { - report_removed(cfg, true, &entry.path().clean_trailing_slashes()); + report_removed(cfg, true, entry.path().as_inner()); } } @@ -465,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) ) ); @@ -474,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) ) ); @@ -494,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) ) ); @@ -505,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) ) ); @@ -562,26 +628,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) }; @@ -590,10 +654,10 @@ where 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, &shown()); + report_removed(cfg, false, path); Ok(()) } @@ -610,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) ) }; @@ -623,11 +687,11 @@ 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!( - "cannot remove '{}': {}", + "cannot remove {}: {}", display_cleaned(filepath), error_string(&e) ); @@ -647,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) ); @@ -695,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; @@ -818,7 +882,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/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"); diff --git a/tree/tests/cp/existing_dir.rs b/tree/tests/cp/existing_dir.rs new file mode 100644 index 000000000..72b033028 --- /dev/null +++ b/tree/tests/cp/existing_dir.rs @@ -0,0 +1,542 @@ +// +// 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 +} + +/// 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 (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"); + 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); + } +} + +/// 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); +} + +/// 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] +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), (0o777, 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, 0o777); + + 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); +} + +/// 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); +} + +/// `--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()); +} + +/// `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 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] +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"); +} + +/// `--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}"); + } + } +} 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); +} 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", 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" + ); +} diff --git a/tree/tests/mv/mod.rs b/tree/tests/mv/mod.rs index 315b3622c..932b4b6eb 100644 --- a/tree/tests/mv/mod.rs +++ b/tree/tests/mv/mod.rs @@ -1589,6 +1589,101 @@ 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" + ); + } +} + +// 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. diff --git a/tree/tests/rm/mod.rs b/tree/tests/rm/mod.rs index 10141ded3..9cf46b667 100644 --- a/tree/tests/rm/mod.rs +++ b/tree/tests/rm/mod.rs @@ -1369,3 +1369,258 @@ 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()); +} + +/// `-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'"]); +} + +/// 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()); +}