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/// Parse a symbolic mode string and compute the resulting mode.
63///
64/// Format: `[ugoa]*[+-=][rwxXstugo]+` (comma-separated clauses)
65fn parse_symbolic_mode(mode_str: &str, current_mode: u32) -> Result<u32, String> {
66    let mut mode = current_mode & 0o7777;
67
68    // Preserve the file type bits from the original mode so that
69    // apply_symbolic_clause can detect directories for capital-X handling.
70    let file_type_bits = current_mode & 0o170000;
71
72    // Get the current umask
73    let umask = get_umask();
74
75    for clause in mode_str.split(',') {
76        if clause.is_empty() {
77            return Err(format!("invalid mode: '{}'", mode_str));
78        }
79        mode = apply_symbolic_clause(clause, mode | file_type_bits, umask)? & 0o7777;
80    }
81
82    Ok(mode)
83}
84
85/// Get the current umask value.
86fn get_umask() -> u32 {
87    // Set umask to 0, read the old value, then restore it.
88    // SAFETY: umask is always safe to call.
89    let old = unsafe { libc::umask(0) };
90    unsafe {
91        libc::umask(old);
92    }
93    old as u32
94}
95
96/// Apply a single symbolic mode clause (e.g. "u+x", "go-w", "a=r").
97fn apply_symbolic_clause(clause: &str, current_mode: u32, umask: u32) -> Result<u32, String> {
98    let bytes = clause.as_bytes();
99    let len = bytes.len();
100    let mut pos = 0;
101
102    // Parse the "who" part: [ugoa]*
103    let mut who_mask: u32 = 0;
104    let mut who_specified = false;
105    while pos < len {
106        match bytes[pos] {
107            b'u' => {
108                who_mask |= USER_BITS | S_ISUID;
109                who_specified = true;
110            }
111            b'g' => {
112                who_mask |= GROUP_BITS | S_ISGID;
113                who_specified = true;
114            }
115            b'o' => {
116                who_mask |= OTHER_BITS | S_ISVTX;
117                who_specified = true;
118            }
119            b'a' => {
120                who_mask |= ALL_BITS;
121                who_specified = true;
122            }
123            _ => break,
124        }
125        pos += 1;
126    }
127
128    // If no who specified, default to 'a' but filtered by umask
129    if !who_specified {
130        who_mask = ALL_BITS;
131    }
132
133    if pos >= len {
134        return Err(format!("invalid mode: '{}'", clause));
135    }
136
137    let mut mode = current_mode;
138
139    // Process one or more operator+perm sequences: [+-=][rwxXstugo]*
140    while pos < len {
141        // Parse operator
142        let op = match bytes[pos] {
143            b'+' => '+',
144            b'-' => '-',
145            b'=' => '=',
146            _ => return Err(format!("invalid mode: '{}'", clause)),
147        };
148        pos += 1;
149
150        // Parse permission bits
151        let mut perm_bits: u32 = 0;
152        let mut has_x_cap = false;
153
154        while pos < len && bytes[pos] != b'+' && bytes[pos] != b'-' && bytes[pos] != b'=' {
155            match bytes[pos] {
156                b'r' => {
157                    perm_bits |= S_IRUSR | S_IRGRP | S_IROTH;
158                }
159                b'w' => {
160                    perm_bits |= S_IWUSR | S_IWGRP | S_IWOTH;
161                }
162                b'x' => {
163                    perm_bits |= S_IXUSR | S_IXGRP | S_IXOTH;
164                }
165                b'X' => {
166                    has_x_cap = true;
167                }
168                b's' => {
169                    perm_bits |= S_ISUID | S_ISGID;
170                }
171                b't' => {
172                    perm_bits |= S_ISVTX;
173                }
174                b'u' => {
175                    // Copy user bits
176                    let u = current_mode & USER_BITS;
177                    perm_bits |= u | (u >> 3) | (u >> 6);
178                }
179                b'g' => {
180                    // Copy group bits
181                    let g = current_mode & GROUP_BITS;
182                    perm_bits |= (g << 3) | g | (g >> 3);
183                }
184                b'o' => {
185                    // Copy other bits
186                    let o = current_mode & OTHER_BITS;
187                    perm_bits |= (o << 6) | (o << 3) | o;
188                }
189                b',' => break,
190                _ => return Err(format!("invalid mode: '{}'", clause)),
191            }
192            pos += 1;
193        }
194
195        // Handle capital X: add execute only if directory or already has execute
196        if has_x_cap {
197            // Check if current mode has any execute bit set, or if we're going
198            // to set any execute bit. The caller's is_dir check happens at
199            // chmod_file level. For parse_mode, we check the current_mode.
200            let is_executable = (current_mode & (S_IXUSR | S_IXGRP | S_IXOTH)) != 0;
201            // Note: directory check is indicated by the S_IFDIR bit (0o40000)
202            let is_dir = (current_mode & 0o170000) == 0o040000;
203            if is_executable || is_dir {
204                perm_bits |= S_IXUSR | S_IXGRP | S_IXOTH;
205            }
206        }
207
208        // Apply the operation, masked by who_mask
209        let effective = perm_bits & who_mask;
210
211        // When who is not specified, apply umask filtering for + and -
212        let effective = if !who_specified {
213            // For +/-, umask filters which bits can be changed
214            // For =, umask also applies
215            let umask_filter = !(umask) & (USER_BITS | GROUP_BITS | OTHER_BITS);
216            // Keep setuid/setgid/sticky that are in effective
217            let special = effective & (S_ISUID | S_ISGID | S_ISVTX);
218            (effective & umask_filter) | special
219        } else {
220            effective
221        };
222
223        match op {
224            '+' => {
225                mode |= effective;
226            }
227            '-' => {
228                mode &= !effective;
229            }
230            '=' => {
231                // Clear the who bits, then set the specified ones
232                let clear_mask = who_mask & (USER_BITS | GROUP_BITS | OTHER_BITS);
233                // For '=', also clear setuid/setgid/sticky if who includes them
234                let clear_special = who_mask & (S_ISUID | S_ISGID | S_ISVTX);
235                mode &= !(clear_mask | clear_special);
236
237                let effective_eq = if !who_specified {
238                    let umask_filter = !(umask) & (USER_BITS | GROUP_BITS | OTHER_BITS);
239                    let special = (perm_bits & who_mask) & (S_ISUID | S_ISGID | S_ISVTX);
240                    ((perm_bits & who_mask) & umask_filter) | special
241                } else {
242                    perm_bits & who_mask
243                };
244                mode |= effective_eq;
245            }
246            _ => unreachable!(),
247        }
248    }
249
250    Ok(mode)
251}
252
253/// Format a mode as an octal string (4 digits).
254fn format_mode(mode: u32) -> String {
255    format!("{:04o}", mode & 0o7777)
256}
257
258/// Apply a mode to a file and return whether a change was made.
259///
260/// If `config.verbose` is true, prints a message for every file.
261/// If `config.changes` is true, prints only when the mode changes.
262pub fn chmod_file(path: &Path, mode: u32, config: &ChmodConfig) -> Result<bool, io::Error> {
263    let metadata = fs::symlink_metadata(path)?;
264
265    // Skip symlinks
266    if metadata.file_type().is_symlink() {
267        return Ok(false);
268    }
269
270    let old_mode = metadata.mode() & 0o7777;
271    let changed = old_mode != mode;
272
273    if changed {
274        let perms = fs::Permissions::from_mode(mode);
275        fs::set_permissions(path, perms)?;
276    }
277
278    let path_display = path.display();
279    if config.verbose {
280        if changed {
281            eprintln!(
282                "mode of '{}' changed from {} to {}",
283                path_display,
284                format_mode(old_mode),
285                format_mode(mode)
286            );
287        } else {
288            eprintln!(
289                "mode of '{}' retained as {}",
290                path_display,
291                format_mode(old_mode)
292            );
293        }
294    } else if config.changes && changed {
295        eprintln!(
296            "mode of '{}' changed from {} to {}",
297            path_display,
298            format_mode(old_mode),
299            format_mode(mode)
300        );
301    }
302
303    Ok(changed)
304}
305
306/// Recursively apply a mode string to a directory tree.
307///
308/// The mode is re-parsed for each file using its current mode, which matters
309/// for symbolic modes (e.g. `a+X` behaves differently for files vs directories).
310pub fn chmod_recursive(
311    path: &Path,
312    mode_str: &str,
313    config: &ChmodConfig,
314) -> Result<bool, io::Error> {
315    if config.preserve_root && path == Path::new("/") {
316        return Err(io::Error::other(
317            "it is dangerous to operate recursively on '/'",
318        ));
319    }
320
321    let mut had_error = false;
322
323    // Process the path itself first
324    match process_entry(path, mode_str, config) {
325        Ok(()) => {}
326        Err(e) => {
327            if !config.quiet {
328                eprintln!("chmod: cannot access '{}': {}", path.display(), e);
329            }
330            had_error = true;
331        }
332    }
333
334    // Walk the directory tree
335    if path.is_dir() {
336        walk_dir(path, mode_str, config, &mut had_error);
337    }
338
339    if had_error {
340        Err(io::Error::other("some operations failed"))
341    } else {
342        Ok(true)
343    }
344}
345
346/// Process a single entry: read its mode, parse the mode string, and apply.
347fn process_entry(path: &Path, mode_str: &str, config: &ChmodConfig) -> Result<(), io::Error> {
348    let metadata = fs::symlink_metadata(path)?;
349
350    // Skip symlinks
351    if metadata.file_type().is_symlink() {
352        return Ok(());
353    }
354
355    let current_mode = metadata.mode();
356    let new_mode = parse_mode(mode_str, current_mode).map_err(|e| io::Error::other(e))?;
357    chmod_file(path, new_mode, config)?;
358    Ok(())
359}
360
361/// Walk a directory recursively, applying the mode to each entry.
362/// Uses rayon for parallel processing when verbose/changes output is not needed.
363fn walk_dir(dir: &Path, mode_str: &str, config: &ChmodConfig, had_error: &mut bool) {
364    // For non-verbose mode, use parallel traversal with rayon
365    if !config.verbose && !config.changes {
366        let error_flag = std::sync::atomic::AtomicBool::new(false);
367        walk_dir_parallel(dir, mode_str, config, &error_flag);
368        if error_flag.load(std::sync::atomic::Ordering::Relaxed) {
369            *had_error = true;
370        }
371        return;
372    }
373
374    // Sequential path for verbose/changes mode (output ordering matters)
375    let entries = match fs::read_dir(dir) {
376        Ok(entries) => entries,
377        Err(e) => {
378            if !config.quiet {
379                eprintln!("chmod: cannot open directory '{}': {}", dir.display(), e);
380            }
381            *had_error = true;
382            return;
383        }
384    };
385
386    for entry in entries {
387        let entry = match entry {
388            Ok(e) => e,
389            Err(e) => {
390                if !config.quiet {
391                    eprintln!("chmod: error reading directory entry: {}", e);
392                }
393                *had_error = true;
394                continue;
395            }
396        };
397
398        let entry_path = entry.path();
399
400        let file_type = match entry.file_type() {
401            Ok(ft) => ft,
402            Err(e) => {
403                if !config.quiet {
404                    eprintln!(
405                        "chmod: cannot read file type of '{}': {}",
406                        entry_path.display(),
407                        e
408                    );
409                }
410                *had_error = true;
411                continue;
412            }
413        };
414
415        if file_type.is_symlink() {
416            continue;
417        }
418
419        match process_entry(&entry_path, mode_str, config) {
420            Ok(()) => {}
421            Err(e) => {
422                if !config.quiet {
423                    eprintln!(
424                        "chmod: changing permissions of '{}': {}",
425                        entry_path.display(),
426                        e
427                    );
428                }
429                *had_error = true;
430            }
431        }
432
433        if file_type.is_dir() {
434            walk_dir(&entry_path, mode_str, config, had_error);
435        }
436    }
437}
438
439/// Parallel directory walk using rayon for non-verbose chmod operations.
440fn walk_dir_parallel(
441    dir: &Path,
442    mode_str: &str,
443    config: &ChmodConfig,
444    had_error: &std::sync::atomic::AtomicBool,
445) {
446    let entries = match fs::read_dir(dir) {
447        Ok(entries) => entries,
448        Err(e) => {
449            if !config.quiet {
450                eprintln!("chmod: cannot open directory '{}': {}", dir.display(), e);
451            }
452            had_error.store(true, std::sync::atomic::Ordering::Relaxed);
453            return;
454        }
455    };
456
457    let entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
458
459    use rayon::prelude::*;
460    entries.par_iter().for_each(|entry| {
461        let entry_path = entry.path();
462        let file_type = match entry.file_type() {
463            Ok(ft) => ft,
464            Err(_) => {
465                had_error.store(true, std::sync::atomic::Ordering::Relaxed);
466                return;
467            }
468        };
469
470        if file_type.is_symlink() {
471            return;
472        }
473
474        if process_entry(&entry_path, mode_str, config).is_err() {
475            had_error.store(true, std::sync::atomic::Ordering::Relaxed);
476        }
477
478        if file_type.is_dir() {
479            walk_dir_parallel(&entry_path, mode_str, config, had_error);
480        }
481    });
482}