From 2d3c22bee110e15a8fda4c750ffe1342c4f6b7ce Mon Sep 17 00:00:00 2001 From: Joseph Rafael Ferrer Date: Thu, 23 Jan 2025 00:04:52 +0800 Subject: [PATCH] chmod + tests --- plib/src/modestr.rs | 420 +++++++++++++----- tree/chmod.rs | 145 ++++-- tree/mkdir.rs | 6 +- tree/mkfifo.rs | 6 +- tree/tests/chmod/mod.rs | 187 ++++++++ .../{integration2.rs => tree-tests-umask.rs} | 165 ++++++- tree/tests/tree-tests.rs | 1 + 7 files changed, 744 insertions(+), 186 deletions(-) create mode 100644 tree/tests/chmod/mod.rs rename tree/tests/{integration2.rs => tree-tests-umask.rs} (60%) diff --git a/plib/src/modestr.rs b/plib/src/modestr.rs index 9efe1ed12..7b125055c 100644 --- a/plib/src/modestr.rs +++ b/plib/src/modestr.rs @@ -7,10 +7,7 @@ // SPDX-License-Identifier: MIT // -use libc::{ - S_IRGRP, S_IROTH, S_IRUSR, S_IRWXG, S_IRWXO, S_IRWXU, S_ISUID, S_ISVTX, S_IWGRP, S_IWOTH, - S_IWUSR, S_IXGRP, S_IXOTH, S_IXUSR, -}; +use libc::{S_IRWXG, S_IRWXO, S_IRWXU, S_ISGID, S_ISUID, S_ISVTX, S_IXGRP, S_IXOTH, S_IXUSR}; #[derive(PartialEq, Debug, Default)] pub enum ChmodActionOp { @@ -58,7 +55,8 @@ pub struct ChmodSymbolic { #[derive(Debug)] pub enum ChmodMode { - Absolute(u32), + /// (Numeric value, number of digits in octal notation) + Absolute(u32, u32), Symbolic(ChmodSymbolic), } @@ -75,7 +73,7 @@ enum ParseState { pub fn parse(mode: &str) -> Result { if let Ok(m) = u32::from_str_radix(mode, 8) { - return Ok(ChmodMode::Absolute(m)); + return Ok(ChmodMode::Absolute(m, mode.len() as u32)); } let mut done_with_char; @@ -189,155 +187,212 @@ pub fn parse(mode: &str) -> Result { } // apply symbolic mutations to the given file at path -pub fn mutate(cur_mode: u32, symbolic: &ChmodSymbolic) -> u32 { - let mut new_mode = cur_mode; - let mut user = cur_mode & S_IRWXU as u32; - let mut group = cur_mode & S_IRWXG as u32; - let mut others = cur_mode & S_IRWXO as u32; +pub fn mutate(init_mode: u32, is_dir: bool, symbolic: &ChmodSymbolic) -> u32 { + let mut user = init_mode & S_IRWXU as u32; + let mut group = init_mode & S_IRWXG as u32; + let mut others = init_mode & S_IRWXO as u32; + let mut special = init_mode & (S_ISUID | S_ISGID | S_ISVTX) as u32; + + let mut cached_umask = None; + + let mut get_umask = || -> u32 { + match cached_umask { + Some(m) => m, + None => { + // WARNING: + // Potential umask race-condition. Tests that exercise this code path must be + // located in tree/tree-tests-umask.rs so that they would not be run in parallel. + + let m = unsafe { libc::umask(0) }; + unsafe { libc::umask(m) }; // Immediately revert + + let mask = m as u32; // Cast for macOS + cached_umask = Some(mask); + mask + } + } + }; // apply each clause for clause in &symbolic.clauses { + let who_is_not_specified = !(clause.user || clause.group || clause.others); + // apply each action for action in &clause.actions { + let mut rwx = 0; + if action.read { + rwx |= 0b100; + } + if action.write { + rwx |= 0b010; + } + if action.execute { + rwx |= 0b001; + } + + // Specification says: + // "if the current (unmodified) file mode bits have at least one of the execute bits" + // + // Upon testing the GNU chmod implementation, "current" here does not mean the initial + // mode bits, but the mode bits built by the previous clauses. + let has_any_exec_bits = + ((user | group | others) & (S_IXUSR | S_IXGRP | S_IXOTH) as u32) != 0; + match action.op { // add bits to the mode ChmodActionOp::Add => { - if action.copy_user { - user |= cur_mode & S_IRWXU as u32; + if clause.user { + user |= rwx << 6; } - if action.copy_group { - group |= cur_mode & S_IRWXG as u32; + if clause.group { + group |= rwx << 3; } - if action.copy_others { - others |= cur_mode & S_IRWXO as u32; + if clause.others { + others |= rwx; } - if action.read { - user |= S_IRUSR as u32; - group |= S_IRGRP as u32; - others |= S_IROTH as u32; - } - if action.write { - user |= S_IWUSR as u32; - group |= S_IWGRP as u32; - others |= S_IWOTH as u32; - } - if action.execute { - user |= S_IXUSR as u32; - group |= S_IXGRP as u32; - others |= S_IXOTH as u32; - } - if action.execute_dir { - user |= S_IXUSR as u32; - group |= S_IXGRP as u32; - others |= S_IXOTH as u32; + + if who_is_not_specified { + let umask = get_umask(); + + user |= (rwx << 6) & !umask; + group |= (rwx << 3) & !umask; + others |= rwx & !umask; } + if action.setuid { - user |= S_ISUID as u32; - } - if action.sticky { - others |= S_ISVTX as u32; + // If "who" is missing, set both `S_ISUID` and `S_ISGID` + if clause.user || who_is_not_specified { + special |= S_ISUID as u32; + } + if clause.group || who_is_not_specified { + special |= S_ISGID as u32; + } } } // remove bits from the mode ChmodActionOp::Remove => { - if action.copy_user { - user &= !(cur_mode & S_IRWXU as u32); - } - if action.copy_group { - group &= !(cur_mode & S_IRWXG as u32); - } - if action.copy_others { - others &= !(cur_mode & S_IRWXO as u32); + if clause.user { + user &= !(rwx << 6); } - if action.read { - user &= !S_IRUSR as u32; - group &= !S_IRGRP as u32; - others &= !S_IROTH as u32; + if clause.group { + group &= !(rwx << 3); } - if action.write { - user &= !S_IWUSR as u32; - group &= !S_IWGRP as u32; - others &= !S_IWOTH as u32; + if clause.others { + others &= !rwx; } - if action.execute { - user &= !S_IXUSR as u32; - group &= !S_IXGRP as u32; - others &= !S_IXOTH as u32; - } - if action.execute_dir { - user &= !S_IXUSR as u32; - group &= !S_IXGRP as u32; - others &= !S_IXOTH as u32; - } - if action.setuid { - user &= !S_ISUID as u32; - } - if action.sticky { - others &= !S_ISVTX as u32; + + if who_is_not_specified { + let umask = get_umask(); + + user &= !(rwx << 6) & !umask; + group &= !(rwx << 3) & !umask; + others &= !rwx & !umask; } } // set the mode bits ChmodActionOp::Set => { - if action.copy_user { - user = cur_mode & S_IRWXU as u32; - } else { - user = 0; - } - if action.copy_group { - group = cur_mode & S_IRWXG as u32; - } else { - group = 0; + // See the EXTENDED DESCRIPTION section of + // https://pubs.opengroup.org/onlinepubs/9699919799/utilities/chmod.html + // for the meaning of "permcopy" and "permlist" + + // The 3 permission bits to copy from "permcopy" + let copy_value = match (action.copy_user, action.copy_group, action.copy_others) + { + (true, false, false) => user >> 6, + (false, true, false) => group >> 3, + (false, false, true) => others, + (false, false, false) => { + // Either a "permlist" was specified or nothing is + 0 + } + _ => panic!( + "Only one of 'u', 'g', or 'o' can be used as the source for copying" + ), + }; + + // Should be at most 3 bits + debug_assert!(copy_value <= 0b111); + + if clause.user { + user = (copy_value | rwx) << 6; } - if action.copy_others { - others = cur_mode & S_IRWXO as u32; - } else { - others = 0; + if clause.group { + group = (copy_value | rwx) << 3; } - if action.read { - user |= S_IRUSR as u32; - group |= S_IRGRP as u32; - others |= S_IROTH as u32; + if clause.others { + others = copy_value | rwx; } - if action.write { - user |= S_IWUSR as u32; - group |= S_IWGRP as u32; - others |= S_IWOTH as u32; + + if who_is_not_specified { + let umask = get_umask(); + + user = (copy_value | rwx) << 6 & !umask; + group = (copy_value | rwx) << 3 & !umask; + others = (copy_value | rwx) & !umask; } - if action.execute { - user |= S_IXUSR as u32; - group |= S_IXGRP as u32; - others |= S_IXOTH as u32; + + // Always reset when "op" is "=" + special = 0; + } + } + + if action.setuid { + match action.op { + ChmodActionOp::Add | ChmodActionOp::Set => { + // If "who" is missing, set both `S_ISUID` and `S_ISGID` + if clause.user || who_is_not_specified { + special |= S_ISUID as u32; + } + if clause.group || who_is_not_specified { + special |= S_ISGID as u32; + } } - if action.execute_dir { - user |= S_IXUSR as u32; - group |= S_IXGRP as u32; - others |= S_IXOTH as u32; + ChmodActionOp::Remove => { + // If "who" is missing, remove both `S_ISUID` and `S_ISGID` + if clause.user || who_is_not_specified { + special &= !S_ISUID as u32; + } + if clause.group || who_is_not_specified { + special &= !S_ISGID as u32; + } } - if action.setuid { - user |= S_ISUID as u32; + } + } + + if action.sticky { + // Not affected by the umask + match action.op { + ChmodActionOp::Add | ChmodActionOp::Set => { + special |= S_ISVTX as u32; } - if action.sticky { - others |= S_ISVTX as u32; + ChmodActionOp::Remove => { + special &= !S_ISVTX as u32; } } } - } - // apply the clause - if clause.user { - new_mode = (new_mode & !S_IRWXU as u32) | user; - } - if clause.group { - new_mode = (new_mode & !S_IRWXG as u32) | group; - } - if clause.others { - new_mode = (new_mode & !S_IRWXO as u32) | others; + if action.execute_dir && (is_dir || has_any_exec_bits) { + let mask = if who_is_not_specified { get_umask() } else { 0 }; + + match action.op { + ChmodActionOp::Add | ChmodActionOp::Set => { + user |= S_IXUSR as u32 & !mask; + group |= S_IXGRP as u32 & !mask; + others |= S_IXOTH as u32 & !mask; + } + ChmodActionOp::Remove => { + user &= !S_IXUSR as u32 & !mask; + group &= !S_IXGRP as u32 & !mask; + others &= !S_IXOTH as u32 & !mask; + } + } + } } } - new_mode + user | group | others | special } #[cfg(test)] @@ -386,4 +441,139 @@ mod tests { _ => panic!("unexpected mode"), } } + + fn parse_symbolic(mode: &str) -> ChmodSymbolic { + match parse(mode).unwrap() { + ChmodMode::Symbolic(s) => s, + _ => panic!("Incorrect parsing result"), + } + } + + #[test] + fn test_mutate_mode_empty_who() { + // NOTE: Potential umask race condition here + let umask = unsafe { + let m = libc::umask(0); + libc::umask(m); + m as u32 + }; + + let mode = mutate(0, false, &parse_symbolic("=rwx")); + + assert_eq!(mode | umask, 0o777); + } + + // Clears all file mode bits + #[test] + fn test_mutate_mode_chmod_example_1() { + assert_eq!(mutate(0o777, false, &parse_symbolic("a+=")), 0); + assert_eq!(mutate(0o777, false, &parse_symbolic("a+,a=")), 0); + } + + // Clears group and other write bits + #[test] + fn test_mutate_mode_chmod_example_2() { + assert_eq!( + mutate(0b111_010_010, false, &parse_symbolic("go+-w")), + 0o700 + ); + assert_eq!( + mutate(0b111_010_010, false, &parse_symbolic("go+,go-w")), + 0o700 + ); + } + + // Sets group bit to match other bits and then clears group write bit + #[test] + fn test_mutate_mode_chmod_example_3() { + assert_eq!( + mutate(0o007, false, &parse_symbolic("g=o-w")), + 0b000_101_111 + ); + assert_eq!( + mutate(0o007, false, &parse_symbolic("g=o,g-w")), + 0b000_101_111 + ); + } + + // Clears group read bit and sets group write bit + #[test] + fn test_mutate_mode_chmod_example_4() { + assert_eq!( + mutate(0b000_100_000, false, &parse_symbolic("g-r+w")), + 0b000_010_000 + ); + assert_eq!( + mutate(0b000_100_000, false, &parse_symbolic("g-r,g+w")), + 0b000_010_000 + ); + } + + // Sets owner bits to match group bits and sets other bits to match group bits + #[test] + fn test_mutate_mode_chmod_example_5() { + assert_eq!(mutate(0o070, false, &parse_symbolic("uo=g")), 0o777); + } + + #[test] + fn test_mutate_mode_exec_dir() { + let plus_exec_dir = parse_symbolic("+X"); + + // Always apply X on directories + assert_eq!(mutate(0o444, true, &plus_exec_dir), 0o555); + + // Ignore X on non-directories not having any execute bits + assert_eq!( + mutate(0o444 /* a=rw */, false, &plus_exec_dir), + 0o444 /* Still a=rw */ + ); + + // Apply X when file has an execute bit + assert_eq!(mutate(0o544, false, &plus_exec_dir), 0o555); + assert_eq!(mutate(0o454, false, &plus_exec_dir), 0o555); + assert_eq!(mutate(0o445, false, &plus_exec_dir), 0o555); + assert_eq!(mutate(0o554, false, &plus_exec_dir), 0o555); + assert_eq!(mutate(0o545, false, &plus_exec_dir), 0o555); + assert_eq!(mutate(0o455, false, &plus_exec_dir), 0o555); + assert_eq!(mutate(0o555, false, &plus_exec_dir), 0o555); + + // =X should clear the read permission on user + assert_eq!(mutate(0o500, false, &parse_symbolic("=X")), 0o111); + // +X should retain the read permission on user + assert_eq!(mutate(0o500, false, &parse_symbolic("+X")), 0o511); + + // -X removes execute permission on everyone + assert_eq!(mutate(0o711, false, &parse_symbolic("-X")), 0o600); + + // Add execute permission on user then +X + assert_eq!(mutate(0o400, false, &parse_symbolic("u=x,+X")), 0o111); + } + + #[test] + fn test_mutate_mode_clear_set_copy_then_reset() { + assert_eq!( + mutate(0o111, false, &parse_symbolic("a=,u=rwx,g=u,u=")), + 0o070 + ); + assert_eq!( + mutate(0o111, false, &parse_symbolic("a=,u=rwx,o=u,u=")), + 0o007 + ); + assert_eq!( + mutate(0o111, false, &parse_symbolic("a=,g=rwx,u=g,g=")), + 0o700 + ); + assert_eq!( + mutate(0o111, false, &parse_symbolic("a=,g=rwx,o=g,g=")), + 0o007 + ); + assert_eq!( + mutate(0o111, false, &parse_symbolic("a=,o=rwx,u=o,o=")), + 0o700 + ); + assert_eq!( + mutate(0o111, false, &parse_symbolic("a=,o=rwx,g=o,o=")), + 0o070 + ); + } } diff --git a/tree/chmod.rs b/tree/chmod.rs index 45f943724..d9fa17a87 100644 --- a/tree/chmod.rs +++ b/tree/chmod.rs @@ -7,13 +7,14 @@ // SPDX-License-Identifier: MIT // +mod common; + +use self::common::error_string; use clap::Parser; -use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory}; -use modestr::{ChmodMode, ChmodSymbolic}; +use gettextrs::{bind_textdomain_codeset, gettext, setlocale, textdomain, LocaleCategory}; +use modestr::ChmodMode; use plib::modestr; -use std::os::unix::fs::PermissionsExt; -use std::path::Path; -use std::{fs, io}; +use std::{cell::RefCell, io, os::unix::fs::MetadataExt}; /// chmod - change the file modes #[derive(Parser)] @@ -24,54 +25,99 @@ struct Args { recurse: bool, /// Represents the change to be made to the file mode bits of each file named by one of the file operands. + #[arg(allow_hyphen_values = true)] // To allow passing `chmod -x f` mode: String, /// The files to change files: Vec, } -// apply symbolic mutations to the given file at path -fn set_permissions_symbolic(path: &Path, symbolic: &ChmodSymbolic) -> Result<(), io::Error> { - // query the current mode bits - let metadata = fs::metadata(path)?; - let mut perms = metadata.permissions(); - - // perform mutations on the mode bits - let new_mode = modestr::mutate(perms.mode(), symbolic); - - // update path in filesystem - perms.set_mode(new_mode); - fs::set_permissions(path, perms)?; - - Ok(()) -} - fn chmod_file(filename: &str, mode: &ChmodMode, recurse: bool) -> Result<(), io::Error> { - let path = Path::new(filename); - let metadata = fs::metadata(path)?; - - 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(); - chmod_file(entry_filename, mode, recurse)?; - } - } - - match mode { - // set the mode bits to the given value - ChmodMode::Absolute(m) => { - fs::set_permissions(path, fs::Permissions::from_mode(*m))?; - } - - // apply symbolic mutations to the mode bits - ChmodMode::Symbolic(s) => { - set_permissions_symbolic(path, s)?; - } - } - - Ok(()) + let terminate = RefCell::new(false); + let result = RefCell::new(Ok(())); // Either `Ok(())` or the last error encountered + + ftw::traverse_directory( + filename, + |entry| { + if *terminate.borrow() { + return Ok(false); + } + + let md = entry.metadata().unwrap(); + let is_dir = md.is_dir(); + + let new_mode = match mode { + ChmodMode::Absolute(m, num_digits) => { + // Done to match the behavior of coreutils chmod: + // "For directories chmod preserves set-user-ID and set-group-ID bits unless + // you explicitly specify otherwise" + // + // Odd quirk: 0755 would not clear the set-group-ID bit but 00755 would even + // though the set-group-ID is at the 4th position from the right + if is_dir && (*num_digits < 5) { + *m | (md.mode() & (libc::S_ISUID | libc::S_ISGID) as u32) + } else { + *m + } + } + ChmodMode::Symbolic(s) => modestr::mutate(md.mode(), is_dir, s), + }; + + if md.is_symlink() { + // Uses libc::fstatat to check for the validity of the symlink + let is_dangling = { + let target_deref_md = + ftw::Metadata::new(entry.dir_fd(), entry.file_name(), true); + target_deref_md.is_err() + }; + + if is_dangling { + let err_str = gettext!("cannot operate on dangling symlink '{}'", entry.path()); + *result.borrow_mut() = Err(io::Error::other(err_str)); + *terminate.borrow_mut() = true; + return Err(()); + } else { + // Symlink permissions are always lrwxrwxrwx + // fchmodat with AT_SYMLINK_NOFOLLOW on Linux would fail + if cfg!(target_os = "linux") { + return Ok(is_dir && recurse); + } + } + } + + let ret = unsafe { + libc::fchmodat( + entry.dir_fd(), + entry.file_name().as_ptr(), + new_mode as libc::mode_t, // Cast for macOS + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + + if ret != 0 { + let e = io::Error::last_os_error(); + + *result.borrow_mut() = Err(e); + *terminate.borrow_mut() = true; + return Err(()); + } + + Ok(is_dir && recurse) + }, + |_| Ok(()), // No-op + |entry, error| { + let e = error.inner(); + let err_str = gettext!("cannot access '{}': {}", entry.path(), error_string(&e)); + *result.borrow_mut() = Err(io::Error::other(err_str)); + *terminate.borrow_mut() = true; + }, + ftw::TraverseDirectoryOpts { + follow_symlinks_on_args: true, // Default behavior of coreutils chmod with or without -R + ..Default::default() + }, + ); + + result.into_inner() } fn main() -> Result<(), Box> { @@ -84,13 +130,16 @@ fn main() -> Result<(), Box> { let mut exit_code = 0; // parse the mode string - let mode = modestr::parse(&args.mode)?; + let Ok(mode) = modestr::parse(&args.mode) else { + eprintln!("chmod: {}", gettext!("invalid mode: '{}'", args.mode)); + std::process::exit(1); + }; // apply the mode to each file for filename in &args.files { if let Err(e) = chmod_file(filename, &mode, args.recurse) { exit_code = 1; - eprintln!("{}: {}", filename, e); + eprintln!("chmod: {}", error_string(&e)); } } diff --git a/tree/mkdir.rs b/tree/mkdir.rs index 93ff8c47e..312069c5a 100644 --- a/tree/mkdir.rs +++ b/tree/mkdir.rs @@ -45,8 +45,8 @@ fn create_dir_with_mode(path: &str, mode: u32) -> io::Result<()> { fn do_mkdir(dirname: &str, mode: &ChmodMode, parents: bool) -> io::Result<()> { let mode_val = match mode { - ChmodMode::Absolute(mode) => *mode, - ChmodMode::Symbolic(sym) => modestr::mutate(0o777, sym), + ChmodMode::Absolute(mode, _) => *mode, + ChmodMode::Symbolic(sym) => modestr::mutate(0o777, true, sym), }; if parents { @@ -77,7 +77,7 @@ fn main() -> Result<(), Box> { // parse the mode string let mode = match args.mode { Some(mode) => modestr::parse(&mode)?, - None => ChmodMode::Absolute(0o777), + None => ChmodMode::Absolute(0o777, 3), }; // apply the mode to each file diff --git a/tree/mkfifo.rs b/tree/mkfifo.rs index d9c49c625..86bc7e89f 100644 --- a/tree/mkfifo.rs +++ b/tree/mkfifo.rs @@ -27,8 +27,8 @@ struct Args { fn do_mkfifo(filename: &str, mode: &ChmodMode) -> io::Result<()> { let mode_val = match mode { - ChmodMode::Absolute(mode) => *mode, - ChmodMode::Symbolic(sym) => modestr::mutate(0o666, sym), + ChmodMode::Absolute(mode, _) => *mode, + ChmodMode::Symbolic(sym) => modestr::mutate(0o666, false, sym), }; let res = unsafe { libc::mkfifo(filename.as_ptr() as *const i8, mode_val as libc::mode_t) }; @@ -51,7 +51,7 @@ fn main() -> Result<(), Box> { // parse the mode string let mode = match args.mode { Some(mode) => modestr::parse(&mode)?, - None => ChmodMode::Absolute(0o666), + None => ChmodMode::Absolute(0o666, 3), }; // apply the mode to each file diff --git a/tree/tests/chmod/mod.rs b/tree/tests/chmod/mod.rs new file mode 100644 index 000000000..7b02b6b00 --- /dev/null +++ b/tree/tests/chmod/mod.rs @@ -0,0 +1,187 @@ +// +// 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::{ + fs, + os::unix::{self, fs::PermissionsExt}, +}; + +fn chmod_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("chmod"), + args: str_args, + stdin_data: String::new(), + expected_out: String::from(expected_output), + expected_err: String::from(expected_error), + expected_exit_code, + }); +} + +// Port of coreutils/tests/chmod/ignore-symlink.sh +#[test] +fn test_chmod_ignore_symlink() { + let test_dir = &format!("{}/test_chmod_ignore_symlink", env!("CARGO_TARGET_TMPDIR")); + let f = &format!("{test_dir}/f"); + let l = &format!("{test_dir}/l"); + + fs::create_dir(test_dir).unwrap(); + fs::File::create(f).unwrap(); + unix::fs::symlink(f, l).unwrap(); + + chmod_test(&["u+w", "-R", test_dir], "", "", 0); + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Port of coreutils/tests/chmod/inaccessible.sh +#[test] +fn test_chmod_inaccessible() { + let test_dir = &format!("{}/test_chmod_inaccessible", env!("CARGO_TARGET_TMPDIR")); + let d = &format!("{test_dir}/d"); + let d_e = &format!("{test_dir}/d/e"); + + fs::create_dir(test_dir).unwrap(); + fs::create_dir_all(d_e).unwrap(); + + chmod_test(&["0", d_e, d], "", "", 0); + chmod_test(&["u+rwx", d, d_e], "", "", 0); + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Port of coreutils/tests/chmod/no-x.sh +#[test] +fn test_chmod_no_x() { + let test_dir = &format!("{}/test_chmod_no_x", env!("CARGO_TARGET_TMPDIR")); + let a = &format!("{test_dir}/a"); + let a_b = &format!("{test_dir}/a/b"); + let d = &format!("{test_dir}/d"); + let d_no_x_y = &format!("{test_dir}/d/no-x/y"); + + fs::create_dir(test_dir).unwrap(); + fs::create_dir_all(a_b).unwrap(); + fs::create_dir_all(d_no_x_y).unwrap(); + + chmod_test(&["u=rw", d_no_x_y], "", "", 0); + chmod_test( + &["-R", "o=r", d], + "", + &format!("chmod: cannot access '{d_no_x_y}': Permission denied\n"), + 1, + ); + + chmod_test( + &["a-x", a, a_b], + "", + &format!("chmod: cannot access '{a_b}': Permission denied\n"), + 1, + ); + + // Reset permission so it can be deleted + fs::set_permissions(a, fs::Permissions::from_mode(0o777)).unwrap(); + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Port of coreutils/tests/chmod/octal.sh +#[test] +fn test_chmod_octal() { + let test_dir = &format!("{}/test_chmod_octal", env!("CARGO_TARGET_TMPDIR")); + + fs::create_dir(test_dir).unwrap(); + + for perm in ["0-anything", "7-anything", "8"] { + chmod_test( + &[perm, test_dir], + "", + &format!("chmod: invalid mode: '{perm}'\n"), + 1, + ); + } + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Partial port of coreutils/tests/chmod/symlinks.sh +// +// Excluding non-standard options --L, -P, --no-dereference and --dereference +#[test] +fn test_chmod_symlinks() { + let test_dir = &format!("{}/test_chmod_symlinks", env!("CARGO_TARGET_TMPDIR")); + let a_b = &format!("{test_dir}/a/b"); + let a_dangle = &format!("{test_dir}/a/dangle"); + let a_dirlink = &format!("{test_dir}/a/dirlink"); + let a_b_file = &format!("{test_dir}/a/b/file"); + let a_c = &format!("{test_dir}/a/c"); + let a_c_file = &format!("{test_dir}/a/c/file"); + let a_c_link = &format!("{test_dir}/a/c/link"); + + fs::create_dir(test_dir).unwrap(); + + fs::create_dir_all(a_b).unwrap(); + fs::create_dir_all(a_c).unwrap(); + fs::File::create(a_b_file).unwrap(); + fs::File::create(a_c_file).unwrap(); + + unix::fs::symlink("foo", a_dangle).unwrap(); + unix::fs::symlink("../b/file", a_c_link).unwrap(); + unix::fs::symlink("b", a_dirlink).unwrap(); + + let reset_modes = || { + chmod_test(&["777", a_b, a_c, a_b_file, a_c_file], "", "", 0); + }; + + fn count_755(files: &[&str], expected: usize) { + let count = files + .into_iter() + .filter_map(|file| { + let mode = fs::metadata(file).unwrap().permissions().mode(); + if (mode & 0o777) == 0o755 { + Some(file) + } else { + None + } + }) + .count(); + assert_eq!(count, expected) + } + + reset_modes(); + + chmod_test(&["755", "-R", a_c], "", "", 0); + + count_755(&[a_c, a_c_file, a_b_file], 2); + + reset_modes(); + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Port of coreutils/tests/chmod/thru-dangling.sh +#[test] +fn test_chmod_thru_dangling() { + let test_dir = &format!("{}/test_chmod_thru_dangling", env!("CARGO_TARGET_TMPDIR")); + let non_existent = &format!("{test_dir}/non-existent"); + let dangle = &format!("{test_dir}/dangle"); + + fs::create_dir(test_dir).unwrap(); + unix::fs::symlink(non_existent, dangle).unwrap(); + + chmod_test( + &["644", dangle], + "", + &format!("chmod: cannot operate on dangling symlink '{dangle}'\n"), + 1, + ); + + fs::remove_dir_all(test_dir).unwrap(); +} diff --git a/tree/tests/integration2.rs b/tree/tests/tree-tests-umask.rs similarity index 60% rename from tree/tests/integration2.rs rename to tree/tests/tree-tests-umask.rs index 54b3a0b0a..f5bd4c331 100644 --- a/tree/tests/integration2.rs +++ b/tree/tests/tree-tests-umask.rs @@ -7,15 +7,17 @@ // SPDX-License-Identifier: MIT // -//! The use of `libc::umask` in these tests interferes with the permissions of -//! newly created files in `integration.rs`. They are located here in -//! `integration2.rs` because umask is a per-process. +//! The use of `libc::umask` in these tests interferes with the permissions of newly created files +//! in `tree-tests.rs`. They are located here in `tree-tests-umask.rs` so that they can have a +//! different per-process umask than those in `tree-tests.rs`. use plib::testing::{run_test, TestPlan}; -use std::fs; -use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; -use std::path::Path; -use std::sync::Mutex; +use std::{ + fs, + os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}, + path::Path, + sync::Mutex, +}; static UMASK_SETTER: Mutex = Mutex::new(UmaskSetter); @@ -34,11 +36,17 @@ impl UmaskSetter { } } -fn cp_test(args: &[&str], expected_output: &str, expected_error: &str, expected_exit_code: i32) { +fn base_test( + cmd: &str, + 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("cp"), + cmd: String::from(cmd), args: str_args, stdin_data: String::new(), expected_out: String::from(expected_output), @@ -47,17 +55,34 @@ fn cp_test(args: &[&str], expected_output: &str, expected_error: &str, expected_ }); } +fn cp_test(args: &[&str], expected_output: &str, expected_error: &str, expected_exit_code: i32) { + base_test( + "cp", + args, + expected_output, + expected_error, + expected_exit_code, + ) +} + fn rm_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(); + base_test( + "rm", + args, + expected_output, + expected_error, + expected_exit_code, + ) +} - run_test(TestPlan { - cmd: String::from("rm"), - args: str_args, - stdin_data: String::new(), - expected_out: String::from(expected_output), - expected_err: String::from(expected_error), +fn chmod_test(args: &[&str], expected_output: &str, expected_error: &str, expected_exit_code: i32) { + base_test( + "chmod", + args, + expected_output, + expected_error, expected_exit_code, - }); + ) } // Port of coreutils/tests/cp/existing-perm-dir.sh @@ -195,3 +220,109 @@ fn test_rm_deep_1() { umask_setter.umask(original_umask); fs::remove_dir_all(test_dir).unwrap(); } + +// Port of coreutils/tests/chmod/equals.sh +#[test] +fn test_chmod_equals() { + let test_dir = &format!("{}/test_chmod_equals", env!("CARGO_TARGET_TMPDIR")); + let f = &format!("{test_dir}/f"); + + fs::create_dir(test_dir).unwrap(); + fs::File::create(f).unwrap(); + + let categories = ["u", "g", "o"]; + let expected_perms = [0o700, 0o070, 0o007]; + + for src in categories { + for (dest, expected) in categories.iter().copied().zip(expected_perms) { + if src != dest { + let opts = format!("a=,{src}=rwx,{dest}={src},{src}="); + chmod_test(&[&opts, f], "", "", 0); + + let perm = fs::metadata(f).unwrap().permissions(); + let actual = perm.mode() & !(libc::S_IFMT as u32); // Mask out the file type + assert_eq!(actual, expected); + } + } + } + + let umask_setter = UMASK_SETTER.lock().unwrap(); + let original_umask = umask_setter.umask(0o27); + + chmod_test(&["a=,u=rwx,=u", f], "", "", 0); + + let perm = fs::metadata(f).unwrap().permissions(); + let actual = perm.mode() & !(libc::S_IFMT as u32); + assert_eq!(actual, 0o750); + + // Revert umask + umask_setter.umask(original_umask); + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Port of coreutils/tests/chmod/equal-x.sh +#[test] +fn test_chmod_equal_x() { + let test_dir = &format!("{}/test_chmod_equal_x", env!("CARGO_TARGET_TMPDIR")); + let f = &format!("{test_dir}/f"); + + fs::create_dir(test_dir).unwrap(); + fs::File::create(f).unwrap(); + + let umask_setter = UMASK_SETTER.lock().unwrap(); + let original_umask = umask_setter.umask(0o005); + + let expected_mode = 0o110; + + for mode in ["=x", "=xX", "=Xx", "=x,=X", "=X,=x"] { + let opt = format!("a=r,{mode}"); + chmod_test(&[&opt, f], "", "", 0); + + let permissions = fs::metadata(f).unwrap().permissions(); + assert_eq!(permissions.mode() & 0o777, expected_mode,); + } + + // Revert umask + umask_setter.umask(original_umask); + + fs::remove_dir_all(test_dir).unwrap(); +} + +// Partial port of coreutils/tests/chmod/setgid.sh +// +// Mixing of symbolic and octal mode operands are not supported in the standard: +// "For an octal integer mode operand, the file mode bits shall be set absolutely." +#[test] +fn test_chmod_setgid() { + let test_dir = &format!("{}/test_chmod_setgid", env!("CARGO_TARGET_TMPDIR")); + let d = &format!("{test_dir}/d"); + + fs::create_dir(test_dir).unwrap(); + + let umask_setter = UMASK_SETTER.lock().unwrap(); + let original_umask = umask_setter.umask(0); + + fs::DirBuilder::new().mode(0o755).create(d).unwrap(); + + chmod_test(&["g+s", d], "", "", 0); + + for mode in ["+", "-", "g-s", "00755", "000755", "755", "0755"] { + chmod_test(&[mode, d], "", "", 0); + + let expected_mode = match mode { + "g-s" | "00755" | "000755" => 0o755, + _ => 0o2755, + }; + + let permissions = fs::metadata(d).unwrap().permissions(); + + assert_eq!(permissions.mode() & 0o7777, expected_mode, "mode: {mode}"); + + chmod_test(&["2755", d], "", "", 0); + } + + umask_setter.umask(original_umask); + + fs::remove_dir_all(test_dir).unwrap(); +} diff --git a/tree/tests/tree-tests.rs b/tree/tests/tree-tests.rs index 0e94c28bd..85277e1b4 100644 --- a/tree/tests/tree-tests.rs +++ b/tree/tests/tree-tests.rs @@ -8,6 +8,7 @@ // mod chgrp; +mod chmod; mod cp; mod link; mod ls;