diff --git a/plib/src/modestr.rs b/plib/src/modestr.rs
index 9efe1ed1..7b125055 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<ChmodMode, String> {
     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<ChmodMode, String> {
 }
 
 // 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 45f94372..d9fa17a8 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<String>,
 }
 
-// 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<dyn std::error::Error>> {
@@ -84,13 +130,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
     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 93ff8c47..312069c5 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<dyn std::error::Error>> {
     // 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 d9c49c62..86bc7e89 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<dyn std::error::Error>> {
     // 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 00000000..7b02b6b0
--- /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<String> = 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 54b3a0b0..f5bd4c33 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<UmaskSetter> = 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<String> = 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<String> = 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 0e94c28b..85277e1b 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;