Skip to main content

coreutils_rs/chmod/
core.rs

1use std::fs;
2use std::io;
3use std::os::unix::fs::MetadataExt;
4use std::os::unix::fs::PermissionsExt;
5use std::path::Path;
6
7/// Configuration for chmod operations.
8#[derive(Debug, Clone, Default)]
9pub struct ChmodConfig {
10    /// Report only when a change is made.
11    pub changes: bool,
12    /// Suppress most error messages.
13    pub quiet: bool,
14    /// Output a diagnostic for every file processed.
15    pub verbose: bool,
16    /// Fail to operate recursively on '/'.
17    pub preserve_root: bool,
18    /// Operate recursively.
19    pub recursive: bool,
20}
21
22// Permission bit constants
23const S_ISUID: u32 = 0o4000;
24const S_ISGID: u32 = 0o2000;
25const S_ISVTX: u32 = 0o1000;
26
27const S_IRUSR: u32 = 0o0400;
28const S_IWUSR: u32 = 0o0200;
29const S_IXUSR: u32 = 0o0100;
30
31const S_IRGRP: u32 = 0o0040;
32const S_IWGRP: u32 = 0o0020;
33const S_IXGRP: u32 = 0o0010;
34
35const S_IROTH: u32 = 0o0004;
36const S_IWOTH: u32 = 0o0002;
37const S_IXOTH: u32 = 0o0001;
38
39const USER_BITS: u32 = S_IRUSR | S_IWUSR | S_IXUSR;
40const GROUP_BITS: u32 = S_IRGRP | S_IWGRP | S_IXGRP;
41const OTHER_BITS: u32 = S_IROTH | S_IWOTH | S_IXOTH;
42const ALL_BITS: u32 = USER_BITS | GROUP_BITS | OTHER_BITS | S_ISUID | S_ISGID | S_ISVTX;
43
44/// Parse a mode string (octal or symbolic) and return the new mode.
45///
46/// `mode_str` can be:
47/// - Octal: "755", "0644"
48/// - Symbolic: "u+x", "g-w", "o=r", "a+rw", "u=rw,g=r,o=", "+t", "u+s"
49/// - Combined: "u+rwx,g+rx,o+r"
50///
51/// `current_mode` is the existing file mode, needed for symbolic modes.
52pub fn parse_mode(mode_str: &str, current_mode: u32) -> Result<u32, String> {
53    // Try octal first: only if all characters are octal digits
54    if !mode_str.is_empty() && mode_str.chars().all(|c| c.is_ascii_digit() && c < '8') {
55        if let Ok(octal) = u32::from_str_radix(mode_str, 8) {
56            return Ok(octal & 0o7777);
57        }
58    }
59    parse_symbolic_mode(mode_str, current_mode)
60}
61
62/// Like `parse_mode` but ignores the process umask.
63///
64/// Used by `install -m` where the mode string is applied without umask
65/// filtering (matching GNU coreutils behaviour).
66pub fn parse_mode_no_umask(mode_str: &str, current_mode: u32) -> Result<u32, String> {
67    // Try octal first
68    if !mode_str.is_empty() && mode_str.chars().all(|c| c.is_ascii_digit() && c < '8') {
69        if let Ok(octal) = u32::from_str_radix(mode_str, 8) {
70            return Ok(octal & 0o7777);
71        }
72    }
73    parse_symbolic_mode_with_umask(mode_str, current_mode, 0)
74}
75
76/// Parse a mode and also compute whether the umask blocked any requested bits.
77/// Returns `(new_mode, umask_blocked)` where `umask_blocked` is true if the
78/// umask prevented some requested bits from being changed.
79///
80/// This is needed for GNU compatibility: when no who is specified (e.g. `-rwx`
81/// instead of `a-rwx`), umask filters the operation. If the resulting mode
82/// differs from what would have been achieved without the umask, GNU warns
83/// and exits 1 (but only when the mode was passed as an option-like arg).
84pub fn parse_mode_check_umask(mode_str: &str, current_mode: u32) -> Result<(u32, bool), String> {
85    // Octal modes are not affected by umask
86    if !mode_str.is_empty() && mode_str.chars().all(|c| c.is_ascii_digit() && c < '8') {
87        if let Ok(octal) = u32::from_str_radix(mode_str, 8) {
88            return Ok((octal & 0o7777, false));
89        }
90    }
91
92    let umask = get_umask();
93    let with_umask = parse_symbolic_mode_with_umask(mode_str, current_mode, umask)?;
94    let without_umask = parse_symbolic_mode_with_umask(mode_str, current_mode, 0)?;
95    Ok((with_umask, with_umask != without_umask))
96}
97
98/// Parse a symbolic mode string and compute the resulting mode.
99///
100/// Format: `[ugoa]*[+-=][rwxXstugo]+` (comma-separated clauses)
101fn parse_symbolic_mode(mode_str: &str, current_mode: u32) -> Result<u32, String> {
102    parse_symbolic_mode_with_umask(mode_str, current_mode, get_umask())
103}
104
105/// Inner implementation that accepts an explicit umask value.
106fn parse_symbolic_mode_with_umask(
107    mode_str: &str,
108    current_mode: u32,
109    umask: u32,
110) -> Result<u32, String> {
111    let mut mode = current_mode & 0o7777;
112
113    // Preserve the file type bits from the original mode so that
114    // apply_symbolic_clause can detect directories for capital-X handling.
115    let file_type_bits = current_mode & 0o170000;
116
117    for clause in mode_str.split(',') {
118        if clause.is_empty() {
119            return Err(format!("invalid mode: '{}'", mode_str));
120        }
121        mode = apply_symbolic_clause(clause, mode | file_type_bits, umask)? & 0o7777;
122    }
123
124    Ok(mode)
125}
126
127/// Get the current umask value.
128pub fn get_umask() -> u32 {
129    // Set umask to 0, read the old value, then restore it.
130    // SAFETY: umask is always safe to call.
131    let old = unsafe { libc::umask(0) };
132    unsafe {
133        libc::umask(old);
134    }
135    old as u32
136}
137
138/// Apply a single symbolic mode clause (e.g. "u+x", "go-w", "a=r").
139fn apply_symbolic_clause(clause: &str, current_mode: u32, umask: u32) -> Result<u32, String> {
140    let bytes = clause.as_bytes();
141    let len = bytes.len();
142    let mut pos = 0;
143
144    // Parse the "who" part: [ugoa]*
145    let mut who_mask: u32 = 0;
146    let mut who_specified = false;
147    while pos < len {
148        match bytes[pos] {
149            b'u' => {
150                who_mask |= USER_BITS | S_ISUID;
151                who_specified = true;
152            }
153            b'g' => {
154                who_mask |= GROUP_BITS | S_ISGID;
155                who_specified = true;
156            }
157            b'o' => {
158                who_mask |= OTHER_BITS | S_ISVTX;
159                who_specified = true;
160            }
161            b'a' => {
162                who_mask |= ALL_BITS;
163                who_specified = true;
164            }
165            _ => break,
166        }
167        pos += 1;
168    }
169
170    // If no who specified, default to 'a' but filtered by umask
171    if !who_specified {
172        who_mask = ALL_BITS;
173    }
174
175    if pos >= len {
176        return Err(format!("invalid mode: '{}'", clause));
177    }
178
179    let mut mode = current_mode;
180
181    // Process one or more operator+perm sequences: [+-=][rwxXstugo]*
182    while pos < len {
183        // Parse operator
184        let op = match bytes[pos] {
185            b'+' => '+',
186            b'-' => '-',
187            b'=' => '=',
188            _ => return Err(format!("invalid mode: '{}'", clause)),
189        };
190        pos += 1;
191
192        // Parse permission bits
193        let mut perm_bits: u32 = 0;
194        let mut has_x_cap = false;
195        // Track whether we've seen regular perm chars (rwxXst) vs copy-from (ugo).
196        // GNU chmod does not allow mixing these in the same clause after the operator.
197        let mut has_perm_chars = false;
198        let mut has_copy_from = false;
199
200        while pos < len && bytes[pos] != b'+' && bytes[pos] != b'-' && bytes[pos] != b'=' {
201            match bytes[pos] {
202                b'r' => {
203                    if has_copy_from {
204                        return Err(format!("invalid mode: '{}'", clause));
205                    }
206                    has_perm_chars = true;
207                    perm_bits |= S_IRUSR | S_IRGRP | S_IROTH;
208                }
209                b'w' => {
210                    if has_copy_from {
211                        return Err(format!("invalid mode: '{}'", clause));
212                    }
213                    has_perm_chars = true;
214                    perm_bits |= S_IWUSR | S_IWGRP | S_IWOTH;
215                }
216                b'x' => {
217                    if has_copy_from {
218                        return Err(format!("invalid mode: '{}'", clause));
219                    }
220                    has_perm_chars = true;
221                    perm_bits |= S_IXUSR | S_IXGRP | S_IXOTH;
222                }
223                b'X' => {
224                    if has_copy_from {
225                        return Err(format!("invalid mode: '{}'", clause));
226                    }
227                    has_perm_chars = true;
228                    has_x_cap = true;
229                }
230                b's' => {
231                    if has_copy_from {
232                        return Err(format!("invalid mode: '{}'", clause));
233                    }
234                    has_perm_chars = true;
235                    perm_bits |= S_ISUID | S_ISGID;
236                }
237                b't' => {
238                    if has_copy_from {
239                        return Err(format!("invalid mode: '{}'", clause));
240                    }
241                    has_perm_chars = true;
242                    perm_bits |= S_ISVTX;
243                }
244                b'u' => {
245                    if has_perm_chars {
246                        return Err(format!("invalid mode: '{}'", clause));
247                    }
248                    has_copy_from = true;
249                    // Copy user bits
250                    let u = current_mode & USER_BITS;
251                    perm_bits |= u | (u >> 3) | (u >> 6);
252                }
253                b'g' => {
254                    if has_perm_chars {
255                        return Err(format!("invalid mode: '{}'", clause));
256                    }
257                    has_copy_from = true;
258                    // Copy group bits
259                    let g = current_mode & GROUP_BITS;
260                    perm_bits |= (g << 3) | g | (g >> 3);
261                }
262                b'o' => {
263                    if has_perm_chars {
264                        return Err(format!("invalid mode: '{}'", clause));
265                    }
266                    has_copy_from = true;
267                    // Copy other bits
268                    let o = current_mode & OTHER_BITS;
269                    perm_bits |= (o << 6) | (o << 3) | o;
270                }
271                b',' => break,
272                _ => return Err(format!("invalid mode: '{}'", clause)),
273            }
274            pos += 1;
275        }
276
277        // Handle capital X: add execute only if directory or already has execute
278        if has_x_cap {
279            // Check if current mode has any execute bit set, or if we're going
280            // to set any execute bit. The caller's is_dir check happens at
281            // chmod_file level. For parse_mode, we check the current_mode.
282            let is_executable = (current_mode & (S_IXUSR | S_IXGRP | S_IXOTH)) != 0;
283            // Note: directory check is indicated by the S_IFDIR bit (0o40000)
284            let is_dir = (current_mode & 0o170000) == 0o040000;
285            if is_executable || is_dir {
286                perm_bits |= S_IXUSR | S_IXGRP | S_IXOTH;
287            }
288        }
289
290        // Apply the operation, masked by who_mask
291        let effective = perm_bits & who_mask;
292
293        // When who is not specified, apply umask filtering for + and -
294        let effective = if !who_specified {
295            // For +/-, umask filters which bits can be changed
296            // For =, umask also applies
297            let umask_filter = !(umask) & (USER_BITS | GROUP_BITS | OTHER_BITS);
298            // Keep setuid/setgid/sticky that are in effective
299            let special = effective & (S_ISUID | S_ISGID | S_ISVTX);
300            (effective & umask_filter) | special
301        } else {
302            effective
303        };
304
305        match op {
306            '+' => {
307                mode |= effective;
308            }
309            '-' => {
310                mode &= !effective;
311            }
312            '=' => {
313                // Clear the who bits, then set the specified ones
314                let clear_mask = who_mask & (USER_BITS | GROUP_BITS | OTHER_BITS);
315                // For '=', also clear setuid/setgid/sticky if who includes them
316                let clear_special = who_mask & (S_ISUID | S_ISGID | S_ISVTX);
317                mode &= !(clear_mask | clear_special);
318
319                let effective_eq = if !who_specified {
320                    let umask_filter = !(umask) & (USER_BITS | GROUP_BITS | OTHER_BITS);
321                    let special = (perm_bits & who_mask) & (S_ISUID | S_ISGID | S_ISVTX);
322                    ((perm_bits & who_mask) & umask_filter) | special
323                } else {
324                    perm_bits & who_mask
325                };
326                mode |= effective_eq;
327            }
328            _ => unreachable!(),
329        }
330    }
331
332    Ok(mode)
333}
334
335/// Format a mode as an octal string (4 digits).
336fn format_mode(mode: u32) -> String {
337    format!("{:04o}", mode & 0o7777)
338}
339
340/// Format a mode as a symbolic permission string like `rwxr-xr-x`.
341/// Includes setuid/setgid/sticky representation matching GNU coreutils.
342fn format_symbolic(mode: u32) -> String {
343    let m = mode & 0o7777;
344    let mut s = [b'-'; 9];
345
346    // User
347    if m & S_IRUSR != 0 {
348        s[0] = b'r';
349    }
350    if m & S_IWUSR != 0 {
351        s[1] = b'w';
352    }
353    if m & S_IXUSR != 0 {
354        s[2] = if m & S_ISUID != 0 { b's' } else { b'x' };
355    } else if m & S_ISUID != 0 {
356        s[2] = b'S';
357    }
358
359    // Group
360    if m & S_IRGRP != 0 {
361        s[3] = b'r';
362    }
363    if m & S_IWGRP != 0 {
364        s[4] = b'w';
365    }
366    if m & S_IXGRP != 0 {
367        s[5] = if m & S_ISGID != 0 { b's' } else { b'x' };
368    } else if m & S_ISGID != 0 {
369        s[5] = b'S';
370    }
371
372    // Other
373    if m & S_IROTH != 0 {
374        s[6] = b'r';
375    }
376    if m & S_IWOTH != 0 {
377        s[7] = b'w';
378    }
379    if m & S_IXOTH != 0 {
380        s[8] = if m & S_ISVTX != 0 { b't' } else { b'x' };
381    } else if m & S_ISVTX != 0 {
382        s[8] = b'T';
383    }
384
385    String::from_utf8(s.to_vec()).unwrap()
386}
387
388/// Format the symbolic mode string for umask-blocked warning messages.
389/// Produces output like `rwxr-xr-x`.
390pub fn format_symbolic_for_warning(mode: u32) -> String {
391    format_symbolic(mode)
392}
393
394/// Apply a mode to a file and return whether a change was made.
395///
396/// If `config.verbose` is true, prints a message for every file.
397/// If `config.changes` is true, prints only when the mode changes.
398///
399/// GNU chmod sends verbose/changes output to stdout.
400pub fn chmod_file(path: &Path, mode: u32, config: &ChmodConfig) -> Result<bool, io::Error> {
401    let metadata = fs::symlink_metadata(path)?;
402
403    // Skip symlinks
404    if metadata.file_type().is_symlink() {
405        return Ok(false);
406    }
407
408    let old_mode = metadata.mode() & 0o7777;
409    let changed = old_mode != mode;
410
411    if changed {
412        let perms = fs::Permissions::from_mode(mode);
413        fs::set_permissions(path, perms)?;
414    }
415
416    let path_display = path.display();
417    if config.verbose {
418        if changed {
419            println!(
420                "mode of '{}' changed from {} ({}) to {} ({})",
421                path_display,
422                format_mode(old_mode),
423                format_symbolic(old_mode),
424                format_mode(mode),
425                format_symbolic(mode)
426            );
427        } else {
428            println!(
429                "mode of '{}' retained as {} ({})",
430                path_display,
431                format_mode(old_mode),
432                format_symbolic(old_mode)
433            );
434        }
435    } else if config.changes && changed {
436        println!(
437            "mode of '{}' changed from {} ({}) to {} ({})",
438            path_display,
439            format_mode(old_mode),
440            format_symbolic(old_mode),
441            format_mode(mode),
442            format_symbolic(mode)
443        );
444    }
445
446    Ok(changed)
447}
448
449/// Recursively apply a mode string to a directory tree.
450///
451/// The mode is re-parsed for each file using its current mode, which matters
452/// for symbolic modes (e.g. `a+X` behaves differently for files vs directories).
453pub fn chmod_recursive(
454    path: &Path,
455    mode_str: &str,
456    config: &ChmodConfig,
457) -> Result<bool, io::Error> {
458    if config.preserve_root && path == Path::new("/") {
459        return Err(io::Error::other(
460            "it is dangerous to operate recursively on '/'",
461        ));
462    }
463
464    let mut had_error = false;
465
466    // Process the path itself first
467    match process_entry(path, mode_str, config) {
468        Ok(()) => {}
469        Err(e) => {
470            if !config.quiet {
471                eprintln!("chmod: cannot access '{}': {}", path.display(), e);
472            }
473            had_error = true;
474        }
475    }
476
477    // Walk the directory tree
478    if path.is_dir() {
479        walk_dir(path, mode_str, config, &mut had_error);
480    }
481
482    if had_error {
483        Err(io::Error::other("some operations failed"))
484    } else {
485        Ok(true)
486    }
487}
488
489/// Process a single entry: read its mode, parse the mode string, and apply.
490fn process_entry(path: &Path, mode_str: &str, config: &ChmodConfig) -> Result<(), io::Error> {
491    let metadata = fs::symlink_metadata(path)?;
492
493    // Skip symlinks
494    if metadata.file_type().is_symlink() {
495        return Ok(());
496    }
497
498    let current_mode = metadata.mode();
499    let mut new_mode = parse_mode(mode_str, current_mode).map_err(|e| io::Error::other(e))?;
500
501    // GNU chmod: for directories, preserve setuid/setgid bits when the octal
502    // mode doesn't explicitly specify them (i.e., <= 4 octal digits).
503    if metadata.is_dir()
504        && !mode_str.is_empty()
505        && mode_str.bytes().all(|b| b.is_ascii_digit() && b < b'8')
506        && mode_str.len() <= 4
507    {
508        let existing_special = current_mode & 0o7000;
509        new_mode |= existing_special;
510    }
511
512    chmod_file(path, new_mode, config)?;
513    Ok(())
514}
515
516/// Walk a directory recursively, applying the mode to each entry.
517/// Uses rayon for parallel processing when verbose/changes output is not needed.
518fn walk_dir(dir: &Path, mode_str: &str, config: &ChmodConfig, had_error: &mut bool) {
519    // For non-verbose mode, use parallel traversal with rayon
520    if !config.verbose && !config.changes {
521        let error_flag = std::sync::atomic::AtomicBool::new(false);
522        walk_dir_parallel(dir, mode_str, config, &error_flag);
523        if error_flag.load(std::sync::atomic::Ordering::Relaxed) {
524            *had_error = true;
525        }
526        return;
527    }
528
529    // Sequential path for verbose/changes mode (output ordering matters)
530    let entries = match fs::read_dir(dir) {
531        Ok(entries) => entries,
532        Err(e) => {
533            if !config.quiet {
534                eprintln!("chmod: cannot open directory '{}': {}", dir.display(), e);
535            }
536            *had_error = true;
537            return;
538        }
539    };
540
541    for entry in entries {
542        let entry = match entry {
543            Ok(e) => e,
544            Err(e) => {
545                if !config.quiet {
546                    eprintln!("chmod: error reading directory entry: {}", e);
547                }
548                *had_error = true;
549                continue;
550            }
551        };
552
553        let entry_path = entry.path();
554
555        let file_type = match entry.file_type() {
556            Ok(ft) => ft,
557            Err(e) => {
558                if !config.quiet {
559                    eprintln!(
560                        "chmod: cannot read file type of '{}': {}",
561                        entry_path.display(),
562                        e
563                    );
564                }
565                *had_error = true;
566                continue;
567            }
568        };
569
570        if file_type.is_symlink() {
571            continue;
572        }
573
574        match process_entry(&entry_path, mode_str, config) {
575            Ok(()) => {}
576            Err(e) => {
577                if !config.quiet {
578                    eprintln!(
579                        "chmod: changing permissions of '{}': {}",
580                        entry_path.display(),
581                        e
582                    );
583                }
584                *had_error = true;
585            }
586        }
587
588        if file_type.is_dir() {
589            walk_dir(&entry_path, mode_str, config, had_error);
590        }
591    }
592}
593
594/// Parallel directory walk using rayon for non-verbose chmod operations.
595fn walk_dir_parallel(
596    dir: &Path,
597    mode_str: &str,
598    config: &ChmodConfig,
599    had_error: &std::sync::atomic::AtomicBool,
600) {
601    let entries = match fs::read_dir(dir) {
602        Ok(entries) => entries,
603        Err(e) => {
604            if !config.quiet {
605                eprintln!("chmod: cannot open directory '{}': {}", dir.display(), e);
606            }
607            had_error.store(true, std::sync::atomic::Ordering::Relaxed);
608            return;
609        }
610    };
611
612    let entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
613
614    use rayon::prelude::*;
615    entries.par_iter().for_each(|entry| {
616        let entry_path = entry.path();
617        let file_type = match entry.file_type() {
618            Ok(ft) => ft,
619            Err(_) => {
620                had_error.store(true, std::sync::atomic::Ordering::Relaxed);
621                return;
622            }
623        };
624
625        if file_type.is_symlink() {
626            return;
627        }
628
629        if process_entry(&entry_path, mode_str, config).is_err() {
630            had_error.store(true, std::sync::atomic::Ordering::Relaxed);
631        }
632
633        if file_type.is_dir() {
634            walk_dir_parallel(&entry_path, mode_str, config, had_error);
635        }
636    });
637}