Skip to main content

uu_chmod/
chmod.rs

1// This file is part of the uutils coreutils package.
2//
3// For the full copyright and license information, please view the LICENSE
4// file that was distributed with this source code.
5
6// spell-checker:ignore (ToDO) Chmoder cmode fmode fperm fref ugoa RFILE RFILE's
7
8use clap::{Arg, ArgAction, Command};
9use std::ffi::OsString;
10use std::fs;
11use std::os::unix::fs::{MetadataExt, PermissionsExt};
12use std::path::{Path, PathBuf};
13use thiserror::Error;
14use uucore::display::Quotable;
15use uucore::error::{ExitCode, UError, UResult, USimpleError, UUsageError, set_exit_code};
16use uucore::fs::display_permissions_unix;
17use uucore::libc::mode_t;
18use uucore::mode;
19use uucore::perms::{TraverseSymlinks, configure_symlink_and_recursion};
20
21#[cfg(all(unix, not(target_os = "redox")))]
22use uucore::safe_traversal::{DirFd, SymlinkBehavior};
23use uucore::{format_usage, show, show_error};
24
25use uucore::translate;
26
27#[derive(Debug, Error)]
28enum ChmodError {
29    #[error("{}", translate!("chmod-error-cannot-stat", "file" => _0.quote()))]
30    CannotStat(PathBuf),
31    #[error("{}", translate!("chmod-error-dangling-symlink", "file" => _0.quote()))]
32    DanglingSymlink(PathBuf),
33    #[error("{}", translate!("chmod-error-no-such-file", "file" => _0.quote()))]
34    NoSuchFile(PathBuf),
35    #[error("{}", translate!("chmod-error-preserve-root", "file" => _0.quote()))]
36    PreserveRoot(PathBuf),
37    #[error("{}", translate!("chmod-error-permission-denied", "file" => _0.quote()))]
38    PermissionDenied(PathBuf),
39    #[error("{}", translate!("chmod-error-new-permissions", "file" => _0.maybe_quote(), "actual" => _1.clone(), "expected" => _2.clone()))]
40    NewPermissions(PathBuf, String, String),
41}
42
43impl UError for ChmodError {}
44
45mod options {
46    pub const HELP: &str = "help";
47    pub const CHANGES: &str = "changes";
48    pub const QUIET: &str = "quiet"; // visible_alias("silent")
49    pub const VERBOSE: &str = "verbose";
50    pub const NO_PRESERVE_ROOT: &str = "no-preserve-root";
51    pub const PRESERVE_ROOT: &str = "preserve-root";
52    pub const REFERENCE: &str = "RFILE";
53    pub const RECURSIVE: &str = "recursive";
54    pub const MODE: &str = "MODE";
55    pub const FILE: &str = "FILE";
56}
57
58/// Extract negative modes (starting with '-') from the rest of the arguments.
59///
60/// This is mainly required for GNU compatibility, where "non-positional negative" modes are used
61/// as the actual positional MODE. Some examples of these cases are:
62/// * "chmod -w -r file", which is the same as "chmod -w,-r file"
63/// * "chmod -w file -r", which is the same as "chmod -w,-r file"
64///
65/// These can currently not be handled by clap.
66/// Therefore it might be possible that a pseudo MODE is inserted to pass clap parsing.
67/// The pseudo MODE is later replaced by the extracted (and joined) negative modes.
68fn extract_negative_modes(mut args: impl uucore::Args) -> (Option<String>, Vec<OsString>) {
69    // we look up the args until "--" is found
70    // "-mode" will be extracted into parsed_cmode_vec
71    let (parsed_cmode_vec, pre_double_hyphen_args): (Vec<OsString>, Vec<OsString>) =
72        args.by_ref().take_while(|a| a != "--").partition(|arg| {
73            let arg = if let Some(arg) = arg.to_str() {
74                arg.to_string()
75            } else {
76                return false;
77            };
78            arg.len() >= 2
79                && arg.starts_with('-')
80                && matches!(
81                    arg.chars().nth(1).unwrap(),
82                    'r' | 'w' | 'x' | 'X' | 's' | 't' | 'u' | 'g' | 'o' | '0'..='7'
83                )
84        });
85
86    let mut clean_args = Vec::new();
87    if !parsed_cmode_vec.is_empty() {
88        // we need a pseudo cmode for clap, which won't be used later.
89        // this is required because clap needs the default "chmod MODE FILE" scheme.
90        clean_args.push("w".into());
91    }
92    clean_args.extend(pre_double_hyphen_args);
93
94    if let Some(arg) = args.next() {
95        // as there is still something left in the iterator, we previously consumed the "--"
96        // -> add it to the args again
97        clean_args.push("--".into());
98        clean_args.push(arg);
99    }
100    clean_args.extend(args);
101
102    let parsed_cmode = Some(
103        parsed_cmode_vec
104            .iter()
105            .map(|s| s.to_str().unwrap())
106            .collect::<Vec<&str>>()
107            .join(","),
108    )
109    .filter(|s| !s.is_empty());
110    (parsed_cmode, clean_args)
111}
112
113#[uucore::main]
114pub fn uumain(args: impl uucore::Args) -> UResult<()> {
115    let (parsed_cmode, args) = extract_negative_modes(args.skip(1)); // skip binary name
116    let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?;
117
118    let changes = matches.get_flag(options::CHANGES);
119    let quiet = matches.get_flag(options::QUIET);
120    let verbose = matches.get_flag(options::VERBOSE);
121    let preserve_root = matches.get_flag(options::PRESERVE_ROOT);
122    let fmode = match matches.get_one::<OsString>(options::REFERENCE) {
123        Some(fref) => match fs::metadata(fref) {
124            Ok(meta) => Some(meta.mode() & 0o7777),
125            Err(_) => {
126                return Err(ChmodError::CannotStat(fref.into()).into());
127            }
128        },
129        None => None,
130    };
131
132    let modes = matches.get_one::<String>(options::MODE);
133    let cmode = if let Some(parsed_cmode) = parsed_cmode {
134        parsed_cmode
135    } else {
136        modes.unwrap().to_owned() // modes is required
137    };
138    let mut files: Vec<OsString> = matches
139        .get_many::<OsString>(options::FILE)
140        .map(|v| v.cloned().collect())
141        .unwrap_or_default();
142    let cmode = if fmode.is_some() {
143        // "--reference" and MODE are mutually exclusive
144        // if "--reference" was used MODE needs to be interpreted as another FILE
145        // it wasn't possible to implement this behavior directly with clap
146        files.push(OsString::from(cmode));
147        None
148    } else {
149        Some(cmode)
150    };
151
152    if files.is_empty() {
153        return Err(UUsageError::new(
154            1,
155            translate!("chmod-error-missing-operand"),
156        ));
157    }
158
159    let (recursive, dereference, traverse_symlinks) =
160        configure_symlink_and_recursion(&matches, TraverseSymlinks::First)?;
161
162    let chmoder = Chmoder {
163        changes,
164        quiet,
165        verbose,
166        preserve_root,
167        recursive,
168        fmode,
169        cmode,
170        traverse_symlinks,
171        dereference,
172    };
173
174    chmoder.chmod(&files)
175}
176
177pub fn uu_app() -> Command {
178    Command::new(uucore::util_name())
179        .version(uucore::crate_version!())
180        .about(translate!("chmod-about"))
181        .override_usage(format_usage(&translate!("chmod-usage")))
182        .help_template(uucore::localized_help_template(uucore::util_name()))
183        .args_override_self(true)
184        .infer_long_args(true)
185        .no_binary_name(true)
186        .disable_help_flag(true)
187        .after_help(translate!("chmod-after-help"))
188        .arg(
189            Arg::new(options::HELP)
190                .long(options::HELP)
191                .help(translate!("chmod-help-print-help"))
192                .action(ArgAction::Help),
193        )
194        .arg(
195            Arg::new(options::CHANGES)
196                .long(options::CHANGES)
197                .short('c')
198                .help(translate!("chmod-help-changes"))
199                .action(ArgAction::SetTrue),
200        )
201        .arg(
202            Arg::new(options::QUIET)
203                .long(options::QUIET)
204                .visible_alias("silent")
205                .short('f')
206                .help(translate!("chmod-help-quiet"))
207                .action(ArgAction::SetTrue),
208        )
209        .arg(
210            Arg::new(options::VERBOSE)
211                .long(options::VERBOSE)
212                .short('v')
213                .help(translate!("chmod-help-verbose"))
214                .action(ArgAction::SetTrue),
215        )
216        .arg(
217            Arg::new(options::NO_PRESERVE_ROOT)
218                .long(options::NO_PRESERVE_ROOT)
219                .help(translate!("chmod-help-no-preserve-root"))
220                .action(ArgAction::SetTrue),
221        )
222        .arg(
223            Arg::new(options::PRESERVE_ROOT)
224                .long(options::PRESERVE_ROOT)
225                .help(translate!("chmod-help-preserve-root"))
226                .action(ArgAction::SetTrue),
227        )
228        .arg(
229            Arg::new(options::RECURSIVE)
230                .long(options::RECURSIVE)
231                .short('R')
232                .help(translate!("chmod-help-recursive"))
233                .action(ArgAction::SetTrue),
234        )
235        .arg(
236            Arg::new(options::REFERENCE)
237                .long("reference")
238                .value_hint(clap::ValueHint::FilePath)
239                .value_parser(clap::value_parser!(OsString))
240                .help(translate!("chmod-help-reference")),
241        )
242        .arg(
243            Arg::new(options::MODE).required_unless_present(options::REFERENCE),
244            // It would be nice if clap could parse with delimiter, e.g. "g-x,u+x",
245            // however .multiple_occurrences(true) cannot be used here because FILE already needs that.
246            // Only one positional argument with .multiple_occurrences(true) set is allowed per command
247        )
248        .arg(
249            Arg::new(options::FILE)
250                .required_unless_present(options::MODE)
251                .action(ArgAction::Append)
252                .value_hint(clap::ValueHint::AnyPath)
253                .value_parser(clap::value_parser!(OsString)),
254        )
255        // Add common arguments with chgrp, chown & chmod
256        .args(uucore::perms::common_args())
257}
258
259struct Chmoder {
260    changes: bool,
261    quiet: bool,
262    verbose: bool,
263    preserve_root: bool,
264    recursive: bool,
265    fmode: Option<u32>,
266    cmode: Option<String>,
267    traverse_symlinks: TraverseSymlinks,
268    dereference: bool,
269}
270
271impl Chmoder {
272    /// Calculate the new mode based on the current mode and the chmod specification.
273    /// Returns (`new_mode`, `naively_expected_new_mode`) for symbolic modes, or (`new_mode`, `new_mode`) for numeric/reference modes.
274    fn calculate_new_mode(&self, current_mode: u32, is_dir: bool) -> UResult<(u32, u32)> {
275        if let Some(mode) = self.fmode {
276            Ok((mode, mode))
277        } else {
278            let cmode_unwrapped = self.cmode.clone().unwrap();
279            let mut new_mode = current_mode;
280            let mut naively_expected_new_mode = current_mode;
281
282            for mode in cmode_unwrapped.split(',') {
283                let result = if mode.chars().any(|c| c.is_ascii_digit()) {
284                    mode::parse_numeric(new_mode, mode, is_dir).map(|v| (v, v))
285                } else {
286                    mode::parse_symbolic(new_mode, mode, mode::get_umask(), is_dir).map(|m| {
287                        // calculate the new mode as if umask was 0
288                        let naive_mode =
289                            mode::parse_symbolic(naively_expected_new_mode, mode, 0, is_dir)
290                                .unwrap(); // we know that mode must be valid, so this cannot fail
291                        (m, naive_mode)
292                    })
293                };
294
295                match result {
296                    Ok((mode, naive_mode)) => {
297                        new_mode = mode;
298                        naively_expected_new_mode = naive_mode;
299                    }
300                    Err(f) => {
301                        return if self.quiet {
302                            Err(ExitCode::new(1))
303                        } else {
304                            Err(USimpleError::new(1, f))
305                        };
306                    }
307                }
308            }
309            Ok((new_mode, naively_expected_new_mode))
310        }
311    }
312
313    /// Report permission changes based on verbose and changes flags
314    fn report_permission_change(&self, file_path: &Path, old_mode: u32, new_mode: u32) {
315        if self.verbose || self.changes {
316            let current_permissions = display_permissions_unix(old_mode as mode_t, false);
317            let new_permissions = display_permissions_unix(new_mode as mode_t, false);
318
319            if new_mode != old_mode {
320                println!(
321                    "mode of {} changed from {old_mode:04o} ({current_permissions}) to {new_mode:04o} ({new_permissions})",
322                    file_path.quote(),
323                );
324            } else if self.verbose {
325                println!(
326                    "mode of {} retained as {old_mode:04o} ({current_permissions})",
327                    file_path.quote(),
328                );
329            }
330        }
331    }
332
333    /// Handle symlinks during directory traversal based on traversal mode
334    #[cfg(not(unix))]
335    fn handle_symlink_during_traversal(
336        &self,
337        path: &Path,
338        is_command_line_arg: bool,
339    ) -> UResult<()> {
340        let should_follow_symlink = match self.traverse_symlinks {
341            TraverseSymlinks::All => true,
342            TraverseSymlinks::First => is_command_line_arg,
343            TraverseSymlinks::None => false,
344        };
345
346        if !should_follow_symlink {
347            return self.chmod_file_internal(path, false);
348        }
349
350        match fs::metadata(path) {
351            Ok(meta) if meta.is_dir() => self.walk_dir_with_context(path, false),
352            Ok(_) => {
353                // It's a file symlink, chmod it
354                self.chmod_file(path)
355            }
356            Err(_) => {
357                // Dangling symlink, chmod it without dereferencing
358                self.chmod_file_internal(path, false)
359            }
360        }
361    }
362
363    fn chmod(&self, files: &[OsString]) -> UResult<()> {
364        let mut r = Ok(());
365
366        for filename in files {
367            let file = Path::new(filename);
368            if !file.exists() {
369                if file.is_symlink() {
370                    if !self.dereference && !self.recursive {
371                        // The file is a symlink and we should not follow it
372                        // Don't try to change the mode of the symlink itself
373                        continue;
374                    }
375                    if self.recursive && self.traverse_symlinks == TraverseSymlinks::None {
376                        continue;
377                    }
378
379                    if !self.quiet {
380                        show!(ChmodError::DanglingSymlink(filename.into()));
381                        set_exit_code(1);
382                    }
383
384                    if self.verbose {
385                        println!(
386                            "{}",
387                            translate!("chmod-verbose-failed-dangling", "file" => filename.quote())
388                        );
389                    }
390                } else if !self.quiet {
391                    show!(ChmodError::NoSuchFile(filename.into()));
392                }
393                // GNU exits with exit code 1 even if -q or --quiet are passed
394                // So we set the exit code, because it hasn't been set yet if `self.quiet` is true.
395                set_exit_code(1);
396                continue;
397            } else if !self.dereference && file.is_symlink() {
398                // The file is a symlink and we should not follow it
399                // chmod 755 --no-dereference a/link
400                // should not change the permissions in this case
401                continue;
402            }
403            if self.recursive && self.preserve_root && Self::is_root(file) {
404                return Err(ChmodError::PreserveRoot("/".into()).into());
405            }
406            if self.recursive {
407                r = self.walk_dir_with_context(file, true).and(r);
408            } else {
409                r = self.chmod_file(file).and(r);
410            }
411        }
412        r
413    }
414
415    fn is_root(file: impl AsRef<Path>) -> bool {
416        matches!(fs::canonicalize(&file), Ok(p) if p == Path::new("/"))
417    }
418
419    // Non-safe traversal implementation for platforms without safe_traversal support
420    #[cfg(any(not(unix), target_os = "redox"))]
421    fn walk_dir_with_context(&self, file_path: &Path, is_command_line_arg: bool) -> UResult<()> {
422        let mut r = self.chmod_file(file_path);
423
424        // Determine whether to traverse symlinks based on context and traversal mode
425        let should_follow_symlink = match self.traverse_symlinks {
426            TraverseSymlinks::All => true,
427            TraverseSymlinks::First => is_command_line_arg, // Only follow symlinks that are command line args
428            TraverseSymlinks::None => false,
429        };
430
431        // If the path is a directory (or we should follow symlinks), recurse into it
432        if (!file_path.is_symlink() || should_follow_symlink) && file_path.is_dir() {
433            // We buffer all paths in this dir to not keep too many fd's open during recursion
434            let mut paths_in_this_dir = Vec::new();
435
436            for dir_entry in file_path.read_dir()? {
437                match dir_entry {
438                    Ok(entry) => paths_in_this_dir.push(entry.path()),
439                    Err(err) => {
440                        r = r.and(Err(err.into()));
441                        continue;
442                    }
443                }
444            }
445            for path in paths_in_this_dir {
446                #[cfg(not(unix))]
447                {
448                    if path.is_symlink() {
449                        r = self.handle_symlink_during_recursion(&path).and(r);
450                    } else {
451                        r = self.walk_dir_with_context(path.as_path(), false).and(r);
452                    }
453                }
454                #[cfg(target_os = "redox")]
455                {
456                    r = self.walk_dir_with_context(path.as_path(), false).and(r);
457                }
458            }
459        }
460        r
461    }
462
463    #[cfg(all(unix, not(target_os = "redox")))]
464    fn walk_dir_with_context(&self, file_path: &Path, is_command_line_arg: bool) -> UResult<()> {
465        let mut r = self.chmod_file(file_path);
466
467        // Determine whether to traverse symlinks based on context and traversal mode
468        let should_follow_symlink = match self.traverse_symlinks {
469            TraverseSymlinks::All => true,
470            TraverseSymlinks::First => is_command_line_arg, // Only follow symlinks that are command line args
471            TraverseSymlinks::None => false,
472        };
473
474        // If the path is a directory (or we should follow symlinks), recurse into it using safe traversal
475        if (!file_path.is_symlink() || should_follow_symlink) && file_path.is_dir() {
476            match DirFd::open(file_path, SymlinkBehavior::Follow) {
477                Ok(dir_fd) => {
478                    r = self.safe_traverse_dir(&dir_fd, file_path).and(r);
479                }
480                Err(err) => {
481                    // Handle permission denied errors with proper file path context
482                    if err.kind() == std::io::ErrorKind::PermissionDenied {
483                        r = r.and(Err(ChmodError::PermissionDenied(file_path.into()).into()));
484                    } else {
485                        r = r.and(Err(err.into()));
486                    }
487                }
488            }
489        }
490        r
491    }
492
493    #[cfg(all(unix, not(target_os = "redox")))]
494    fn safe_traverse_dir(&self, dir_fd: &DirFd, dir_path: &Path) -> UResult<()> {
495        let mut r = Ok(());
496
497        let entries = dir_fd.read_dir()?;
498
499        // Determine if we should follow symlinks (doesn't depend on entry_name)
500        let should_follow_symlink = self.traverse_symlinks == TraverseSymlinks::All;
501
502        for entry_name in entries {
503            let entry_path = dir_path.join(&entry_name);
504
505            let dir_meta = dir_fd.metadata_at(&entry_name, should_follow_symlink.into());
506            let Ok(meta) = dir_meta else {
507                // Handle permission denied with proper file path context
508                let e = dir_meta.unwrap_err();
509                let error = if e.kind() == std::io::ErrorKind::PermissionDenied {
510                    ChmodError::PermissionDenied(entry_path).into()
511                } else {
512                    e.into()
513                };
514                r = r.and(Err(error));
515                continue;
516            };
517
518            if entry_path.is_symlink() {
519                r = self
520                    .handle_symlink_during_safe_recursion(&entry_path, dir_fd, &entry_name)
521                    .and(r);
522            } else {
523                // For regular files and directories, chmod them
524                r = self
525                    .safe_chmod_file(&entry_path, dir_fd, &entry_name, meta.mode() & 0o7777)
526                    .and(r);
527
528                // Recurse into subdirectories using the existing directory fd
529                if meta.is_dir() {
530                    match dir_fd.open_subdir(&entry_name, SymlinkBehavior::Follow) {
531                        Ok(child_dir_fd) => {
532                            r = self.safe_traverse_dir(&child_dir_fd, &entry_path).and(r);
533                        }
534                        Err(err) => {
535                            let error = if err.kind() == std::io::ErrorKind::PermissionDenied {
536                                ChmodError::PermissionDenied(entry_path).into()
537                            } else {
538                                err.into()
539                            };
540                            r = r.and(Err(error));
541                        }
542                    }
543                }
544            }
545        }
546        r
547    }
548
549    #[cfg(all(unix, not(target_os = "redox")))]
550    fn handle_symlink_during_safe_recursion(
551        &self,
552        path: &Path,
553        dir_fd: &DirFd,
554        entry_name: &std::ffi::OsStr,
555    ) -> UResult<()> {
556        // During recursion, determine behavior based on traversal mode
557        match self.traverse_symlinks {
558            TraverseSymlinks::All => {
559                // Follow all symlinks during recursion
560                // Check if the symlink target is a directory, but handle dangling symlinks gracefully
561                match fs::metadata(path) {
562                    Ok(meta) if meta.is_dir() => self.walk_dir_with_context(path, false),
563                    Ok(meta) => {
564                        // It's a file symlink, chmod it using safe traversal
565                        self.safe_chmod_file(path, dir_fd, entry_name, meta.mode() & 0o7777)
566                    }
567                    Err(_) => {
568                        // Dangling symlink, chmod it without dereferencing
569                        self.chmod_file_internal(path, false)
570                    }
571                }
572            }
573            TraverseSymlinks::First | TraverseSymlinks::None => {
574                // Don't follow symlinks encountered during recursion
575                // For these symlinks, don't dereference them even if dereference is normally true
576                self.chmod_file_internal(path, false)
577            }
578        }
579    }
580
581    #[cfg(all(unix, not(target_os = "redox")))]
582    fn safe_chmod_file(
583        &self,
584        file_path: &Path,
585        dir_fd: &DirFd,
586        entry_name: &std::ffi::OsStr,
587        current_mode: u32,
588    ) -> UResult<()> {
589        // Calculate the new mode using the helper method
590        let (new_mode, _) = self.calculate_new_mode(current_mode, file_path.is_dir())?;
591
592        // Use safe traversal to change the mode
593        let follow_symlinks = self.dereference;
594        if let Err(_e) = dir_fd.chmod_at(entry_name, new_mode, follow_symlinks.into()) {
595            if self.verbose {
596                println!(
597                    "failed to change mode of {} to {new_mode:o}",
598                    file_path.quote(),
599                );
600            }
601            return Err(ChmodError::PermissionDenied(file_path.into()).into());
602        }
603
604        // Report the change using the helper method
605        self.report_permission_change(file_path, current_mode, new_mode);
606
607        Ok(())
608    }
609
610    #[cfg(not(unix))]
611    fn handle_symlink_during_recursion(&self, path: &Path) -> UResult<()> {
612        // Use the common symlink handling logic
613        self.handle_symlink_during_traversal(path, false)
614    }
615
616    fn chmod_file(&self, file: &Path) -> UResult<()> {
617        self.chmod_file_internal(file, self.dereference)
618    }
619
620    fn chmod_file_internal(&self, file: &Path, dereference: bool) -> UResult<()> {
621        use uucore::perms::get_metadata;
622
623        let metadata = get_metadata(file, dereference);
624
625        let fperm = match metadata {
626            Ok(meta) => meta.mode() & 0o7777,
627            Err(err) => {
628                // Handle dangling symlinks or other errors
629                return if file.is_symlink() && !dereference {
630                    if self.verbose {
631                        println!(
632                            "neither symbolic link {} nor referent has been changed",
633                            file.quote()
634                        );
635                    }
636                    Ok(()) // Skip dangling symlinks
637                } else if err.kind() == std::io::ErrorKind::PermissionDenied {
638                    Err(ChmodError::PermissionDenied(file.into()).into())
639                } else {
640                    Err(ChmodError::CannotStat(file.into()).into())
641                };
642            }
643        };
644
645        // Calculate the new mode using the helper method
646        let (new_mode, naively_expected_new_mode) =
647            self.calculate_new_mode(fperm, file.is_dir())?;
648
649        // Determine how to apply the permissions
650        if let Some(mode) = self.fmode {
651            self.change_file(fperm, mode, file)?;
652        } else {
653            // Special handling for symlinks when not dereferencing
654            if file.is_symlink() && !dereference {
655                // TODO: On most Unix systems, symlink permissions are ignored by the kernel,
656                // so changing them has no effect. We skip this operation for compatibility.
657                // Note that "chmod without dereferencing" effectively does nothing on symlinks.
658                if self.verbose {
659                    println!(
660                        "neither symbolic link {} nor referent has been changed",
661                        file.quote()
662                    );
663                }
664            } else {
665                self.change_file(fperm, new_mode, file)?;
666            }
667            // if a permission would have been removed if umask was 0, but it wasn't because umask was not 0, print an error and fail
668            if (new_mode & !naively_expected_new_mode) != 0 {
669                return Err(ChmodError::NewPermissions(
670                    file.into(),
671                    display_permissions_unix(new_mode as mode_t, false),
672                    display_permissions_unix(naively_expected_new_mode as mode_t, false),
673                )
674                .into());
675            }
676        }
677
678        Ok(())
679    }
680
681    fn change_file(&self, fperm: u32, mode: u32, file: &Path) -> Result<(), i32> {
682        if fperm == mode {
683            // Use the helper method for consistent reporting
684            self.report_permission_change(file, fperm, mode);
685            Ok(())
686        } else if let Err(err) = fs::set_permissions(file, fs::Permissions::from_mode(mode)) {
687            if !self.quiet {
688                show_error!("{err}");
689            }
690            if self.verbose {
691                println!(
692                    "failed to change mode of file {} from {fperm:04o} ({}) to {mode:04o} ({})",
693                    file.quote(),
694                    display_permissions_unix(fperm as mode_t, false),
695                    display_permissions_unix(mode as mode_t, false)
696                );
697            }
698            Err(1)
699        } else {
700            // Use the helper method for consistent reporting
701            self.report_permission_change(file, fperm, mode);
702            Ok(())
703        }
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    #[test]
712    fn test_extract_negative_modes() {
713        // "chmod -w -r file" becomes "chmod -w,-r file". clap does not accept "-w,-r" as MODE.
714        // Therefore, "w" is added as pseudo mode to pass clap.
715        let (c, a) = extract_negative_modes(["-w", "-r", "file"].iter().map(OsString::from));
716        assert_eq!(c, Some("-w,-r".to_string()));
717        assert_eq!(a, ["w", "file"]);
718
719        // "chmod -w file -r" becomes "chmod -w,-r file". clap does not accept "-w,-r" as MODE.
720        // Therefore, "w" is added as pseudo mode to pass clap.
721        let (c, a) = extract_negative_modes(["-w", "file", "-r"].iter().map(OsString::from));
722        assert_eq!(c, Some("-w,-r".to_string()));
723        assert_eq!(a, ["w", "file"]);
724
725        // "chmod -w -- -r file" becomes "chmod -w -r file", where "-r" is interpreted as file.
726        // Again, "w" is needed as pseudo mode.
727        let (c, a) = extract_negative_modes(["-w", "--", "-r", "f"].iter().map(OsString::from));
728        assert_eq!(c, Some("-w".to_string()));
729        assert_eq!(a, ["w", "--", "-r", "f"]);
730
731        // "chmod -- -r file" becomes "chmod -r file".
732        let (c, a) = extract_negative_modes(["--", "-r", "file"].iter().map(OsString::from));
733        assert_eq!(c, None);
734        assert_eq!(a, ["--", "-r", "file"]);
735    }
736}