From a1b0ce02fac4def7e17f78f69a63759ab84d62ea Mon Sep 17 00:00:00 2001 From: Joseph Rafael Ferrer Date: Thu, 3 Jul 2025 00:02:08 +0800 Subject: [PATCH] chown + tests --- tree/chgrp.rs | 148 ++---- tree/chown.rs | 192 ++++---- tree/common/change_ownership.rs | 115 +++++ tree/common/copy.rs | 839 +++++++++++++++++++++++++++++++ tree/common/mod.rs | 845 +------------------------------- tree/tests/chown/mod.rs | 643 ++++++++++++++++++++++++ tree/tests/tree-tests.rs | 1 + 7 files changed, 1762 insertions(+), 1021 deletions(-) create mode 100644 tree/common/change_ownership.rs create mode 100644 tree/common/copy.rs create mode 100644 tree/tests/chown/mod.rs diff --git a/tree/chgrp.rs b/tree/chgrp.rs index 77a8806a8..98e99d703 100644 --- a/tree/chgrp.rs +++ b/tree/chgrp.rs @@ -9,37 +9,17 @@ mod common; -use self::common::error_string; +use self::common::{chown_traverse, error_string, ChangeOwnershipArgs}; use clap::Parser; use gettextrs::{bind_textdomain_codeset, gettext, setlocale, textdomain, LocaleCategory}; -use std::{cell::RefCell, ffi::CString, io, os::unix::fs::MetadataExt}; +use std::{ffi::CString, io}; /// chgrp - change file group ownership #[derive(Parser)] #[command(version, about, disable_help_flag = true)] struct Args { - #[arg(long, action = clap::ArgAction::HelpLong)] // Bec. help clashes with -h - help: Option, - - /// Change symbolic links, rather than the files they point to - #[arg(short = 'h', long, default_value_t = false)] - no_derereference: bool, - - /// Follow command line symlinks during -R recursion - #[arg(short = 'H', overrides_with_all = ["follow_cli", "follow_symlinks", "follow_none"])] - follow_cli: bool, - - /// Follow symlinks during -R recursion - #[arg(short = 'L', overrides_with_all = ["follow_cli", "follow_symlinks", "follow_none"])] - follow_symlinks: bool, - - /// Never follow symlinks during -R recursion - #[arg(short = 'P', overrides_with_all = ["follow_cli", "follow_symlinks", "follow_none"], default_value_t = true)] - follow_none: bool, - - /// Recursively change groups of directories and their contents - #[arg(short, short_alias = 'R', long)] - recurse: bool, + #[command(flatten)] + delegate: ChangeOwnershipArgs, /// A group name from the group database or a numeric group ID group: String, @@ -48,89 +28,6 @@ struct Args { files: Vec, } -fn chgrp_file(filename: &str, gid: Option, args: &Args) -> bool { - let recurse = args.recurse; - let no_derereference = args.no_derereference; - - let terminate = RefCell::new(false); - - ftw::traverse_directory( - filename, - |entry| { - if *terminate.borrow() { - return Ok(false); - } - - let md = entry.metadata().unwrap(); - - // According to the spec: - // "The user ID of the file shall be used as the owner argument." - let uid = md.uid(); - - // Don't change the group ID if the group argument is empty - let gid = gid.unwrap_or(libc::gid_t::MAX); - - let ret = unsafe { - libc::fchownat( - entry.dir_fd(), - entry.file_name().as_ptr(), - uid, - gid, - // Default is to change the file that the symbolic link points to unless the - // -h flag is specified. - if no_derereference { - libc::AT_SYMLINK_NOFOLLOW - } else { - 0 - }, - ) - }; - if ret != 0 { - let e = io::Error::last_os_error(); - let err_str = match e.kind() { - io::ErrorKind::PermissionDenied => { - gettext!("cannot access '{}': {}", entry.path(), error_string(&e)) - } - _ => { - gettext!("changing group of '{}': {}", entry.path(), error_string(&e)) - } - }; - eprintln!("chgrp: {}", err_str); - *terminate.borrow_mut() = true; - return Err(()); - } - - Ok(recurse) - }, - |_| Ok(()), // Do nothing on `postprocess_dir` - |entry, error| { - let e = error.inner(); - let err_str = match e.kind() { - io::ErrorKind::PermissionDenied => { - gettext!( - "cannot read directory '{}': {}", - entry.path(), - error_string(&e) - ) - } - _ => { - gettext!("changing group of '{}': {}", entry.path(), error_string(&e)) - } - }; - eprintln!("chgrp: {}", err_str); - *terminate.borrow_mut() = true; - }, - ftw::TraverseDirectoryOpts { - follow_symlinks_on_args: args.follow_cli, - follow_symlinks: args.follow_symlinks, - ..Default::default() - }, - ); - - let failed = *terminate.borrow(); - !failed -} - // lookup string group by name, or parse numeric group ID fn parse_group(group: &str) -> Result, String> { // empty strings are accepted without errors @@ -155,13 +52,37 @@ fn parse_group(group: &str) -> Result, String> { } } +fn err_handler(e: io::Error, path: ftw::DisplayablePath) { + let err_str = match e.kind() { + io::ErrorKind::PermissionDenied => { + gettext!("cannot read directory '{}': {}", path, error_string(&e)) + } + _ => { + gettext!("changing group of '{}': {}", path, error_string(&e)) + } + }; + eprintln!("chgrp: {}", err_str); +} + +fn chown_err_handler(e: io::Error, path: ftw::DisplayablePath) { + let err_str = match e.kind() { + io::ErrorKind::PermissionDenied => { + gettext!("cannot access '{}': {}", path, error_string(&e)) + } + _ => { + gettext!("changing group of '{}': {}", path, error_string(&e)) + } + }; + eprintln!("chgrp: {}", err_str); +} + fn main() -> Result<(), Box> { // parse command line arguments let mut args = Args::parse(); // Enable `no_derereference` if `-R` is enabled without either `-H` or `-L` - if args.recurse && !(args.follow_cli || args.follow_symlinks) { - args.no_derereference = true; + if args.delegate.recurse && !(args.delegate.follow_cli || args.delegate.follow_symlinks) { + args.delegate.no_dereference = true; } // initialize translations @@ -182,7 +103,14 @@ fn main() -> Result<(), Box> { // apply the group to each file for filename in &args.files { - let success = chgrp_file(filename, gid, &args); + let success = chown_traverse( + filename, + None, + gid, + &args.delegate, + err_handler, + chown_err_handler, + ); if !success { exit_code = 1; } diff --git a/tree/chown.rs b/tree/chown.rs index cf5e3e2c1..6762e47f1 100644 --- a/tree/chown.rs +++ b/tree/chown.rs @@ -6,40 +6,20 @@ // file in the root directory of this project. // SPDX-License-Identifier: MIT // -// TODO: -// - implement -h, -H, -L, -P -// +mod common; + +use self::common::{chown_traverse, error_string, ChangeOwnershipArgs}; use clap::Parser; -use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory}; -use std::ffi::CString; -use std::os::unix::fs::MetadataExt; -use std::path::Path; -use std::{fs, io}; +use gettextrs::{bind_textdomain_codeset, gettext, setlocale, textdomain, LocaleCategory}; +use std::{ffi::CString, io}; /// chown - change the file ownership #[derive(Parser)] #[command(version, about)] struct Args { - /// Change symbolic links, rather than the files they point to - #[arg(short = 'h', long)] - no_derereference: bool, - - /// Follow command line symlinks during -R recursion - #[arg(short = 'H', long)] - follow_cli: bool, - - /// Follow symlinks during -R recursion - #[arg(short = 'L', group = "deref")] - dereference: bool, - - /// Never follow symlinks during -R recursion - #[arg(short = 'P', group = "deref")] - no_dereference2: bool, - - /// Recursively change groups of directories and their contents - #[arg(short, short_alias = 'R', long)] - recurse: bool, + #[command(flatten)] + delegate: ChangeOwnershipArgs, /// Owner and group are changed to OWNER[:GROUP] owner_group: String, @@ -48,89 +28,77 @@ struct Args { files: Vec, } -fn chown_file(filename: &str, uid: u32, gid: Option, recurse: bool) -> Result<(), io::Error> { - let path = Path::new(filename); - let metadata = fs::metadata(path)?; - - // recurse into directories - if metadata.is_dir() && recurse { - for entry in fs::read_dir(path)? { - let entry = entry?; - let entry_path = entry.path(); - let entry_filename = entry_path.to_str().unwrap(); - chown_file(entry_filename, uid, gid, recurse)?; - } - } - - // change the user, and optionally, the group - let pathstr = CString::new(filename).unwrap(); - let gid = { - if let Some(gid) = gid { - gid - } else { - metadata.gid() - } - }; - unsafe { - if libc::chown(pathstr.as_ptr(), uid, gid) != 0 { - return Err(io::Error::last_os_error()); - } - } - - Ok(()) -} - // lookup string group by name, or parse numeric group ID -fn parse_group(group: &str) -> Result { +fn parse_group(group: &str) -> Result { match group.parse::() { Ok(gid) => Ok(gid), Err(_) => { // lookup group by name let group_cstr = CString::new(group).unwrap(); - let group = unsafe { libc::getgrnam(group_cstr.as_ptr()) }; - if group.is_null() { - return Err("group not found"); + let group_name = unsafe { libc::getgrnam(group_cstr.as_ptr()) }; + if group_name.is_null() { + return Err(gettext!("invalid group: '{}'", group)); } - let gid = unsafe { (*group).gr_gid }; + let gid = unsafe { (*group_name).gr_gid }; Ok(gid) } } } // lookup string user by name, or parse numeric user ID -fn parse_user(user: &str) -> Result { +fn parse_user(user: &str) -> Result { match user.parse::() { Ok(uid) => Ok(uid), Err(_) => { // lookup user by name let user_cstr = CString::new(user).unwrap(); - let user = unsafe { libc::getpwnam(user_cstr.as_ptr()) }; - if user.is_null() { - return Err("user not found"); + let user_name = unsafe { libc::getpwnam(user_cstr.as_ptr()) }; + if user_name.is_null() { + return Err(gettext!("invalid user: '{}'", user)); } - let uid = unsafe { (*user).pw_uid }; + let uid = unsafe { (*user_name).pw_uid }; Ok(uid) } } } -fn parse_owner_group(owner_group: &str) -> Result<(u32, Option), &'static str> { - match owner_group.split_once(':') { - None => { - let uid = parse_user(owner_group)?; - Ok((uid, None)) - } - Some((owner, group)) => { - let uid = parse_user(owner)?; - let gid = parse_group(group)?; - Ok((uid, Some(gid))) +enum ParseOwnerGroupResult { + EmptyOrColon, + OwnerOnly(u32), + OwnerGroup((Option, Option)), +} + +fn parse_owner_group(owner_group: &str) -> Result { + if owner_group.is_empty() || owner_group == ":" { + return Ok(ParseOwnerGroupResult::EmptyOrColon); + } else { + match owner_group.split_once(':') { + None => { + let uid = parse_user(owner_group)?; + Ok(ParseOwnerGroupResult::OwnerOnly(uid)) + } + Some((owner, group)) => { + let uid = if owner.is_empty() { + None + } else { + Some(parse_user(owner)?) + }; + + let gid = if group.is_empty() { + None + } else { + Some(parse_group(group)?) + }; + + Ok(ParseOwnerGroupResult::OwnerGroup((uid, gid))) + } } } } -fn main() -> Result<(), Box> { +fn main() -> Result<(), io::Error> { setlocale(LocaleCategory::LcAll, ""); textdomain("posixutils-rs")?; bind_textdomain_codeset("posixutils-rs", "UTF-8")?; @@ -139,14 +107,70 @@ fn main() -> Result<(), Box> { let mut exit_code = 0; - // lookup the owner and group - let (uid, gid) = parse_owner_group(&args.owner_group)?; + let (uid, gid) = match parse_owner_group(&args.owner_group) { + Ok(owner_group) => match owner_group { + ParseOwnerGroupResult::EmptyOrColon => { + // GNU behavior: If `owner_group` is empty or just ":", do nothing + return Ok(()); + } + ParseOwnerGroupResult::OwnerOnly(uid) => (Some(uid), None), + ParseOwnerGroupResult::OwnerGroup(pair) => match pair { + (None, None) => { + // Should have been handled in the `ParseOwnerGroupResult::EmptyOrColon` branch + unreachable!() + } + (None, Some(gid)) => { + // `chown :group f` is equivalent to `chgrp group f` + (None, Some(gid)) + } + (Some(_), None) => { + // `chown owner: f` is invalid. There needs to be a group after the : + let err_str = gettext!("invalid spec: '{}'", &args.owner_group); + eprintln!("chown: {}", err_str); + std::process::exit(1); + } + (Some(uid), Some(gid)) => (Some(uid), Some(gid)), + }, + }, + Err(err_str) => { + eprintln!("chown: {}", err_str); + std::process::exit(1); + } + }; // apply the group to each file for filename in &args.files { - if let Err(e) = chown_file(filename, uid, gid, args.recurse) { + let success = chown_traverse( + filename, + uid, + gid, + &args.delegate, + |e: io::Error, path: ftw::DisplayablePath| { + let err_str = match e.kind() { + io::ErrorKind::PermissionDenied => { + gettext!("cannot read directory '{}': {}", path, error_string(&e)) + } + io::ErrorKind::NotFound => { + gettext!("cannot access '{}': {}", path, error_string(&e)) + } + _ => { + gettext!("changing ownership of '{}': {}", path, error_string(&e)) + } + }; + eprintln!("chown: {}", err_str); + }, + |e: io::Error, path: ftw::DisplayablePath| { + let err_str = if uid.is_none() { + gettext!("changing group of '{}': {}", path, error_string(&e)) + } else { + gettext!("changing ownership of '{}': {}", path, error_string(&e)) + }; + + eprintln!("chown: {}", err_str); + }, + ); + if !success { exit_code = 1; - eprintln!("{}: {}", filename, e); } } diff --git a/tree/common/change_ownership.rs b/tree/common/change_ownership.rs new file mode 100644 index 000000000..b172cb9be --- /dev/null +++ b/tree/common/change_ownership.rs @@ -0,0 +1,115 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// 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 +// + +use super::error_string; +use clap::Parser; +use gettextrs::gettext; +use std::{cell::RefCell, io, os::unix::fs::MetadataExt}; + +#[derive(Parser)] +#[command(version, about, disable_help_flag = true)] +pub struct ChangeOwnershipArgs { + #[arg(long, action = clap::ArgAction::HelpLong)] // Bec. help clashes with -h + help: Option, + + /// Change symbolic links, rather than the files they point to + #[arg(short = 'h', long, default_value_t = false)] + pub no_dereference: bool, + + /// Follow command line symlinks during -R recursion + #[arg(short = 'H', overrides_with_all = ["follow_cli", "follow_symlinks", "follow_none"])] + pub follow_cli: bool, + + /// Follow symlinks during -R recursion + #[arg(short = 'L', overrides_with_all = ["follow_cli", "follow_symlinks", "follow_none"])] + pub follow_symlinks: bool, + + /// Never follow symlinks during -R recursion + #[arg(short = 'P', overrides_with_all = ["follow_cli", "follow_symlinks", "follow_none"])] + pub follow_none: bool, + + /// Recursively change groups of directories and their contents + #[arg(short, short_alias = 'R', long)] + pub recurse: bool, +} + +pub fn chown_traverse( + filename: &str, + uid: Option, + gid: Option, + args: &ChangeOwnershipArgs, + err_handler: F, + chown_err_handler: G, +) -> bool +where + F: Fn(io::Error, ftw::DisplayablePath), // F and G are the same but they must be declared + G: Fn(io::Error, ftw::DisplayablePath), // separately to use two different closures +{ + let recurse = args.recurse; + let no_dereference = args.no_dereference; + let follow_none = args.follow_none; + let follow_symlinks = args.follow_symlinks; + + let terminate = RefCell::new(false); + + ftw::traverse_directory( + filename, + |entry| { + if *terminate.borrow() { + return Ok(false); + } + + let md = entry.metadata().unwrap(); + + // Use the UID from the args if present. If not given, according to the chgrp spec: + // "The user ID of the file shall be used as the owner argument." + let uid = uid.unwrap_or(md.uid()); + + // Don't change the group ID if the group argument is empty + let gid = gid.unwrap_or(libc::gid_t::MAX); + + let ret = unsafe { + libc::fchownat( + entry.dir_fd(), + entry.file_name().as_ptr(), + uid, + gid, + // Default is to change the file that the symbolic link points to unless the + // -h flag or -P flag is specified. + if no_dereference || follow_none { + libc::AT_SYMLINK_NOFOLLOW + } else { + 0 + }, + ) + }; + + if ret != 0 { + chown_err_handler(io::Error::last_os_error(), entry.path()); + *terminate.borrow_mut() = true; + return Err(()); + } + + Ok(recurse) + }, + |_| Ok(()), // Do nothing on `postprocess_dir` + |entry, error| { + err_handler(error.inner(), entry.path()); + *terminate.borrow_mut() = true; + }, + ftw::TraverseDirectoryOpts { + follow_symlinks_on_args: args.follow_cli, + follow_symlinks: args.follow_symlinks, + ..Default::default() + }, + ); + + let failed = *terminate.borrow(); + !failed +} diff --git a/tree/common/copy.rs b/tree/common/copy.rs new file mode 100644 index 000000000..f4afb221d --- /dev/null +++ b/tree/common/copy.rs @@ -0,0 +1,839 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// 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 +// + +use super::error_string; +use ftw::{self, traverse_directory}; +use gettextrs::gettext; +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + ffi::{CStr, CString, OsStr}, + fs, io, + mem::MaybeUninit, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::{ffi::OsStrExt, fs::MetadataExt}, + }, + path::{Path, PathBuf}, +}; + +pub type InodeMap = HashMap<(u64, u64), (ftw::FileDescriptor, CString)>; + +pub struct CopyConfig { + pub force: bool, + pub follow_cli: bool, + pub dereference: bool, + pub interactive: bool, + pub preserve: bool, + pub recursive: bool, +} + +enum CopyResult { + CopyingDirectory, + CopiedFile, + Skipped, +} + +// Implements the algorithm for `cp`: +// +// https://pubs.opengroup.org/onlinepubs/9699919799/utilities/cp.html +fn copy_file_impl( + cfg: &CopyConfig, + source: &ftw::Entry, + target: &Path, + target_dirfd: libc::c_int, + target_filename: *const libc::c_char, + created_files: &mut HashSet, + prompt_fn: F, +) -> io::Result +where + F: Fn(&str) -> bool, +{ + let source_md = source.metadata().unwrap(); + let source_is_symlink = source.is_symlink().unwrap_or(false); + let source_file_type = source_md.file_type(); + let source_is_dir = source_file_type == ftw::FileType::Directory; + + let source_is_special_file = match source_file_type { + ftw::FileType::BlockDevice + | ftw::FileType::CharacterDevice + | ftw::FileType::Fifo + | ftw::FileType::Socket => true, + _ => false, + }; + // -R is required for step 4 + if source_is_special_file && cfg.recursive { + copy_special_file( + cfg, + source_md, + target, + target_dirfd, + target_filename, + created_files, + source_file_type == ftw::FileType::Fifo, + )?; + return Ok(CopyResult::CopiedFile); + } + + let source_deref_md = unsafe { ftw::Metadata::new(source.dir_fd(), source.file_name(), true) }; + + let target_symlink_md = ftw::Metadata::new( + target_dirfd, + unsafe { CStr::from_ptr(target_filename) }, + false, + ); + let target_deref_md = ftw::Metadata::new( + target_dirfd, + unsafe { CStr::from_ptr(target_filename) }, + true, + ); + let target_is_dangling_symlink = target_symlink_md.is_ok() && target_deref_md.is_err(); + + let target_symlink_md = match target_symlink_md { + Ok(md) => Some(md), + Err(e) => { + if e.kind() == io::ErrorKind::NotFound { + None + } else { + let err_str = + gettext!("cannot access '{}': {}", target.display(), error_string(&e)); + return Err(io::Error::other(err_str)); + } + } + }; + + let target_is_dir = match &target_symlink_md { + Some(md) => md.file_type() == ftw::FileType::Directory, + None => false, + }; + + let target_exists = target_symlink_md.is_some(); + + // 1. If source_file references the same file as dest_file + if let (Ok(smd), Ok(tmd)) = (&source_deref_md, &target_deref_md) { + if smd.dev() == tmd.dev() && smd.ino() == tmd.ino() { + let err_str = gettext!( + "'{}' and '{}' are the same file", + source.path(), + target.display() + ); + return Err(io::Error::other(err_str)); + } + } + + // 2. If source_file is of type directory + if source_is_dir { + // 2.a + if !cfg.recursive { + let err_str = gettext!("-r not specified; omitting directory '{}'", source.path()); + return Err(io::Error::other(err_str)); + } + + // 2.b `fs::read_dir` skips `.` and `..`. Any occurence means it comes + // from the input to `cp`. + + // 2.d + if target_exists && !target_is_dir { + let err_str = gettext!( + "cannot overwrite non-directory '{}' with directory '{}'", + target.display(), + source.path() + ); + return Err(io::Error::other(err_str)); + } + + // 2.e + if !target_exists { + if target.starts_with(PathBuf::from(format!("{}", source.path()))) { + let err_str = gettext!( + "cannot copy a directory, '{}', into itself, '{}'", + source.path(), + target.display() + ); + return Err(io::Error::other(err_str)); + } + + unsafe { + // Creates the target directory with the same file permission bits as the source, + // modified by the umask of the process. Copying the permission bits without the + // umask is postponed to the `postprocess_dir` closure on the call to + // `traverse_directory` inside `copy_file`. + let ret = libc::mkdirat( + target_dirfd, + target_filename, + // OR'ed with S_IRWXU according to the spec + source_md.mode() as libc::mode_t | libc::S_IRWXU, + ); + + if ret != 0 { + let e = io::Error::last_os_error(); + let err_str = gettext!( + "cannot create directory '{}': {}", + target.display(), + error_string(&e) + ); + return Err(io::Error::other(err_str)); + } + } + } + + return Ok(CopyResult::CopyingDirectory); + } else { + // 3. If source_file is of type regular file + + let create_target_then_copy = || -> io::Result<()> { + let source_fd = unsafe { + libc::openat(source.dir_fd(), source.file_name().as_ptr(), libc::O_RDONLY) + }; + if source_fd == -1 { + let e = io::Error::last_os_error(); + let err_str = gettext!( + "cannot open '{}' for reading: {}", + source.path(), + error_string(&e) + ); + return Err(io::Error::other(err_str)); + } + let mut source_file = unsafe { fs::File::from_raw_fd(source_fd) }; + + // 3.b + let target_fd = unsafe { + libc::openat( + target_dirfd, + target_filename, + libc::O_WRONLY | libc::O_CREAT, + source_md.mode(), + ) + }; + if target_fd == -1 { + let e = io::Error::last_os_error(); + + // `ErrorKind::IsADirectory` is unstable: + // https://github.com/rust-lang/rust/issues/86442 + let err_msg = if let Some(libc::EISDIR) = e.raw_os_error() { + // EISDIR -> ENOTDIR is to match the diagnostic from + // coreutils/tests/cp/trailing-slash.sh + error_string(&io::Error::from_raw_os_error(libc::ENOTDIR)) + } else { + error_string(&e) + }; + let err_str = gettext!( + "cannot create regular file '{}': {}", + target.display(), + err_msg + ); + return Err(io::Error::other(err_str)); + } + let mut target_file = unsafe { fs::File::from_raw_fd(target_fd) }; + + // 3.d + io::copy(&mut source_file, &mut target_file)?; + + Ok(()) + }; + + // 3.a + if target_exists && !target_is_dangling_symlink { + if created_files.contains(target) { + let err_str = gettext!( + "will not overwrite just-created '{}' with '{}'", + target.display(), + source.path(), + ); + return Err(io::Error::other(err_str)); + } + + // 3.a.i + let target_is_writable = target_symlink_md + .as_ref() + .map(|md| md.is_writable()) + .unwrap_or(false); + + // Different prompt if the target is not writable + if !target_is_writable && (cfg.interactive || cfg.force) { + let mode = target_symlink_md.as_ref().unwrap().mode(); + + let mut mode_str = String::new(); + let bit_loc = 0o400; + for i in 0..9 { + let mask = bit_loc >> i; + if mode & mask != 0 { + match i % 3 { + 0 => mode_str.push('r'), + 1 => mode_str.push('w'), + 2 => mode_str.push('x'), + _ => (), + } + } else { + mode_str.push('-'); + } + } + + // 4 octal digits + // This needs to be formatted separately because `gettext!` does + // not accept a format spec (just plain curly braces, `{}`). + let mode_octal = format!("{:04o}", mode & 0o7777); + + if cfg.force { + let is_affirm = prompt_fn(&gettext!( + "replace '{}', overriding mode {} ({})?", + target.display(), + mode_octal, + mode_str + )); + if !is_affirm { + return Ok(CopyResult::Skipped); + } + } else if cfg.interactive { + let is_affirm = prompt_fn(&gettext!( + "unwritable '{}' (mode {}, {}); try anyway?", + target.display(), + mode_octal, + mode_str + )); + if !is_affirm { + return Ok(CopyResult::Skipped); + } + } + } else { + if cfg.interactive { + let is_affirm = prompt_fn(&gettext!("overwrite '{}'?", target.display())); + if !is_affirm { + return Ok(CopyResult::Skipped); + } + } + } + + // 4.c + if source_is_symlink { + let ret = unsafe { + libc::unlinkat( + target_dirfd, + target_filename, + if target_is_dir { libc::AT_REMOVEDIR } else { 0 }, + ) + }; + if ret != 0 { + return Err(io::Error::last_os_error()); + } + + let ret = unsafe { + libc::symlinkat( + source.read_link().unwrap().as_ptr(), + target_dirfd, + target_filename, + ) + }; + if ret != 0 { + return Err(io::Error::last_os_error()); + } + } else { + // 3.a.ii + let target_fd = unsafe { + libc::openat( + target_dirfd, + target_filename, + libc::O_WRONLY | libc::O_TRUNC, + ) + }; + if target_fd != -1 { + let mut target_file = unsafe { fs::File::from_raw_fd(target_fd) }; + + let source_fd = unsafe { + libc::openat(source.dir_fd(), source.file_name().as_ptr(), libc::O_RDONLY) + }; + if source_fd == -1 { + let e = io::Error::last_os_error(); + let err_str = gettext!( + "cannot open '{}' for reading: {}", + source.path(), + error_string(&e) + ); + return Err(io::Error::other(err_str)); + } + let mut source_file = unsafe { fs::File::from_raw_fd(source_fd) }; + + io::copy(&mut source_file, &mut target_file)?; + } else { + // 3.a.iii + if cfg.force { + let ret = unsafe { + libc::unlinkat( + target_dirfd, + target_filename, + if target_is_dir { libc::AT_REMOVEDIR } else { 0 }, + ) + }; + if ret != 0 { + return Err(io::Error::last_os_error()); + } + + // 3.b + create_target_then_copy()?; + } else { + let e = io::Error::last_os_error(); + let err_str = gettext!( + "cannot open '{}' for reading: {}", + target.display(), + error_string(&e) + ); + return Err(io::Error::other(err_str)); + } + } + } + + // 3.b + } else { + // 4.c + if source_is_symlink { + let ret = unsafe { + libc::symlinkat( + source.read_link().unwrap().as_ptr(), + target_dirfd, + target_filename, + ) + }; + if ret != 0 { + return Err(io::Error::last_os_error()); + } + } else { + create_target_then_copy()?; + } + } + + created_files.insert(target.to_path_buf()); + } + + Ok(CopyResult::CopiedFile) +} + +pub fn copy_file( + cfg: &CopyConfig, + source_arg: &Path, + target_arg: &Path, + created_files: &mut HashSet, + mut inode_map: Option<&mut InodeMap>, + prompt_fn: F, +) -> io::Result<()> +where + F: Copy + Fn(&str) -> bool, +{ + // `RefCell` to allow sharing these between closures + let target_dirfd_stack = RefCell::new(vec![ftw::FileDescriptor::cwd()]); + let target_dir_path = RefCell::new(PathBuf::new()); + let terminate = RefCell::new(false); + let last_error = RefCell::new(None); + + let _ = traverse_directory( + source_arg, + |source| { + let mut terminate_borrowed = terminate.borrow_mut(); + let mut target_dirfd_stack_borrowed = target_dirfd_stack.borrow_mut(); + let mut target_dir_path_borrowed = target_dir_path.borrow_mut(); + + if *terminate_borrowed { + return Ok(false); + } + + let target_dirfd = target_dirfd_stack_borrowed.last().unwrap(); + + let target_filename = if target_dirfd.as_raw_fd() == libc::AT_FDCWD { + target_arg.as_os_str() + } else { + OsStr::from_bytes(source.file_name().to_bytes()) + }; + + let target = target_dir_path_borrowed.join(target_filename); + let target_filename_cstr = CString::new(target_filename.as_bytes()).unwrap(); + + let source_md = source.metadata().unwrap(); + let identifier = (source_md.dev(), source_md.ino()); + + // Hard-link preserving behavior of `mv`. `cp` does not maintain the hard-link structure + // of the hierarchy according to the standard + if let Some(inode_map) = inode_map.as_deref_mut() { + // Preserve hard links like coreutils mv. Creating a copy is also + // allowed by the standard. + if let Some((prev_dirfd, prev_filename)) = inode_map.get(&identifier) { + let ret = unsafe { + libc::linkat( + prev_dirfd.as_raw_fd(), + prev_filename.as_ptr(), + target_dirfd.as_raw_fd(), + target_filename_cstr.as_ptr(), + 0, // Don't dereference prev if it's a symlink + ) + }; + // If success + if ret == 0 { + // Skip since this file/directory is handled by hard-linking + return Ok(false); + } + // else failed + else { + *last_error.borrow_mut() = Some(io::Error::last_os_error()); + *terminate_borrowed = true; + return Ok(false); + } + } + } + + let continue_processing = match copy_file_impl( + cfg, + &source, + &target, + target_dirfd.as_raw_fd(), + target_filename_cstr.as_ptr(), + created_files, + prompt_fn, + ) { + Ok(copy_result) => { + // If copying succeeds, then store the hard-link data + if let Some(inode_map) = inode_map.as_deref_mut() { + // Don't include every file, just those with hard links + if source_md.nlink() > 1 { + inode_map.insert( + identifier, + (target_dirfd.clone(), target_filename_cstr.clone()), + ); + } + } + + match copy_result { + CopyResult::CopyingDirectory => { + // mkdir/mkdirat doesn't return a file descriptor so a new one must be + // opened here. Using O_CREAT | O_DIRECTORY in a call to open/openat would + // not allow atomically creating a directory then opening it: + // + // https://stackoverflow.com/questions/45818628/whats-the-expected-behavior-of-openname-o-creato-directory-mode/48693137#48693137 + let new_target_dirfd = match unsafe { + ftw::FileDescriptor::open_at( + target_dirfd, + &target_filename_cstr, + libc::O_RDONLY, + ) + } { + Ok(fd) => fd, + Err(e) => { + let err_str = gettext!( + "cannot open directory '{}': {}", + target.display(), + error_string(&e) + ); + *last_error.borrow_mut() = Some(io::Error::other(err_str)); + *terminate_borrowed = true; + return Ok(false); + } + }; + + target_dirfd_stack_borrowed.push(new_target_dirfd); + target_dir_path_borrowed.push(target_filename); + + true + } + CopyResult::CopiedFile => { + // Immediately copy the metadata if copying a file. Directories are + // handled on the `postprocess_dir` closure below. + if cfg.preserve { + if let Err(e) = copy_characteristics( + &source, + &target, + target_dirfd.as_raw_fd(), + target_filename_cstr.as_ptr(), + ) { + *last_error.borrow_mut() = Some(e); + *terminate_borrowed = true; + false + } else { + true + } + } else { + true + } + } + CopyResult::Skipped => false, + } + } + Err(e) => { + *last_error.borrow_mut() = Some(e); + *terminate_borrowed = true; + false + } + }; + + Ok(continue_processing) + }, + |source| { + let mut terminate_borrowed = terminate.borrow_mut(); + let mut target_dirfd_stack_borrowed = target_dirfd_stack.borrow_mut(); + let mut target_dir_path_borrowed = target_dir_path.borrow_mut(); + + target_dir_path_borrowed.pop(); + target_dirfd_stack_borrowed.pop(); + + // Preserve metadata for directories. Must do this inside this closure to ensure no + // further last access time changes to the source will be made. + if cfg.preserve { + let target_dirfd = target_dirfd_stack_borrowed.last().unwrap(); + + let target_filename = if target_dirfd.as_raw_fd() == libc::AT_FDCWD { + target_arg.as_os_str() + } else { + OsStr::from_bytes(source.file_name().to_bytes()) + }; + let target_filename_cstr = CString::new(target_filename.as_bytes()).unwrap(); + + if let Err(e) = copy_characteristics( + &source, + &target_dir_path_borrowed, + target_dirfd.as_raw_fd(), + target_filename_cstr.as_ptr(), + ) { + *last_error.borrow_mut() = Some(e); + *terminate_borrowed = true; + } + } + + Ok(()) + }, + |_entry, error| { + *last_error.borrow_mut() = Some(error.inner()); + *terminate.borrow_mut() = true; + }, + ftw::TraverseDirectoryOpts { + follow_symlinks_on_args: cfg.follow_cli, + follow_symlinks: cfg.dereference, + ..Default::default() + }, + ); + + match last_error.into_inner() { + Some(e) => Err(e), + None => Ok(()), + } +} + +pub fn copy_files( + cfg: &CopyConfig, + sources: &[PathBuf], + target: &Path, + mut inode_map: Option<&mut InodeMap>, + prompt_fn: F, +) -> Option<()> +where + F: Copy + Fn(&str) -> bool, +{ + let mut result = Some(()); + + let mut created_files = HashSet::new(); + + // loop through sources, moving each to target + for source in sources { + // This doesn't seem to be compliant with POSIX + let ends_with_slash_dot = |p: &Path| -> bool { + let bytes = p.as_os_str().as_bytes(); + if bytes.len() >= 2 { + let end = &bytes[(bytes.len() - 2)..]; + return end == b"/."; + } + false + }; + + let new_target = 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() + } else { + match source.file_name() { + Some(file_name) => target.join(file_name), + None => { + let err_str = gettext!("invalid filename: {}", source.display()); + eprintln!("cp: {}", err_str); + result = None; + continue; + } + } + }; + + match copy_file( + cfg, + source, + &new_target, + &mut created_files, + inode_map.as_deref_mut(), + prompt_fn, + ) { + Ok(_) => (), + Err(e) => { + eprintln!("cp: {}", error_string(&e)); + result = None; + } + } + } + + result +} + +fn copy_special_file( + cfg: &CopyConfig, + source_md: &ftw::Metadata, + + // Should only be used for keeping track of created files and for displaying error messages + target: &Path, + + target_dirfd: libc::c_int, + target_filename: *const libc::c_char, + created_files: &mut HashSet, + is_fifo: bool, +) -> io::Result<()> { + // 4.a + let dev = source_md.rdev(); + + // 4.b + let mode = if is_fifo { + // Mandatory to be the same as source for FIFO + source_md.mode() + } else { + // Under Rationale: + // "In general, it is strongly suggested that the permissions, + // owner, and group be the same as if the user had run the + // historical mknod, ln, or other utility to create the file" + 0o644 + }; + + let mut stat_buf = MaybeUninit::uninit(); + + // Using `fstatat` to check for the existence of the target file + let ret = unsafe { + libc::fstatat( + target_dirfd, + target_filename, + stat_buf.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + let target_exists = ret == 0; + + if target_exists { + let ret = unsafe { libc::unlinkat(target_dirfd, target_filename, 0) }; + if ret != 0 { + return Err(io::Error::last_os_error()); + } + } + + let ret = unsafe { + libc::mknodat( + target_dirfd, + target_filename, + mode as libc::mode_t, + dev as libc::dev_t, + ) + }; + if ret == 0 { + created_files.insert(target.to_path_buf()); + Ok(()) + } else { + let e = io::Error::last_os_error(); + let err_str = gettext!( + "cannot create regular file '{}': {}", + target.display(), + error_string(&e) + ); + Err(io::Error::other(err_str)) + } +} + +// Copy the metadata in `source_md` to the target. +fn copy_characteristics( + source: &ftw::Entry, + target: &Path, + target_dirfd: libc::c_int, + target_filename: *const libc::c_char, +) -> io::Result<()> { + // Get a new metadata instead because the source's last access time is updated on reads (i.e, + // `io::copy`). + // Should fix sporadic errors on `test_cp_preserve_slink_time` where `dangle` has a later + // access time than `d2`. + let source_md = unsafe { ftw::Metadata::new(source.dir_fd(), source.file_name(), false) }?; + + // [last_access_time, last_modified_time] + let times = [ + libc::timespec { + tv_sec: source_md.atime(), + tv_nsec: source_md.atime_nsec(), + }, + libc::timespec { + tv_sec: source_md.mtime(), + tv_nsec: source_md.mtime_nsec(), + }, + ]; + + unsafe { + // Copy last access and last modified times + let ret = libc::utimensat( + target_dirfd, + target_filename, + times.as_ptr(), + libc::AT_SYMLINK_NOFOLLOW, // Update the file itself if a symlink + ); + if ret != 0 { + let err_str = gettext!( + "failed to preserve times for '{}': {}", + target.display(), + io::Error::last_os_error() + ); + return Err(io::Error::other(err_str)); + } + + // Copy user and group + let ret = libc::fchownat( + target_dirfd, + target_filename, + source_md.uid(), + source_md.gid(), + libc::AT_SYMLINK_NOFOLLOW, + ); + if ret != 0 { + // Ignore errors + errno::set_errno(errno::Errno(0)); + } + + // Copy permissions + let ret = libc::fchmodat( + target_dirfd, + target_filename, + source_md.mode() as libc::mode_t, + libc::AT_SYMLINK_NOFOLLOW, + ); + if ret != 0 { + let fchmodat_error = io::Error::last_os_error(); + + // Symbolic link permissions are ignored on Linux + #[cfg(target_os = "linux")] + if let Ok(md) = ftw::Metadata::new(target_dirfd, CStr::from_ptr(target_filename), false) + { + if md.file_type() == ftw::FileType::SymbolicLink { + if let Some(errno) = fchmodat_error.raw_os_error() { + if errno == libc::EOPNOTSUPP { + return Ok(()); + } + } + } + } + + let err_str = gettext!( + "failed to preserve permissions for '{}': {}", + target.display(), + io::Error::last_os_error() + ); + return Err(io::Error::other(err_str)); + } + } + Ok(()) +} diff --git a/tree/common/mod.rs b/tree/common/mod.rs index f0008e182..56f27d563 100644 --- a/tree/common/mod.rs +++ b/tree/common/mod.rs @@ -1,25 +1,29 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// 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 +// + // This module is shared between `cp`, `mv` and `rm` but is considered as three // separated modules due to the project structure. The `#![allow(unused)]` is // to remove warnings when, say, `rm` doesn't use all the the functions in this // module (but is used in `cp` or `mv`). #![allow(unused)] -use ftw::{self, traverse_directory}; +mod change_ownership; +mod copy; + use gettextrs::gettext; -use std::{ - cell::RefCell, - collections::{HashMap, HashSet}, - ffi::{CStr, CString, OsStr}, - fs, io, - mem::MaybeUninit, - os::{ - fd::{AsRawFd, FromRawFd}, - unix::{ffi::OsStrExt, fs::MetadataExt}, - }, - path::{Path, PathBuf}, -}; +use std::{ffi::CStr, io}; -pub type InodeMap = HashMap<(u64, u64), (ftw::FileDescriptor, CString)>; +// cp and mv +pub use copy::{copy_file, copy_files, CopyConfig}; + +// chgrp and chown +pub use change_ownership::{chown_traverse, ChangeOwnershipArgs}; /// Return the error message. /// @@ -49,816 +53,3 @@ pub fn error_string(e: &io::Error) -> String { // Translate the error string gettext(s) } - -pub struct CopyConfig { - pub force: bool, - pub follow_cli: bool, - pub dereference: bool, - pub interactive: bool, - pub preserve: bool, - pub recursive: bool, -} - -enum CopyResult { - CopyingDirectory, - CopiedFile, - Skipped, -} - -// Implements the algorithm for `cp`: -// -// https://pubs.opengroup.org/onlinepubs/9699919799/utilities/cp.html -fn copy_file_impl( - cfg: &CopyConfig, - source: &ftw::Entry, - target: &Path, - target_dirfd: libc::c_int, - target_filename: *const libc::c_char, - created_files: &mut HashSet, - prompt_fn: F, -) -> io::Result -where - F: Fn(&str) -> bool, -{ - let source_md = source.metadata().unwrap(); - let source_is_symlink = source.is_symlink().unwrap_or(false); - let source_file_type = source_md.file_type(); - let source_is_dir = source_file_type == ftw::FileType::Directory; - - let source_is_special_file = match source_file_type { - ftw::FileType::BlockDevice - | ftw::FileType::CharacterDevice - | ftw::FileType::Fifo - | ftw::FileType::Socket => true, - _ => false, - }; - // -R is required for step 4 - if source_is_special_file && cfg.recursive { - copy_special_file( - cfg, - source_md, - target, - target_dirfd, - target_filename, - created_files, - source_file_type == ftw::FileType::Fifo, - )?; - return Ok(CopyResult::CopiedFile); - } - - let source_deref_md = unsafe { ftw::Metadata::new(source.dir_fd(), source.file_name(), true) }; - - let target_symlink_md = ftw::Metadata::new( - target_dirfd, - unsafe { CStr::from_ptr(target_filename) }, - false, - ); - let target_deref_md = ftw::Metadata::new( - target_dirfd, - unsafe { CStr::from_ptr(target_filename) }, - true, - ); - let target_is_dangling_symlink = target_symlink_md.is_ok() && target_deref_md.is_err(); - - let target_symlink_md = match target_symlink_md { - Ok(md) => Some(md), - Err(e) => { - if e.kind() == io::ErrorKind::NotFound { - None - } else { - let err_str = - gettext!("cannot access '{}': {}", target.display(), error_string(&e)); - return Err(io::Error::other(err_str)); - } - } - }; - - let target_is_dir = match &target_symlink_md { - Some(md) => md.file_type() == ftw::FileType::Directory, - None => false, - }; - - let target_exists = target_symlink_md.is_some(); - - // 1. If source_file references the same file as dest_file - if let (Ok(smd), Ok(tmd)) = (&source_deref_md, &target_deref_md) { - if smd.dev() == tmd.dev() && smd.ino() == tmd.ino() { - let err_str = gettext!( - "'{}' and '{}' are the same file", - source.path(), - target.display() - ); - return Err(io::Error::other(err_str)); - } - } - - // 2. If source_file is of type directory - if source_is_dir { - // 2.a - if !cfg.recursive { - let err_str = gettext!("-r not specified; omitting directory '{}'", source.path()); - return Err(io::Error::other(err_str)); - } - - // 2.b `fs::read_dir` skips `.` and `..`. Any occurence means it comes - // from the input to `cp`. - - // 2.d - if target_exists && !target_is_dir { - let err_str = gettext!( - "cannot overwrite non-directory '{}' with directory '{}'", - target.display(), - source.path() - ); - return Err(io::Error::other(err_str)); - } - - // 2.e - if !target_exists { - if target.starts_with(PathBuf::from(format!("{}", source.path()))) { - let err_str = gettext!( - "cannot copy a directory, '{}', into itself, '{}'", - source.path(), - target.display() - ); - return Err(io::Error::other(err_str)); - } - - unsafe { - // Creates the target directory with the same file permission bits as the source, - // modified by the umask of the process. Copying the permission bits without the - // umask is postponed to the `postprocess_dir` closure on the call to - // `traverse_directory` inside `copy_file`. - let ret = libc::mkdirat( - target_dirfd, - target_filename, - // OR'ed with S_IRWXU according to the spec - source_md.mode() as libc::mode_t | libc::S_IRWXU, - ); - - if ret != 0 { - let e = io::Error::last_os_error(); - let err_str = gettext!( - "cannot create directory '{}': {}", - target.display(), - error_string(&e) - ); - return Err(io::Error::other(err_str)); - } - } - } - - return Ok(CopyResult::CopyingDirectory); - } else { - // 3. If source_file is of type regular file - - let create_target_then_copy = || -> io::Result<()> { - let source_fd = unsafe { - libc::openat(source.dir_fd(), source.file_name().as_ptr(), libc::O_RDONLY) - }; - if source_fd == -1 { - let e = io::Error::last_os_error(); - let err_str = gettext!( - "cannot open '{}' for reading: {}", - source.path(), - error_string(&e) - ); - return Err(io::Error::other(err_str)); - } - let mut source_file = unsafe { fs::File::from_raw_fd(source_fd) }; - - // 3.b - let target_fd = unsafe { - libc::openat( - target_dirfd, - target_filename, - libc::O_WRONLY | libc::O_CREAT, - source_md.mode(), - ) - }; - if target_fd == -1 { - let e = io::Error::last_os_error(); - - // `ErrorKind::IsADirectory` is unstable: - // https://github.com/rust-lang/rust/issues/86442 - let err_msg = if let Some(libc::EISDIR) = e.raw_os_error() { - // EISDIR -> ENOTDIR is to match the diagnostic from - // coreutils/tests/cp/trailing-slash.sh - error_string(&io::Error::from_raw_os_error(libc::ENOTDIR)) - } else { - error_string(&e) - }; - let err_str = gettext!( - "cannot create regular file '{}': {}", - target.display(), - err_msg - ); - return Err(io::Error::other(err_str)); - } - let mut target_file = unsafe { fs::File::from_raw_fd(target_fd) }; - - // 3.d - io::copy(&mut source_file, &mut target_file)?; - - Ok(()) - }; - - // 3.a - if target_exists && !target_is_dangling_symlink { - if created_files.contains(target) { - let err_str = gettext!( - "will not overwrite just-created '{}' with '{}'", - target.display(), - source.path(), - ); - return Err(io::Error::other(err_str)); - } - - // 3.a.i - let target_is_writable = target_symlink_md - .as_ref() - .map(|md| md.is_writable()) - .unwrap_or(false); - - // Different prompt if the target is not writable - if !target_is_writable && (cfg.interactive || cfg.force) { - let mode = target_symlink_md.as_ref().unwrap().mode(); - - let mut mode_str = String::new(); - let bit_loc = 0o400; - for i in 0..9 { - let mask = bit_loc >> i; - if mode & mask != 0 { - match i % 3 { - 0 => mode_str.push('r'), - 1 => mode_str.push('w'), - 2 => mode_str.push('x'), - _ => (), - } - } else { - mode_str.push('-'); - } - } - - // 4 octal digits - // This needs to be formatted separately because `gettext!` does - // not accept a format spec (just plain curly braces, `{}`). - let mode_octal = format!("{:04o}", mode & 0o7777); - - if cfg.force { - let is_affirm = prompt_fn(&gettext!( - "replace '{}', overriding mode {} ({})?", - target.display(), - mode_octal, - mode_str - )); - if !is_affirm { - return Ok(CopyResult::Skipped); - } - } else if cfg.interactive { - let is_affirm = prompt_fn(&gettext!( - "unwritable '{}' (mode {}, {}); try anyway?", - target.display(), - mode_octal, - mode_str - )); - if !is_affirm { - return Ok(CopyResult::Skipped); - } - } - } else { - if cfg.interactive { - let is_affirm = prompt_fn(&gettext!("overwrite '{}'?", target.display())); - if !is_affirm { - return Ok(CopyResult::Skipped); - } - } - } - - // 4.c - if source_is_symlink { - let ret = unsafe { - libc::unlinkat( - target_dirfd, - target_filename, - if target_is_dir { libc::AT_REMOVEDIR } else { 0 }, - ) - }; - if ret != 0 { - return Err(io::Error::last_os_error()); - } - - let ret = unsafe { - libc::symlinkat( - source.read_link().unwrap().as_ptr(), - target_dirfd, - target_filename, - ) - }; - if ret != 0 { - return Err(io::Error::last_os_error()); - } - } else { - // 3.a.ii - let target_fd = unsafe { - libc::openat( - target_dirfd, - target_filename, - libc::O_WRONLY | libc::O_TRUNC, - ) - }; - if target_fd != -1 { - let mut target_file = unsafe { fs::File::from_raw_fd(target_fd) }; - - let source_fd = unsafe { - libc::openat(source.dir_fd(), source.file_name().as_ptr(), libc::O_RDONLY) - }; - if source_fd == -1 { - let e = io::Error::last_os_error(); - let err_str = gettext!( - "cannot open '{}' for reading: {}", - source.path(), - error_string(&e) - ); - return Err(io::Error::other(err_str)); - } - let mut source_file = unsafe { fs::File::from_raw_fd(source_fd) }; - - io::copy(&mut source_file, &mut target_file)?; - } else { - // 3.a.iii - if cfg.force { - let ret = unsafe { - libc::unlinkat( - target_dirfd, - target_filename, - if target_is_dir { libc::AT_REMOVEDIR } else { 0 }, - ) - }; - if ret != 0 { - return Err(io::Error::last_os_error()); - } - - // 3.b - create_target_then_copy()?; - } else { - let e = io::Error::last_os_error(); - let err_str = gettext!( - "cannot open '{}' for reading: {}", - target.display(), - error_string(&e) - ); - return Err(io::Error::other(err_str)); - } - } - } - - // 3.b - } else { - // 4.c - if source_is_symlink { - let ret = unsafe { - libc::symlinkat( - source.read_link().unwrap().as_ptr(), - target_dirfd, - target_filename, - ) - }; - if ret != 0 { - return Err(io::Error::last_os_error()); - } - } else { - create_target_then_copy()?; - } - } - - created_files.insert(target.to_path_buf()); - } - - Ok(CopyResult::CopiedFile) -} - -pub fn copy_file( - cfg: &CopyConfig, - source_arg: &Path, - target_arg: &Path, - created_files: &mut HashSet, - mut inode_map: Option<&mut InodeMap>, - prompt_fn: F, -) -> io::Result<()> -where - F: Copy + Fn(&str) -> bool, -{ - // `RefCell` to allow sharing these between closures - let target_dirfd_stack = RefCell::new(vec![ftw::FileDescriptor::cwd()]); - let target_dir_path = RefCell::new(PathBuf::new()); - let terminate = RefCell::new(false); - let last_error = RefCell::new(None); - - let _ = traverse_directory( - source_arg, - |source| { - let mut terminate_borrowed = terminate.borrow_mut(); - let mut target_dirfd_stack_borrowed = target_dirfd_stack.borrow_mut(); - let mut target_dir_path_borrowed = target_dir_path.borrow_mut(); - - if *terminate_borrowed { - return Ok(false); - } - - let target_dirfd = target_dirfd_stack_borrowed.last().unwrap(); - - let target_filename = if target_dirfd.as_raw_fd() == libc::AT_FDCWD { - target_arg.as_os_str() - } else { - OsStr::from_bytes(source.file_name().to_bytes()) - }; - - let target = target_dir_path_borrowed.join(target_filename); - let target_filename_cstr = CString::new(target_filename.as_bytes()).unwrap(); - - let source_md = source.metadata().unwrap(); - let identifier = (source_md.dev(), source_md.ino()); - - // Hard-link preserving behavior of `mv`. `cp` does not maintain the hard-link structure - // of the hierarchy according to the standard - if let Some(inode_map) = inode_map.as_deref_mut() { - // Preserve hard links like coreutils mv. Creating a copy is also - // allowed by the standard. - if let Some((prev_dirfd, prev_filename)) = inode_map.get(&identifier) { - let ret = unsafe { - libc::linkat( - prev_dirfd.as_raw_fd(), - prev_filename.as_ptr(), - target_dirfd.as_raw_fd(), - target_filename_cstr.as_ptr(), - 0, // Don't dereference prev if it's a symlink - ) - }; - // If success - if ret == 0 { - // Skip since this file/directory is handled by hard-linking - return Ok(false); - } - // else failed - else { - *last_error.borrow_mut() = Some(io::Error::last_os_error()); - *terminate_borrowed = true; - return Ok(false); - } - } - } - - let continue_processing = match copy_file_impl( - cfg, - &source, - &target, - target_dirfd.as_raw_fd(), - target_filename_cstr.as_ptr(), - created_files, - prompt_fn, - ) { - Ok(copy_result) => { - // If copying succeeds, then store the hard-link data - if let Some(inode_map) = inode_map.as_deref_mut() { - // Don't include every file, just those with hard links - if source_md.nlink() > 1 { - inode_map.insert( - identifier, - (target_dirfd.clone(), target_filename_cstr.clone()), - ); - } - } - - match copy_result { - CopyResult::CopyingDirectory => { - // mkdir/mkdirat doesn't return a file descriptor so a new one must be - // opened here. Using O_CREAT | O_DIRECTORY in a call to open/openat would - // not allow atomically creating a directory then opening it: - // - // https://stackoverflow.com/questions/45818628/whats-the-expected-behavior-of-openname-o-creato-directory-mode/48693137#48693137 - let new_target_dirfd = match unsafe { - ftw::FileDescriptor::open_at( - target_dirfd, - &target_filename_cstr, - libc::O_RDONLY, - ) - } { - Ok(fd) => fd, - Err(e) => { - let err_str = gettext!( - "cannot open directory '{}': {}", - target.display(), - error_string(&e) - ); - *last_error.borrow_mut() = Some(io::Error::other(err_str)); - *terminate_borrowed = true; - return Ok(false); - } - }; - - target_dirfd_stack_borrowed.push(new_target_dirfd); - target_dir_path_borrowed.push(target_filename); - - true - } - CopyResult::CopiedFile => { - // Immediately copy the metadata if copying a file. Directories are - // handled on the `postprocess_dir` closure below. - if cfg.preserve { - if let Err(e) = copy_characteristics( - &source, - &target, - target_dirfd.as_raw_fd(), - target_filename_cstr.as_ptr(), - ) { - *last_error.borrow_mut() = Some(e); - *terminate_borrowed = true; - false - } else { - true - } - } else { - true - } - } - CopyResult::Skipped => false, - } - } - Err(e) => { - *last_error.borrow_mut() = Some(e); - *terminate_borrowed = true; - false - } - }; - - Ok(continue_processing) - }, - |source| { - let mut terminate_borrowed = terminate.borrow_mut(); - let mut target_dirfd_stack_borrowed = target_dirfd_stack.borrow_mut(); - let mut target_dir_path_borrowed = target_dir_path.borrow_mut(); - - target_dir_path_borrowed.pop(); - target_dirfd_stack_borrowed.pop(); - - // Preserve metadata for directories. Must do this inside this closure to ensure no - // further last access time changes to the source will be made. - if cfg.preserve { - let target_dirfd = target_dirfd_stack_borrowed.last().unwrap(); - - let target_filename = if target_dirfd.as_raw_fd() == libc::AT_FDCWD { - target_arg.as_os_str() - } else { - OsStr::from_bytes(source.file_name().to_bytes()) - }; - let target_filename_cstr = CString::new(target_filename.as_bytes()).unwrap(); - - if let Err(e) = copy_characteristics( - &source, - &target_dir_path_borrowed, - target_dirfd.as_raw_fd(), - target_filename_cstr.as_ptr(), - ) { - *last_error.borrow_mut() = Some(e); - *terminate_borrowed = true; - } - } - - Ok(()) - }, - |_entry, error| { - *last_error.borrow_mut() = Some(error.inner()); - *terminate.borrow_mut() = true; - }, - ftw::TraverseDirectoryOpts { - follow_symlinks_on_args: cfg.follow_cli, - follow_symlinks: cfg.dereference, - ..Default::default() - }, - ); - - match last_error.into_inner() { - Some(e) => Err(e), - None => Ok(()), - } -} - -pub fn copy_files( - cfg: &CopyConfig, - sources: &[PathBuf], - target: &Path, - mut inode_map: Option<&mut InodeMap>, - prompt_fn: F, -) -> Option<()> -where - F: Copy + Fn(&str) -> bool, -{ - let mut result = Some(()); - - let mut created_files = HashSet::new(); - - // loop through sources, moving each to target - for source in sources { - // This doesn't seem to be compliant with POSIX - let ends_with_slash_dot = |p: &Path| -> bool { - let bytes = p.as_os_str().as_bytes(); - if bytes.len() >= 2 { - let end = &bytes[(bytes.len() - 2)..]; - return end == b"/."; - } - false - }; - - let new_target = 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() - } else { - match source.file_name() { - Some(file_name) => target.join(file_name), - None => { - let err_str = gettext!("invalid filename: {}", source.display()); - eprintln!("cp: {}", err_str); - result = None; - continue; - } - } - }; - - match copy_file( - cfg, - source, - &new_target, - &mut created_files, - inode_map.as_deref_mut(), - prompt_fn, - ) { - Ok(_) => (), - Err(e) => { - eprintln!("cp: {}", error_string(&e)); - result = None; - } - } - } - - result -} - -fn copy_special_file( - cfg: &CopyConfig, - source_md: &ftw::Metadata, - - // Should only be used for keeping track of created files and for displaying error messages - target: &Path, - - target_dirfd: libc::c_int, - target_filename: *const libc::c_char, - created_files: &mut HashSet, - is_fifo: bool, -) -> io::Result<()> { - // 4.a - let dev = source_md.rdev(); - - // 4.b - let mode = if is_fifo { - // Mandatory to be the same as source for FIFO - source_md.mode() - } else { - // Under Rationale: - // "In general, it is strongly suggested that the permissions, - // owner, and group be the same as if the user had run the - // historical mknod, ln, or other utility to create the file" - 0o644 - }; - - let mut stat_buf = MaybeUninit::uninit(); - - // Using `fstatat` to check for the existence of the target file - let ret = unsafe { - libc::fstatat( - target_dirfd, - target_filename, - stat_buf.as_mut_ptr(), - libc::AT_SYMLINK_NOFOLLOW, - ) - }; - let target_exists = ret == 0; - - if target_exists { - let ret = unsafe { libc::unlinkat(target_dirfd, target_filename, 0) }; - if ret != 0 { - return Err(io::Error::last_os_error()); - } - } - - let ret = unsafe { - libc::mknodat( - target_dirfd, - target_filename, - mode as libc::mode_t, - dev as libc::dev_t, - ) - }; - if ret == 0 { - created_files.insert(target.to_path_buf()); - Ok(()) - } else { - let e = io::Error::last_os_error(); - let err_str = gettext!( - "cannot create regular file '{}': {}", - target.display(), - error_string(&e) - ); - Err(io::Error::other(err_str)) - } -} - -// Copy the metadata in `source_md` to the target. -fn copy_characteristics( - source: &ftw::Entry, - target: &Path, - target_dirfd: libc::c_int, - target_filename: *const libc::c_char, -) -> io::Result<()> { - // Get a new metadata instead because the source's last access time is updated on reads (i.e, - // `io::copy`). - // Should fix sporadic errors on `test_cp_preserve_slink_time` where `dangle` has a later - // access time than `d2`. - let source_md = unsafe { ftw::Metadata::new(source.dir_fd(), source.file_name(), false) }?; - - // [last_access_time, last_modified_time] - let times = [ - libc::timespec { - tv_sec: source_md.atime(), - tv_nsec: source_md.atime_nsec(), - }, - libc::timespec { - tv_sec: source_md.mtime(), - tv_nsec: source_md.mtime_nsec(), - }, - ]; - - unsafe { - // Copy last access and last modified times - let ret = libc::utimensat( - target_dirfd, - target_filename, - times.as_ptr(), - libc::AT_SYMLINK_NOFOLLOW, // Update the file itself if a symlink - ); - if ret != 0 { - let err_str = gettext!( - "failed to preserve times for '{}': {}", - target.display(), - io::Error::last_os_error() - ); - return Err(io::Error::other(err_str)); - } - - // Copy user and group - let ret = libc::fchownat( - target_dirfd, - target_filename, - source_md.uid(), - source_md.gid(), - libc::AT_SYMLINK_NOFOLLOW, - ); - if ret != 0 { - // Ignore errors - errno::set_errno(errno::Errno(0)); - } - - // Copy permissions - let ret = libc::fchmodat( - target_dirfd, - target_filename, - source_md.mode() as libc::mode_t, - libc::AT_SYMLINK_NOFOLLOW, - ); - if ret != 0 { - let fchmodat_error = io::Error::last_os_error(); - - // Symbolic link permissions are ignored on Linux - #[cfg(target_os = "linux")] - if let Ok(md) = ftw::Metadata::new(target_dirfd, CStr::from_ptr(target_filename), false) - { - if md.file_type() == ftw::FileType::SymbolicLink { - if let Some(errno) = fchmodat_error.raw_os_error() { - if errno == libc::EOPNOTSUPP { - return Ok(()); - } - } - } - } - - let err_str = gettext!( - "failed to preserve permissions for '{}': {}", - target.display(), - io::Error::last_os_error() - ); - return Err(io::Error::other(err_str)); - } - } - Ok(()) -} diff --git a/tree/tests/chown/mod.rs b/tree/tests/chown/mod.rs new file mode 100644 index 000000000..497fbd45b --- /dev/null +++ b/tree/tests/chown/mod.rs @@ -0,0 +1,643 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// 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 +// + +use plib::testing::{run_test, TestPlan}; +use std::{ + collections::HashSet, + fs, io, + os::unix::{self, fs::MetadataExt}, + sync::OnceLock, +}; + +fn chown_test(args: &[&str], expected_output: &str, expected_error: &str, expected_exit_code: i32) { + let str_args: Vec = args.iter().map(|s| String::from(*s)).collect(); + + run_test(TestPlan { + cmd: String::from("chown"), + args: str_args, + stdin_data: String::new(), + expected_out: String::from(expected_output), + expected_err: String::from(expected_error), + expected_exit_code, + }); +} + +fn is_root() -> bool { + unsafe { libc::geteuid() == 0 } +} + +// Returns all the groups of the current user +fn current_user_group_ids() -> &'static Vec { + static GROUP_IDS: OnceLock> = OnceLock::new(); + GROUP_IDS.get_or_init(|| { + let num_groups = unsafe { libc::getgroups(0, std::ptr::null_mut()) }; + if num_groups < 0 { + panic!( + "unable to determine number of secondary groups {}", + io::Error::last_os_error() + ); + } + + let mut groups_list: Vec = vec![0; num_groups as usize]; + let actual_num_groups = unsafe { libc::getgroups(num_groups, groups_list.as_mut_ptr()) }; + assert_eq!(num_groups, actual_num_groups); + + groups_list + }) +} + +// Tests parsing the `owner:group` argument. Requires root because this modifies the owner. +#[test] +#[cfg_attr( + not(all(feature = "posixutils_test_all", feature = "requires_root")), + ignore +)] +fn test_chown_ownergroup_spec() { + let test_dir = &format!("{}/test_chown_ownergroup_spec", env!("CARGO_TARGET_TMPDIR")); + let f = &format!("{test_dir}/f"); + + fs::create_dir(test_dir).unwrap(); + fs::File::create(f).unwrap(); + + // chown owner[:group] f + + chown_test(&["0", f], "", "", 0); + chown_test(&["0:0", f], "", "", 0); + + // Empty `owner` is allowed and is equivalent to chgrp + chown_test(&[":0", f], "", "", 0); + + // `group` cannot be empty + chown_test(&["0:", f], "", "chown: invalid spec: '0:'\n", 1); + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Changing the owner always requires root. Requires root because this modifies the owner. +#[test] +#[cfg_attr( + not(all(feature = "posixutils_test_all", feature = "requires_root")), + ignore +)] +fn test_chown_change_owner() { + let test_dir = &format!("{}/test_chown_change_owner", env!("CARGO_TARGET_TMPDIR")); + let f = &format!("{test_dir}/f"); + + fs::create_dir(test_dir).unwrap(); + fs::File::create(f).unwrap(); + + // Test `chown owner f` + for owner in [0, 1, 2] { + chown_test(&[owner.to_string().as_str(), f], "", "", 0); + let md = fs::metadata(f).unwrap(); + assert_eq!(owner, md.uid()); + } + + // `chown owner: f` should fail and not modify the owner of the file + chown_test(&["1", f], "", "", 0); + let original_uid = fs::metadata(f).unwrap().uid(); + chown_test(&["0:", f], "", "chown: invalid spec: '0:'\n", 1); + assert_eq!(original_uid, fs::metadata(f).unwrap().uid()); + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Tests the error when the owner is changed by a non-root user +#[test] +fn test_chown_change_owner_without_root() { + if is_root() { + eprintln!("Skipping test: must not be root to test the failure case"); + return; + } + + let test_dir = &format!("{}/test_chown_change_owner", env!("CARGO_TARGET_TMPDIR")); + let f = &format!("{test_dir}/f"); + + fs::create_dir(test_dir).unwrap(); + fs::File::create(f).unwrap(); + + chown_test( + &["0", f], + "", + &format!("chown: changing ownership of '{f}': Operation not permitted\n"), + 1, + ); + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Tests changing both the owner and the group simultaneously. Requires root because this modifies +// the owner. +#[test] +#[cfg_attr( + not(all(feature = "posixutils_test_all", feature = "requires_root")), + ignore +)] +fn test_chown_change_owner_and_group() { + let test_dir = &format!( + "{}/test_chown_change_owner_and_group", + env!("CARGO_TARGET_TMPDIR") + ); + let f = &format!("{test_dir}/f"); + + fs::create_dir(test_dir).unwrap(); + fs::File::create(f).unwrap(); + + // Test `chown owner:group f` + for owner in [0, 1, 2] { + for group in [0, 1, 2] { + chown_test(&[&format!("{owner}:{group}"), f], "", "", 0); + let md = fs::metadata(f).unwrap(); + assert_eq!(owner, md.uid()); + assert_eq!(group, md.gid()); + } + } + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Changing only the group does not require root privileges +#[test] +fn test_chown_change_group() { + let test_dir = &format!("{}/test_chown_change_group", env!("CARGO_TARGET_TMPDIR")); + let f = &format!("{test_dir}/f"); + + fs::create_dir(test_dir).unwrap(); + fs::File::create(f).unwrap(); + + // Test `chown :group f` + let owner = fs::metadata(f).unwrap().uid(); + for &group in current_user_group_ids() { + chown_test(&[&format!(":{group}"), f], "", "", 0); + let md = fs::metadata(f).unwrap(); + assert_eq!(owner, md.uid()); + assert_eq!(group, md.gid()); + } + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Verifies that changing a file's group to one the user doesn't belong to fails with the expected +// error message +#[test] +fn test_chown_change_to_non_member_group() { + if is_root() { + eprintln!("Skipping test: must not be root to test the failure case"); + return; + } + + // Get the GID of a group that the test runner doesn't belong to + fn get_non_member_group() -> Option { + let user_groups: HashSet<_> = current_user_group_ids().into_iter().collect(); + let mut non_member_group: Option = None; // Group that the current user does not belong to + + // Start reading the group database + unsafe { libc::setgrent() }; + + loop { + let group = unsafe { libc::getgrent() }; + if group.is_null() { + break; + } + let gid = unsafe { (&*group).gr_gid }; + + if !user_groups.contains(&gid) { + non_member_group = Some(gid); + break; + } + } + + // End reading the group database + unsafe { libc::endgrent() }; + + non_member_group + } + + let test_dir = &format!( + "{}/test_chown_change_to_non_member_group", + env!("CARGO_TARGET_TMPDIR") + ); + let f = &format!("{test_dir}/f"); + + fs::create_dir(test_dir).unwrap(); + fs::File::create(f).unwrap(); + + if let Some(non_member_group) = get_non_member_group() { + chown_test( + &[&format!(":{non_member_group}"), f], + "", + // Message should be "changing group of" instead of "changing ownership of" + &format!("chown: changing group of '{f}': Operation not permitted\n"), + 1, + ); + } else { + panic!("Unable to test: the user belongs to all groups"); + } + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Tests error messages for nonexistent file/user/group +#[test] +fn test_chown_nonexistent() { + let test_dir = &format!("{}/test_chown_nonexistent", env!("CARGO_TARGET_TMPDIR")); + let f = &format!("{test_dir}/f"); + + fs::create_dir(test_dir).unwrap(); + + // Nonexistent file + chown_test( + &[&format!(":1"), f], + "", + &format!("chown: cannot access '{f}': No such file or directory\n"), + 1, + ); + fs::File::create(f).unwrap(); + + // Nonexistent user + chown_test( + &[&format!("nonexistent_user"), f], + "", + &format!("chown: invalid user: 'nonexistent_user'\n"), + 1, + ); + + // Nonexistent group + chown_test( + &[&format!(":nonexistent_group"), f], + "", + &format!("chown: invalid group: 'nonexistent_group'\n"), + 1, + ); + fs::remove_dir_all(test_dir).unwrap(); +} + +// Returns a UID and GID that are different from the default UID/GID that are set when creating a +// file. GID is chosen from the current user's groups while the UID is either `None` when not +// running as root or any other UID. +fn select_target_ownergroup() -> (Option, u32) { + let original_gid = unsafe { libc::getegid() }; + + let target_owner = if is_root() { + // Choose any UID that is not the owner of `f` and `symlink` + let mut target_owner = None; + for owner in 0.. { + if owner != original_gid { + target_owner = Some(owner); + break; + } + } + Some(target_owner.unwrap()) + } else { + // Don't change the owner when this test is not run as root + None + }; + + let target_group = if is_root() { + // Choose any GID that is not the group of `f` and `symlink` + let mut target_group = None; + for group in 0.. { + if group != original_gid { + target_group = Some(group); + break; + } + } + target_group.unwrap() + } else { + // Choose a group from the current user's group + let mut target_group = None; + for &group in current_user_group_ids() { + if group != original_gid { + target_group = Some(group); + break; + } + } + target_group.unwrap() + }; + + (target_owner, target_group) +} + +// Tests the -h flag. This test can be run with or without root. +#[test] +fn test_chown_symlink() { + let test_dir = &format!("{}/test_chown_symlink", env!("CARGO_TARGET_TMPDIR")); + let f = &format!("{test_dir}/f"); + let symlink = &format!("{test_dir}/symlink"); + + fs::create_dir(test_dir).unwrap(); + fs::File::create(f).unwrap(); + unix::fs::symlink(f, symlink).unwrap(); + + let (original_uid, original_gid) = fs::metadata(f).map(|md| (md.uid(), md.gid())).unwrap(); + let (symlink_uid, symlink_gid) = fs::symlink_metadata(symlink) + .map(|md| (md.uid(), md.gid())) + .unwrap(); + + // Because they are created by the same process + assert_eq!(original_uid, symlink_uid); + assert_eq!(original_gid, symlink_gid); + + let (target_owner, target_group) = select_target_ownergroup(); + let target_owner_str = match target_owner { + Some(uid) => uid.to_string(), + None => String::from(""), + }; + + // `chown -h owner:group symlink` should only change the owner/group of the symlink itself + chown_test( + &["-h", &format!("{target_owner_str}:{target_group}"), symlink], + "", + "", + 0, + ); + assert_eq!(fs::symlink_metadata(f).unwrap().gid(), original_gid); // `f` should be unaffected + assert_eq!(fs::symlink_metadata(symlink).unwrap().gid(), target_group); + if is_root() { + assert_eq!( + fs::symlink_metadata(symlink).unwrap().uid().to_string(), + target_owner_str + ); + } + + // Reset `symlink` + { + let owner_group_arg = if is_root() { + format!("{original_uid}:{original_gid}") + } else { + format!(":{original_gid}") + }; + chown_test(&["-h", &owner_group_arg, symlink], "", "", 0); + } + + // `chown owner:group symlink` should modify `f` through `symlink` + chown_test( + &[&format!("{target_owner_str}:{target_group}"), symlink], + "", + "", + 0, + ); + assert_eq!(fs::symlink_metadata(symlink).unwrap().gid(), original_gid); // `symlink` should be unaffected + assert_eq!(fs::symlink_metadata(f).unwrap().gid(), target_group); + if is_root() { + assert_eq!( + fs::symlink_metadata(f).unwrap().uid().to_string(), + target_owner_str + ); + } + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Tests the -R flag +#[test] +fn test_chown_recursive_basic() { + let test_dir = &format!("{}/test_chown_recursive_basic", env!("CARGO_TARGET_TMPDIR")); + let f = &format!("{test_dir}/f"); + let f_g = &format!("{test_dir}/f/g"); + let f_g_h = &format!("{test_dir}/f/g/h"); + + fs::create_dir(test_dir).unwrap(); + fs::create_dir_all(f_g).unwrap(); + fs::File::create(f_g_h).unwrap(); + + let (target_owner, target_group) = select_target_ownergroup(); + let target_owner_str = match target_owner { + Some(uid) => uid.to_string(), + None => String::from(""), + }; + + chown_test( + &["-R", &format!("{target_owner_str}:{target_group}"), f], + "", + "", + 0, + ); + + // Recursively change the owner and group of f, f/g and f/g/h + assert_eq!(target_group, fs::metadata(f).unwrap().gid()); + assert_eq!(target_group, fs::metadata(f_g).unwrap().gid()); + assert_eq!(target_group, fs::metadata(f_g_h).unwrap().gid()); + if is_root() { + let target_owner = target_owner.unwrap(); + assert_eq!(target_owner, fs::metadata(f).unwrap().uid()); + assert_eq!(target_owner, fs::metadata(f_g).unwrap().uid()); + assert_eq!(target_owner, fs::metadata(f_g_h).unwrap().uid()); + } + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Tests the -P flag +#[test] +fn test_chown_recursive_follow_none() { + let test_dir = &format!( + "{}/test_chown_recursive_follow_none", + env!("CARGO_TARGET_TMPDIR") + ); + let f = &format!("{test_dir}/f"); + let f_g = &format!("{test_dir}/f/g"); + let f_g_h = &format!("{test_dir}/f/g/h"); + let g = &format!("{test_dir}/g"); + let g_symlink = &format!("{test_dir}/g/symlink"); + + fs::create_dir(test_dir).unwrap(); + fs::create_dir_all(f_g).unwrap(); + fs::File::create(f_g_h).unwrap(); + fs::create_dir_all(g).unwrap(); + unix::fs::symlink(f, g_symlink).unwrap(); + + let original_uid = unsafe { libc::geteuid() }; + let original_gid = unsafe { libc::getegid() }; + + let (target_owner, target_group) = select_target_ownergroup(); + let target_owner_str = match target_owner { + Some(uid) => uid.to_string(), + None => String::from(""), + }; + + // chown -RP owner:group g + // Only g and g/symlink should be changed. The symlink to f should not be followed. + chown_test( + &["-RP", &format!("{target_owner_str}:{target_group}"), g], + "", + "", + 0, + ); + + // f, f/g and f/g/h must be unchanged + assert_eq!(original_gid, fs::metadata(f).unwrap().gid()); + assert_eq!(original_gid, fs::metadata(f_g).unwrap().gid()); + assert_eq!(original_gid, fs::metadata(f_g_h).unwrap().gid()); + if is_root() { + assert_eq!(original_uid, fs::metadata(f).unwrap().uid()); + assert_eq!(original_uid, fs::metadata(f_g).unwrap().uid()); + assert_eq!(original_uid, fs::metadata(f_g_h).unwrap().uid()); + } + + // g and g/symlink should be changed + assert_eq!(target_group, fs::metadata(g).unwrap().gid()); + assert_eq!(target_group, fs::symlink_metadata(g_symlink).unwrap().gid()); + if is_root() { + let target_owner = target_owner.unwrap(); + assert_eq!(target_owner, fs::metadata(g).unwrap().uid()); + assert_eq!(target_owner, fs::symlink_metadata(g_symlink).unwrap().uid()); + } + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Tests the -L flag +#[test] +fn test_chown_recursive_follow_symlinks() { + let test_dir = &format!( + "{}/test_chown_recursive_follow_symlinks", + env!("CARGO_TARGET_TMPDIR") + ); + let f = &format!("{test_dir}/f"); + let f_g = &format!("{test_dir}/f/g"); + let f_g_h = &format!("{test_dir}/f/g/h"); + let g = &format!("{test_dir}/g"); + let g_symlink = &format!("{test_dir}/g/symlink"); + + fs::create_dir(test_dir).unwrap(); + fs::create_dir_all(f_g).unwrap(); + fs::File::create(f_g_h).unwrap(); + fs::create_dir_all(g).unwrap(); + unix::fs::symlink(f, g_symlink).unwrap(); + + let original_uid = unsafe { libc::geteuid() }; + let original_gid = unsafe { libc::getegid() }; + + let (target_owner, target_group) = select_target_ownergroup(); + let target_owner_str = match target_owner { + Some(uid) => uid.to_string(), + None => String::from(""), + }; + + // chown -RL owner:group g + // Follow the symlink to f and change everything. `g/symlink` remains unchanged because f was + // changed in its place. + chown_test( + &["-RL", &format!("{target_owner_str}:{target_group}"), g], + "", + "", + 0, + ); + + // g/symlink should be unchanged + assert_eq!(original_gid, fs::symlink_metadata(g_symlink).unwrap().gid()); + if is_root() { + assert_eq!(original_uid, fs::symlink_metadata(g_symlink).unwrap().uid()); + } + + // f, g, f/g and f/g/h should be changed + assert_eq!(target_group, fs::metadata(f).unwrap().gid()); + assert_eq!(target_group, fs::metadata(f_g).unwrap().gid()); + assert_eq!(target_group, fs::metadata(f_g_h).unwrap().gid()); + assert_eq!(target_group, fs::metadata(g).unwrap().gid()); + if is_root() { + let target_owner = target_owner.unwrap(); + assert_eq!(target_owner, fs::metadata(f).unwrap().uid()); + assert_eq!(target_owner, fs::metadata(f_g).unwrap().uid()); + assert_eq!(target_owner, fs::metadata(f_g_h).unwrap().uid()); + assert_eq!(target_owner, fs::metadata(g).unwrap().uid()); + } + + // Recreate the files + fs::remove_dir_all(test_dir).unwrap(); + fs::create_dir(test_dir).unwrap(); + fs::create_dir_all(f_g).unwrap(); + fs::File::create(f_g_h).unwrap(); + fs::create_dir_all(g).unwrap(); + unix::fs::symlink(f, g_symlink).unwrap(); + + // chown -RLh owner:group g + // Follow the symlink to f and change everything. `f` is the one unchanged when -h is used + chown_test( + &["-RLh", &format!("{target_owner_str}:{target_group}"), g], + "", + "", + 0, + ); + + // f should be unchanged + assert_eq!(original_gid, fs::metadata(f).unwrap().gid()); + if is_root() { + assert_eq!(original_uid, fs::metadata(f).unwrap().uid()); + } + + // f, g, f/g and f/g/h should be changed + assert_eq!(target_group, fs::symlink_metadata(g_symlink).unwrap().gid()); + assert_eq!(target_group, fs::metadata(f_g).unwrap().gid()); + assert_eq!(target_group, fs::metadata(f_g_h).unwrap().gid()); + assert_eq!(target_group, fs::metadata(g).unwrap().gid()); + if is_root() { + let target_owner = target_owner.unwrap(); + assert_eq!(target_owner, fs::symlink_metadata(g_symlink).unwrap().uid()); + assert_eq!(target_owner, fs::metadata(f_g).unwrap().uid()); + assert_eq!(target_owner, fs::metadata(f_g_h).unwrap().uid()); + assert_eq!(target_owner, fs::metadata(g).unwrap().uid()); + } + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Changing ownership should propagate through hard links +#[test] +fn test_chown_hardlink() { + let test_dir = &format!("{}/test_chown_hardlink", env!("CARGO_TARGET_TMPDIR")); + let f = &format!("{test_dir}/f"); + let hardlink = &format!("{test_dir}/hardlink"); + + fs::create_dir(test_dir).unwrap(); + fs::File::create(f).unwrap(); + fs::hard_link(f, hardlink).unwrap(); + + let (target_owner, target_group) = select_target_ownergroup(); + let target_owner_str = match target_owner { + Some(uid) => uid.to_string(), + None => String::from(""), + }; + + chown_test( + &[&format!("{target_owner_str}:{target_group}"), f], + "", + "", + 0, + ); + + // `hardlink` should be changed + assert_eq!(target_group, fs::metadata(hardlink).unwrap().gid()); + if is_root() { + assert_eq!(target_owner.unwrap(), fs::metadata(hardlink).unwrap().uid()); + } + + // Recreate f and the hard link + fs::remove_file(f).unwrap(); + fs::remove_file(hardlink).unwrap(); + fs::File::create(f).unwrap(); + fs::hard_link(f, hardlink).unwrap(); + + // Test changing ownership using the hard link + chown_test( + &[&format!("{target_owner_str}:{target_group}"), hardlink], + "", + "", + 0, + ); + + // `f` should be changed + assert_eq!(target_group, fs::metadata(f).unwrap().gid()); + if is_root() { + assert_eq!(target_owner.unwrap(), fs::metadata(f).unwrap().uid()); + } + + fs::remove_dir_all(test_dir).unwrap(); +} diff --git a/tree/tests/tree-tests.rs b/tree/tests/tree-tests.rs index 85277e1b4..8c8491ecf 100644 --- a/tree/tests/tree-tests.rs +++ b/tree/tests/tree-tests.rs @@ -9,6 +9,7 @@ mod chgrp; mod chmod; +mod chown; mod cp; mod link; mod ls;