Skip to main content

common/
chmod.rs

1//! Recursive permission/ownership changes (chmod/chgrp/chown) over a fileset.
2//!
3//! The public entry point is [`chmod`]; it mirrors [`crate::rm()`] but transforms
4//! metadata in place (from a per-type rule) instead of removing entries.
5use crate::filter::TimeFilter;
6use crate::progress::Progress;
7use crate::walk::{self, EntryKind};
8use anyhow::{Context, anyhow};
9use async_recursion::async_recursion;
10use std::os::unix::fs::{MetadataExt, PermissionsExt};
11use tracing::instrument;
12
13/// Error type for chmod operations. See [`crate::error::OperationError`].
14pub type Error = crate::error::OperationError<Summary>;
15
16/// Which user/group id (if any) to apply to each entry type. `None` leaves that
17/// type unchanged for this operation.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub struct OwnerProgram {
20    pub file: Option<u32>,
21    pub dir: Option<u32>,
22    pub symlink: Option<u32>,
23}
24
25impl OwnerProgram {
26    #[must_use]
27    pub fn for_kind(&self, kind: EntryKind) -> Option<u32> {
28        match kind {
29            EntryKind::Dir => self.dir,
30            EntryKind::Symlink => self.symlink,
31            EntryKind::File | EntryKind::Special => self.file,
32        }
33    }
34    #[must_use]
35    pub fn is_empty(&self) -> bool {
36        self.file.is_none() && self.dir.is_none() && self.symlink.is_none()
37    }
38}
39
40/// A parsed `chmod` mode expression: either a symbolic program (applied relative
41/// to the current mode) or an absolute octal value (12-bit).
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub enum ModeSpec {
44    Symbolic(Vec<SymbolicClause>),
45    Octal(u32),
46}
47
48/// One `[ugoa][+-=][rwxXst]` clause. `who`/`perms` are bitmasks (see `WHO_*` /
49/// `PERM_*`).
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub struct SymbolicClause {
52    pub who: u8,
53    pub op: ModeOp,
54    pub perms: u8,
55}
56
57/// The operator in a symbolic mode clause: add, remove, or set permissions.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum ModeOp {
60    Add,
61    Remove,
62    Set,
63}
64
65pub(crate) const WHO_U: u8 = 0b001;
66pub(crate) const WHO_G: u8 = 0b010;
67pub(crate) const WHO_O: u8 = 0b100;
68pub(crate) const WHO_A: u8 = WHO_U | WHO_G | WHO_O;
69pub(crate) const PERM_R: u8 = 0b00_0001;
70pub(crate) const PERM_W: u8 = 0b00_0010;
71pub(crate) const PERM_X: u8 = 0b00_0100;
72pub(crate) const PERM_BIGX: u8 = 0b00_1000;
73pub(crate) const PERM_S: u8 = 0b01_0000;
74pub(crate) const PERM_T: u8 = 0b10_0000;
75
76/// Per-type mode rules. Symlinks are never included (mode bits aren't settable
77/// on Linux symlinks).
78#[derive(Clone, Debug, Default, PartialEq, Eq)]
79pub struct ModeProgram {
80    pub file: Option<ModeSpec>,
81    pub dir: Option<ModeSpec>,
82}
83
84impl ModeProgram {
85    #[must_use]
86    pub fn for_kind(&self, kind: EntryKind) -> Option<&ModeSpec> {
87        match kind {
88            EntryKind::Dir => self.dir.as_ref(),
89            EntryKind::Symlink => None,
90            EntryKind::File | EntryKind::Special => self.file.as_ref(),
91        }
92    }
93    #[must_use]
94    pub fn is_empty(&self) -> bool {
95        self.file.is_none() && self.dir.is_none()
96    }
97}
98
99/// Configuration for a recursive chmod/chgrp/chown run.
100#[derive(Clone, Debug)]
101pub struct Settings {
102    pub mode: ModeProgram,
103    pub owner: OwnerProgram,
104    pub group: OwnerProgram,
105    pub fail_early: bool,
106    /// Apply directory mode/owner changes after their contents (post-order) instead of
107    /// before (the default). Needed when recursively removing the owner's own traversal
108    /// permission from directories.
109    pub defer_dir_changes: bool,
110    pub filter: Option<crate::filter::FilterSettings>,
111    pub time_filter: Option<TimeFilter>,
112    pub dry_run: Option<crate::config::DryRunMode>,
113}
114
115#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
116pub struct Summary {
117    pub files_changed: usize,
118    pub symlinks_changed: usize,
119    pub directories_changed: usize,
120    pub files_unchanged: usize,
121    pub symlinks_unchanged: usize,
122    pub directories_unchanged: usize,
123    pub files_skipped: usize,
124    pub symlinks_skipped: usize,
125    pub directories_skipped: usize,
126}
127
128impl std::ops::Add for Summary {
129    type Output = Self;
130    fn add(self, other: Self) -> Self {
131        Self {
132            files_changed: self.files_changed + other.files_changed,
133            symlinks_changed: self.symlinks_changed + other.symlinks_changed,
134            directories_changed: self.directories_changed + other.directories_changed,
135            files_unchanged: self.files_unchanged + other.files_unchanged,
136            symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
137            directories_unchanged: self.directories_unchanged + other.directories_unchanged,
138            files_skipped: self.files_skipped + other.files_skipped,
139            symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
140            directories_skipped: self.directories_skipped + other.directories_skipped,
141        }
142    }
143}
144
145impl std::fmt::Display for Summary {
146    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
147        write!(
148            f,
149            "files changed: {}\n\
150            symlinks changed: {}\n\
151            directories changed: {}\n\
152            files unchanged: {}\n\
153            symlinks unchanged: {}\n\
154            directories unchanged: {}\n\
155            files skipped: {}\n\
156            symlinks skipped: {}\n\
157            directories skipped: {}\n",
158            self.files_changed,
159            self.symlinks_changed,
160            self.directories_changed,
161            self.files_unchanged,
162            self.symlinks_unchanged,
163            self.directories_unchanged,
164            self.files_skipped,
165            self.symlinks_skipped,
166            self.directories_skipped
167        )
168    }
169}
170
171/// Whether a DSL id token refers to a user or group.
172#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub enum IdKind {
174    User,
175    Group,
176}
177
178/// Resolve a DSL id token to a numeric id. All-numeric tokens are used directly;
179/// otherwise the token is looked up as a user/group name (matching `chown`/`chgrp`).
180fn resolve_id(token: &str, kind: IdKind) -> anyhow::Result<u32> {
181    if let Ok(n) = token.parse::<u32>() {
182        return Ok(n);
183    }
184    match kind {
185        IdKind::User => nix::unistd::User::from_name(token)
186            .with_context(|| format!("looking up user {token:?}"))?
187            .map(|u| u.uid.as_raw())
188            .ok_or_else(|| anyhow!("unknown user: {token}")),
189        IdKind::Group => nix::unistd::Group::from_name(token)
190            .with_context(|| format!("looking up group {token:?}"))?
191            .map(|g| g.gid.as_raw())
192            .ok_or_else(|| anyhow!("unknown group: {token}")),
193    }
194}
195
196/// Parse a `--group`/`--owner` DSL string. A bare token is the default for all
197/// types; `f:`/`d:`/`l:` sections override per type.
198pub fn parse_owner_dsl(s: &str, kind: IdKind) -> anyhow::Result<OwnerProgram> {
199    let mut prog = OwnerProgram::default();
200    let mut bare: Option<u32> = None;
201    for clause in s.split_whitespace() {
202        if let Some((ty, rest)) = clause.split_once(':') {
203            let id = resolve_id(rest, kind)?;
204            match ty {
205                "f" | "file" => prog.file = Some(id),
206                "d" | "dir" | "directory" => prog.dir = Some(id),
207                "l" | "link" | "symlink" => prog.symlink = Some(id),
208                _ => return Err(anyhow!("unknown type prefix {ty:?} (expected f:/d:/l:)")),
209            }
210        } else if bare.is_some() {
211            return Err(anyhow!(
212                "multiple bare values in {s:?}; use f:/d:/l: prefixes to set different types"
213            ));
214        } else {
215            bare = Some(resolve_id(clause, kind)?);
216        }
217    }
218    if let Some(b) = bare {
219        prog.file.get_or_insert(b);
220        prog.dir.get_or_insert(b);
221        prog.symlink.get_or_insert(b);
222    }
223    Ok(prog)
224}
225
226/// Apply a mode spec to a current 12-bit mode, returning the new 12-bit mode.
227/// `is_dir` drives the conditional `X` permission.
228#[must_use]
229pub fn apply_mode(current: u32, spec: &ModeSpec, is_dir: bool) -> u32 {
230    match spec {
231        ModeSpec::Octal(m) => m & 0o7777,
232        ModeSpec::Symbolic(clauses) => {
233            let mut mode = current & 0o7777;
234            for clause in clauses {
235                mode = apply_clause(mode, *clause, is_dir);
236            }
237            mode
238        }
239    }
240}
241
242fn apply_clause(current: u32, clause: SymbolicClause, is_dir: bool) -> u32 {
243    let any_exec = current & 0o111 != 0;
244    let exec =
245        (clause.perms & PERM_X != 0) || (clause.perms & PERM_BIGX != 0 && (is_dir || any_exec));
246    let r = clause.perms & PERM_R != 0;
247    let w = clause.perms & PERM_W != 0;
248    let s = clause.perms & PERM_S != 0;
249    let t = clause.perms & PERM_T != 0;
250    let mut value: u32 = 0;
251    if clause.who & WHO_U != 0 {
252        if r {
253            value |= 0o400;
254        }
255        if w {
256            value |= 0o200;
257        }
258        if exec {
259            value |= 0o100;
260        }
261        if s {
262            value |= 0o4000;
263        }
264    }
265    if clause.who & WHO_G != 0 {
266        if r {
267            value |= 0o040;
268        }
269        if w {
270            value |= 0o020;
271        }
272        if exec {
273            value |= 0o010;
274        }
275        if s {
276            value |= 0o2000;
277        }
278    }
279    if clause.who & WHO_O != 0 {
280        if r {
281            value |= 0o004;
282        }
283        if w {
284            value |= 0o002;
285        }
286        if exec {
287            value |= 0o001;
288        }
289    }
290    if t && clause.who & WHO_O != 0 {
291        // sticky (t) responds only to 'o' (and 'a', which includes 'o') — matches chmod
292        value |= 0o1000;
293    }
294    match clause.op {
295        ModeOp::Add => current | value,
296        ModeOp::Remove => current & !value,
297        ModeOp::Set => {
298            let mut clear: u32 = 0;
299            if clause.who & WHO_U != 0 {
300                clear |= 0o4700;
301            }
302            if clause.who & WHO_G != 0 {
303                clear |= 0o2070;
304            }
305            if clause.who & WHO_O != 0 {
306                clear |= 0o1007;
307            }
308            (current & !clear) | value
309        }
310    }
311}
312
313/// Parse one mode token: an octal literal (all octal digits) or a comma-chained
314/// symbolic expression.
315fn parse_mode_token(token: &str) -> anyhow::Result<ModeSpec> {
316    if token.is_empty() {
317        return Err(anyhow!("empty mode"));
318    }
319    if token.bytes().all(|b| b.is_ascii_digit()) {
320        if token.bytes().any(|b| b > b'7') {
321            return Err(anyhow!("invalid octal mode {token:?} (digits must be 0-7)"));
322        }
323        let value = u32::from_str_radix(token, 8)
324            .with_context(|| format!("parsing octal mode {token:?}"))?;
325        if value > 0o7777 {
326            return Err(anyhow!("octal mode {token:?} out of range (max 0o7777)"));
327        }
328        return Ok(ModeSpec::Octal(value));
329    }
330    let clauses = token
331        .split(',')
332        .map(parse_symbolic_clause)
333        .collect::<anyhow::Result<Vec<_>>>()?;
334    Ok(ModeSpec::Symbolic(clauses))
335}
336
337fn parse_symbolic_clause(clause: &str) -> anyhow::Result<SymbolicClause> {
338    let op_pos = clause
339        .find(['+', '-', '='])
340        .ok_or_else(|| anyhow!("mode clause {clause:?} missing +, - or ="))?;
341    let (who_str, rest) = clause.split_at(op_pos);
342    let op = match &rest[..1] {
343        "+" => ModeOp::Add,
344        "-" => ModeOp::Remove,
345        "=" => ModeOp::Set,
346        _ => unreachable!("find guaranteed one of +-="),
347    };
348    let perms_str = &rest[1..];
349    let mut who = 0u8;
350    for ch in who_str.chars() {
351        who |= match ch {
352            'u' => WHO_U,
353            'g' => WHO_G,
354            'o' => WHO_O,
355            'a' => WHO_A,
356            other => {
357                return Err(anyhow!(
358                    "invalid 'who' {other:?} in {clause:?} (expected u/g/o/a)"
359                ));
360            }
361        };
362    }
363    if who == 0 {
364        who = WHO_A;
365    }
366    let mut perms = 0u8;
367    for ch in perms_str.chars() {
368        perms |= match ch {
369            'r' => PERM_R,
370            'w' => PERM_W,
371            'x' => PERM_X,
372            'X' => PERM_BIGX,
373            's' => PERM_S,
374            't' => PERM_T,
375            other => return Err(anyhow!("invalid permission {other:?} in {clause:?}")),
376        };
377    }
378    Ok(SymbolicClause { who, op, perms })
379}
380
381/// Parse a `--mode` DSL string. A bare token is the default for files+dirs;
382/// `f:`/`d:` sections override per type. `l:` is rejected (symlink mode bits
383/// are not settable on Linux).
384pub fn parse_mode_dsl(s: &str) -> anyhow::Result<ModeProgram> {
385    let mut prog = ModeProgram::default();
386    let mut bare: Option<ModeSpec> = None;
387    for clause in s.split_whitespace() {
388        if let Some((ty, rest)) = clause.split_once(':') {
389            let spec = parse_mode_token(rest)?;
390            match ty {
391                "f" | "file" => prog.file = Some(spec),
392                "d" | "dir" | "directory" => prog.dir = Some(spec),
393                "l" | "link" | "symlink" => {
394                    return Err(anyhow!(
395                        "symlink mode (l:) is not settable on Linux; remove the l: section"
396                    ));
397                }
398                _ => return Err(anyhow!("unknown type prefix {ty:?} (expected f:/d:)")),
399            }
400        } else if bare.is_some() {
401            return Err(anyhow!(
402                "multiple bare mode expressions in {s:?}; chain sub-ops with commas (e.g. g+r,o+w)"
403            ));
404        } else {
405            bare = Some(parse_mode_token(clause)?);
406        }
407    }
408    if let Some(b) = bare {
409        prog.file.get_or_insert(b.clone());
410        prog.dir.get_or_insert(b);
411    }
412    Ok(prog)
413}
414
415/// The concrete syscalls to perform for one entry. `chown` carries the
416/// `(uid, gid)` to pass to `fchownat` (each `None` means "leave unchanged");
417/// `chmod` is the target 12-bit mode. An all-`None` plan is a no-op.
418#[derive(Clone, Copy, Debug, PartialEq, Eq)]
419pub(crate) struct EntryPlan {
420    pub chown: Option<(Option<u32>, Option<u32>)>,
421    pub chmod: Option<u32>,
422}
423
424impl EntryPlan {
425    pub(crate) fn is_noop(&self) -> bool {
426        self.chown.is_none() && self.chmod.is_none()
427    }
428}
429
430/// Compute the plan for one entry from its current mode/uid/gid. Pure: performs
431/// no I/O. `cur_mode` is the full `st_mode` (only the low 12 bits are used).
432pub(crate) fn compute_plan(
433    cur_mode: u32,
434    cur_uid: u32,
435    cur_gid: u32,
436    kind: EntryKind,
437    settings: &Settings,
438) -> EntryPlan {
439    let cur_mode = cur_mode & 0o7777;
440    let uid_change = settings.owner.for_kind(kind).filter(|&u| u != cur_uid);
441    let gid_change = settings.group.for_kind(kind).filter(|&g| g != cur_gid);
442    let need_chown = uid_change.is_some() || gid_change.is_some();
443    let chown = need_chown.then_some((uid_change, gid_change));
444    let chmod = if kind == EntryKind::Symlink {
445        // symlink mode bits are not settable; never chmod a symlink
446        None
447    } else if let Some(spec) = settings.mode.for_kind(kind) {
448        let desired = apply_mode(cur_mode, spec, kind == EntryKind::Dir);
449        // chmod if the value changes, or a chown would clear setuid/setgid we keep.
450        // 0o6000 = setuid|setgid; fchownat does not clear the sticky bit (0o1000)
451        if desired != cur_mode || (need_chown && desired & 0o6000 != 0) {
452            Some(desired)
453        } else {
454            None
455        }
456    } else if need_chown && cur_mode & 0o6000 != 0 {
457        // no mode rule for this type, but chown clears setuid/setgid -> restore.
458        // 0o6000 = setuid|setgid; fchownat does not clear the sticky bit (0o1000)
459        Some(cur_mode)
460    } else {
461        None
462    };
463    EntryPlan { chown, chmod }
464}
465
466fn inc_changed(prog: &Progress, kind: EntryKind) -> Summary {
467    match kind {
468        EntryKind::Dir => {
469            prog.directories_changed.inc();
470            Summary {
471                directories_changed: 1,
472                ..Default::default()
473            }
474        }
475        EntryKind::Symlink => {
476            prog.symlinks_changed.inc();
477            Summary {
478                symlinks_changed: 1,
479                ..Default::default()
480            }
481        }
482        EntryKind::File | EntryKind::Special => {
483            prog.files_changed.inc();
484            Summary {
485                files_changed: 1,
486                ..Default::default()
487            }
488        }
489    }
490}
491
492fn inc_unchanged(prog: &Progress, kind: EntryKind) -> Summary {
493    match kind {
494        EntryKind::Dir => {
495            prog.directories_unchanged.inc();
496            Summary {
497                directories_unchanged: 1,
498                ..Default::default()
499            }
500        }
501        EntryKind::Symlink => {
502            prog.symlinks_unchanged.inc();
503            Summary {
504                symlinks_unchanged: 1,
505                ..Default::default()
506            }
507        }
508        EntryKind::File | EntryKind::Special => {
509            prog.files_unchanged.inc();
510            Summary {
511                files_unchanged: 1,
512                ..Default::default()
513            }
514        }
515    }
516}
517
518fn skipped_summary_for(kind: EntryKind) -> Summary {
519    match kind {
520        EntryKind::Dir => Summary {
521            directories_skipped: 1,
522            ..Default::default()
523        },
524        EntryKind::Symlink => Summary {
525            symlinks_skipped: 1,
526            ..Default::default()
527        },
528        EntryKind::File | EntryKind::Special => Summary {
529            files_skipped: 1,
530            ..Default::default()
531        },
532    }
533}
534
535/// Human-readable description of what a plan changes, for dry-run output.
536fn describe_change(cur_mode: u32, cur_uid: u32, cur_gid: u32, plan: &EntryPlan) -> String {
537    let mut parts = Vec::new();
538    if let Some(mode) = plan.chmod {
539        if mode == cur_mode & 0o7777 {
540            // chmod re-applied only to restore setuid/setgid that chown clears
541            parts.push(format!("mode {mode:04o} (re-applied after chown)"));
542        } else {
543            parts.push(format!("mode {:04o}->{:04o}", cur_mode & 0o7777, mode));
544        }
545    }
546    if let Some((uid, gid)) = plan.chown {
547        if let Some(uid) = uid {
548            parts.push(format!("owner {cur_uid}->{uid}"));
549        }
550        if let Some(gid) = gid {
551            parts.push(format!("group {cur_gid}->{gid}"));
552        }
553    }
554    parts.join(", ")
555}
556
557async fn apply_plan(path: &std::path::Path, plan: &EntryPlan) -> anyhow::Result<()> {
558    if let Some((uid, gid)) = plan.chown {
559        let dst = path.to_owned();
560        walk::run_metadata_probed(
561            congestion::Side::Destination,
562            congestion::MetadataOp::Chmod,
563            async {
564                tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
565                    nix::unistd::fchownat(
566                        nix::fcntl::AT_FDCWD,
567                        &dst,
568                        uid.map(Into::into),
569                        gid.map(Into::into),
570                        nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW,
571                    )
572                    .with_context(|| format!("failed to chown {dst:?}"))?;
573                    Ok(())
574                })
575                .await?
576            },
577        )
578        .await?;
579    }
580    if let Some(mode) = plan.chmod {
581        // path-based chmod (only ever called on non-symlinks); follows the entry
582        // itself. avoids an extra open() vs fchmod and matches rm's dir path.
583        walk::run_metadata_probed(
584            congestion::Side::Destination,
585            congestion::MetadataOp::Chmod,
586            tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)),
587        )
588        .await
589        .with_context(|| format!("failed to chmod {path:?} to {mode:04o}"))?;
590    }
591    Ok(())
592}
593
594/// Apply the change to a single entry (a leaf, or a directory after its children
595/// were processed). Handles the time filter, dry-run, no-op skip, and counters.
596async fn process_entry(
597    prog: &'static Progress,
598    path: &std::path::Path,
599    metadata: &std::fs::Metadata,
600    kind: EntryKind,
601    settings: &Settings,
602) -> Result<Summary, Error> {
603    if let Some(ref time_filter) = settings.time_filter {
604        match time_filter.matches(metadata) {
605            Ok(result) => {
606                if let Some(reason) = result.as_skip_reason() {
607                    if let Some(mode) = settings.dry_run {
608                        crate::dry_run::report_time_skip(path, reason, mode, kind.label());
609                    }
610                    kind.inc_skipped(prog);
611                    return Ok(skipped_summary_for(kind));
612                }
613            }
614            Err(err) => {
615                let err = err.context(format!("failed evaluating time filter on {path:?}"));
616                if settings.fail_early {
617                    return Err(Error::new(err, Default::default()));
618                }
619                tracing::warn!("time filter failed for {:?}, skipping: {:#}", path, &err);
620                kind.inc_skipped(prog);
621                return Ok(skipped_summary_for(kind));
622            }
623        }
624    }
625    let plan = compute_plan(
626        metadata.mode(),
627        metadata.uid(),
628        metadata.gid(),
629        kind,
630        settings,
631    );
632    if plan.is_noop() {
633        if let Some(crate::config::DryRunMode::All) = settings.dry_run {
634            println!("unchanged {} {:?}", kind.label(), path);
635        }
636        return Ok(inc_unchanged(prog, kind));
637    }
638    if settings.dry_run.is_some() {
639        let desc = describe_change(metadata.mode(), metadata.uid(), metadata.gid(), &plan);
640        println!("would modify {} {:?}: {}", kind.label(), path, desc);
641        return Ok(inc_changed(prog, kind));
642    }
643    apply_plan(path, &plan)
644        .await
645        .map_err(|err| Error::new(err, Default::default()))?;
646    Ok(inc_changed(prog, kind))
647}
648
649/// Strip trailing path separators from a root operand. A trailing slash forces the
650/// OS to resolve the final component as a directory, which would dereference a symlink
651/// root like `link/` (following it to its target) -- violating the non-dereference
652/// behavior. Stripping makes `link/` behave like `link` (the symlink itself).
653/// (This non-dereference behavior is not robust against concurrent path replacement
654/// under elevated privilege; see `docs/tocttou.md`.)
655fn without_trailing_separators(path: &std::path::Path) -> std::path::PathBuf {
656    use std::os::unix::ffi::OsStrExt;
657    let bytes = path.as_os_str().as_bytes();
658    let mut end = bytes.len();
659    while end > 1 && bytes[end - 1] == b'/' {
660        end -= 1;
661    }
662    std::path::PathBuf::from(std::ffi::OsStr::from_bytes(&bytes[..end]))
663}
664
665/// Public entry point. Applies metadata changes to `path` and, recursively, its
666/// contents. Mirrors [`crate::rm::rm`] for the root-filter check.
667#[instrument(skip(prog_track, settings))]
668pub async fn chmod(
669    prog_track: &'static Progress,
670    path: &std::path::Path,
671    settings: &Settings,
672) -> Result<Summary, Error> {
673    let stripped = without_trailing_separators(path);
674    let path = stripped.as_path();
675    if let Some(ref filter) = settings.filter
676        && let Some(name) = path.file_name().map(std::path::Path::new)
677    {
678        let metadata = walk::run_metadata_probed(
679            congestion::Side::Source,
680            congestion::MetadataOp::Stat,
681            tokio::fs::symlink_metadata(path),
682        )
683        .await
684        .with_context(|| format!("failed reading metadata from {path:?}"))
685        .map_err(|err| Error::new(err, Default::default()))?;
686        match filter.should_include_root_item(name, metadata.is_dir()) {
687            crate::filter::FilterResult::Included => {}
688            result => {
689                let kind = EntryKind::from_metadata(&metadata);
690                if let Some(mode) = settings.dry_run {
691                    crate::dry_run::report_skip(path, &result, mode, kind.label_long());
692                }
693                kind.inc_skipped(prog_track);
694                return Ok(skipped_summary_for(kind));
695            }
696        }
697    }
698    chmod_internal(prog_track, path, path, settings).await
699}
700
701/// Apply the directory's own change, or skip it when the directory was only traversed to
702/// find include-matches. Factored out so the walk can run it before (pre-order, default)
703/// or after (post-order, `--defer-dir-changes`) descending into the contents.
704async fn apply_dir_self(
705    prog_track: &'static Progress,
706    path: &std::path::Path,
707    metadata: &std::fs::Metadata,
708    traversed_only: bool,
709    settings: &Settings,
710) -> Result<Summary, Error> {
711    if traversed_only {
712        if let Some(crate::config::DryRunMode::All) = settings.dry_run {
713            println!("skip dir {path:?} (only traversed for include matches)");
714        }
715        prog_track.directories_skipped.inc();
716        return Ok(skipped_summary_for(EntryKind::Dir));
717    }
718    process_entry(prog_track, path, metadata, EntryKind::Dir, settings).await
719}
720
721#[instrument(skip(prog_track, settings))]
722#[async_recursion]
723async fn chmod_internal(
724    prog_track: &'static Progress,
725    path: &std::path::Path,
726    source_root: &std::path::Path,
727    settings: &Settings,
728) -> Result<Summary, Error> {
729    let _ops_guard = prog_track.ops.guard();
730    let metadata = walk::run_metadata_probed(
731        congestion::Side::Source,
732        congestion::MetadataOp::Stat,
733        tokio::fs::symlink_metadata(path),
734    )
735    .await
736    .with_context(|| format!("failed reading metadata from {path:?}"))
737    .map_err(|err| Error::new(err, Default::default()))?;
738    let kind = EntryKind::from_metadata(&metadata);
739    if kind != EntryKind::Dir {
740        return process_entry(prog_track, path, &metadata, kind, settings).await;
741    }
742    // a directory may have been entered only because it *could contain* include-matches,
743    // not because it directly matches an include pattern. such "traversed-only" dirs are
744    // not modified themselves (mirrors rm.rs) -- only entries the filter directly selects.
745    let relative_path = walk::relative_to_root(path, source_root);
746    let traversed_only = settings
747        .filter
748        .as_ref()
749        .is_some_and(|f| f.has_includes() && !f.directly_matches_include(relative_path, true));
750    let errors = crate::error_collector::ErrorCollector::default();
751    let mut summary = Summary::default();
752    // pre-order (default, like `chmod -R`): change the directory BEFORE descending, so
753    // `--mode d:u+rwx` can recover an unreadable directory and a child failure can't
754    // prevent the directory's own change.
755    if !settings.defer_dir_changes {
756        match apply_dir_self(prog_track, path, &metadata, traversed_only, settings).await {
757            Ok(dir_summary) => summary = summary + dir_summary,
758            Err(error) => {
759                if settings.fail_early {
760                    return Err(Error::new(error.source, summary + error.summary));
761                }
762                tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
763                summary = summary + error.summary;
764                errors.push(error.source);
765            }
766        }
767    }
768    // descend into the directory's contents
769    match tokio::fs::read_dir(path).await {
770        Ok(mut entries) => {
771            let mut join_set = tokio::task::JoinSet::new();
772            loop {
773                let (entry, entry_file_type) =
774                    match walk::next_entry_probed(&mut entries, congestion::Side::Source, || {
775                        format!("failed traversing directory {path:?}")
776                    })
777                    .await
778                    {
779                        Ok(Some(entry)) => entry,
780                        Ok(None) => break,
781                        Err(error) => {
782                            if settings.fail_early {
783                                return Err(Error::new(error, summary));
784                            }
785                            tracing::error!("chmod: {:#}", &error);
786                            errors.push(error);
787                            break;
788                        }
789                    };
790                let entry_path = entry.path();
791                let entry_kind = EntryKind::from_file_type(entry_file_type.as_ref());
792                let relative_path = walk::relative_to_root(&entry_path, source_root);
793                if let Some(skip_result) = walk::should_skip_entry(
794                    &settings.filter,
795                    relative_path,
796                    entry_kind == EntryKind::Dir,
797                ) {
798                    if let Some(mode) = settings.dry_run {
799                        crate::dry_run::report_skip(
800                            &entry_path,
801                            &skip_result,
802                            mode,
803                            entry_kind.label(),
804                        );
805                    }
806                    entry_kind.inc_skipped(prog_track);
807                    summary = summary + skipped_summary_for(entry_kind);
808                    continue;
809                }
810                let settings = settings.clone();
811                let source_root = source_root.to_owned();
812                let known_leaf = entry_file_type.as_ref().is_some_and(|ft| !ft.is_dir());
813                let pending_guard = if known_leaf {
814                    Some(throttle::pending_meta_permit().await)
815                } else {
816                    None
817                };
818                join_set.spawn(async move {
819                    let _pending_guard = pending_guard;
820                    chmod_internal(prog_track, &entry_path, &source_root, &settings).await
821                });
822            }
823            drop(entries);
824            while let Some(res) = join_set.join_next().await {
825                match res {
826                    Ok(Ok(child)) => summary = summary + child,
827                    Ok(Err(error)) => {
828                        tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
829                        summary = summary + error.summary;
830                        errors.push(error.source);
831                        if settings.fail_early {
832                            break;
833                        }
834                    }
835                    Err(error) => {
836                        errors.push(error.into());
837                        if settings.fail_early {
838                            break;
839                        }
840                    }
841                }
842            }
843        }
844        Err(read_error) => {
845            // couldn't read the directory -- e.g. owner r/x was just removed (a pre-order
846            // restrictive change) or it was already unreadable with no traversability-
847            // restoring rule. report it; unless --fail-early, keep going with the rest.
848            let error = anyhow::Error::new(read_error)
849                .context(format!("failed reading directory {path:?}"));
850            if settings.fail_early {
851                return Err(Error::new(error, summary));
852            }
853            tracing::error!("chmod: {:#}", &error);
854            errors.push(error);
855        }
856    }
857    // under --fail-early, a child failure broke the join loop above; stop here, before any
858    // deferred parent change -- never apply more changes after the error we were asked to
859    // stop on. (read_dir / next_entry failures already returned directly.)
860    if settings.fail_early && errors.has_errors() {
861        return Err(Error::new(errors.into_error().unwrap(), summary));
862    }
863    // post-order (`--defer-dir-changes`): change the directory AFTER its contents -- needed
864    // when recursively removing the owner's own traversal permission. in keep-going mode it
865    // is applied even after a child failure; fail-early returned just above.
866    if settings.defer_dir_changes {
867        match apply_dir_self(prog_track, path, &metadata, traversed_only, settings).await {
868            Ok(dir_summary) => summary = summary + dir_summary,
869            Err(error) => {
870                if settings.fail_early {
871                    return Err(Error::new(error.source, summary + error.summary));
872                }
873                tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
874                summary = summary + error.summary;
875                errors.push(error.source);
876            }
877        }
878    }
879    if errors.has_errors() {
880        return Err(Error::new(errors.into_error().unwrap(), summary));
881    }
882    Ok(summary)
883}
884
885#[cfg(test)]
886mod tests {
887    use super::*;
888    #[test]
889    fn mode_token_octal() {
890        assert_eq!(parse_mode_token("2775").unwrap(), ModeSpec::Octal(0o2775));
891        assert_eq!(parse_mode_token("0644").unwrap(), ModeSpec::Octal(0o644));
892    }
893    #[test]
894    fn mode_token_octal_out_of_range_errors() {
895        assert!(parse_mode_token("9999").is_err()); // 9 is not octal
896        assert!(parse_mode_token("77777").is_err()); // > 12 bits
897    }
898    #[test]
899    fn mode_token_symbolic_simple() {
900        let spec = parse_mode_token("g+w").unwrap();
901        assert_eq!(
902            spec,
903            ModeSpec::Symbolic(vec![SymbolicClause {
904                who: WHO_G,
905                op: ModeOp::Add,
906                perms: PERM_W
907            }])
908        );
909    }
910    #[test]
911    fn mode_token_symbolic_omitted_who_means_all() {
912        let spec = parse_mode_token("+x").unwrap();
913        assert_eq!(
914            spec,
915            ModeSpec::Symbolic(vec![SymbolicClause {
916                who: WHO_A,
917                op: ModeOp::Add,
918                perms: PERM_X
919            }])
920        );
921    }
922    #[test]
923    fn mode_token_symbolic_comma_chained() {
924        let spec = parse_mode_token("u+rw,g-w").unwrap();
925        let ModeSpec::Symbolic(clauses) = spec else {
926            panic!("expected symbolic")
927        };
928        assert_eq!(clauses.len(), 2);
929        assert_eq!(
930            clauses[0],
931            SymbolicClause {
932                who: WHO_U,
933                op: ModeOp::Add,
934                perms: PERM_R | PERM_W
935            }
936        );
937        assert_eq!(
938            clauses[1],
939            SymbolicClause {
940                who: WHO_G,
941                op: ModeOp::Remove,
942                perms: PERM_W
943            }
944        );
945    }
946    #[test]
947    fn mode_token_symbolic_bigx_and_specials() {
948        let spec = parse_mode_token("g+rwXs").unwrap();
949        assert_eq!(
950            spec,
951            ModeSpec::Symbolic(vec![SymbolicClause {
952                who: WHO_G,
953                op: ModeOp::Add,
954                perms: PERM_R | PERM_W | PERM_BIGX | PERM_S,
955            }])
956        );
957    }
958    #[test]
959    fn mode_token_rejects_garbage() {
960        assert!(parse_mode_token("q+z").is_err());
961        assert!(parse_mode_token("g!w").is_err());
962        assert!(parse_mode_token("").is_err());
963    }
964    #[test]
965    fn summary_add_combines_fields() {
966        let a = Summary {
967            files_changed: 1,
968            directories_changed: 2,
969            files_unchanged: 3,
970            ..Default::default()
971        };
972        let b = Summary {
973            files_changed: 10,
974            symlinks_skipped: 4,
975            ..Default::default()
976        };
977        let sum = a + b;
978        assert_eq!(sum.files_changed, 11);
979        assert_eq!(sum.directories_changed, 2);
980        assert_eq!(sum.files_unchanged, 3);
981        assert_eq!(sum.symlinks_skipped, 4);
982    }
983    #[test]
984    fn owner_dsl_bare_applies_to_all_types() {
985        let prog = parse_owner_dsl("0", IdKind::User).unwrap();
986        assert_eq!(prog.file, Some(0));
987        assert_eq!(prog.dir, Some(0));
988        assert_eq!(prog.symlink, Some(0));
989    }
990    #[test]
991    fn owner_dsl_per_type_overrides() {
992        let prog = parse_owner_dsl("f:1 d:2", IdKind::User).unwrap();
993        assert_eq!(prog.file, Some(1));
994        assert_eq!(prog.dir, Some(2));
995        assert_eq!(prog.symlink, None);
996    }
997    #[test]
998    fn owner_dsl_bare_plus_override() {
999        let prog = parse_owner_dsl("5 d:2", IdKind::Group).unwrap();
1000        assert_eq!(prog.file, Some(5));
1001        assert_eq!(prog.dir, Some(2));
1002        assert_eq!(prog.symlink, Some(5));
1003    }
1004    #[test]
1005    fn owner_dsl_explicit_before_bare_is_order_independent() {
1006        let prog = parse_owner_dsl("f:1 5", IdKind::User).unwrap();
1007        assert_eq!(prog.file, Some(1));
1008        assert_eq!(prog.dir, Some(5));
1009        assert_eq!(prog.symlink, Some(5));
1010    }
1011    #[test]
1012    fn owner_dsl_rejects_multiple_bare() {
1013        assert!(parse_owner_dsl("1 2", IdKind::User).is_err());
1014    }
1015    #[test]
1016    fn owner_dsl_rejects_unknown_id() {
1017        assert!(parse_owner_dsl("definitely-no-such-group-xyz", IdKind::Group).is_err());
1018    }
1019    fn sym(s: &str) -> ModeSpec {
1020        parse_mode_token(s).unwrap()
1021    }
1022    #[test]
1023    fn apply_mode_group_add_remove() {
1024        assert_eq!(apply_mode(0o644, &sym("g+w"), false), 0o664);
1025        assert_eq!(apply_mode(0o664, &sym("g-w"), false), 0o644);
1026    }
1027    #[test]
1028    fn apply_mode_set_clears_other_bits() {
1029        // o= clears all 'other' bits (incl sticky), leaves user/group
1030        assert_eq!(apply_mode(0o755, &sym("o="), false), 0o750);
1031        // chained absolute-ish set from zero
1032        assert_eq!(apply_mode(0o000, &sym("u=rwx,go=rx"), false), 0o755);
1033        // o= on a sticky file clears the sticky bit too
1034        assert_eq!(apply_mode(0o1755, &sym("o="), false), 0o0750);
1035    }
1036    #[test]
1037    fn apply_mode_conditional_bigx() {
1038        // file without execute: X does nothing
1039        assert_eq!(apply_mode(0o644, &sym("a+X"), false), 0o644);
1040        // file with a user-execute bit: X applies to all
1041        assert_eq!(apply_mode(0o744, &sym("a+X"), false), 0o755);
1042        // directory: X always applies
1043        assert_eq!(apply_mode(0o644, &sym("a+X"), true), 0o755);
1044    }
1045    #[test]
1046    fn apply_mode_setgid_and_sticky() {
1047        assert_eq!(apply_mode(0o750, &sym("g+rwxs"), true), 0o2770);
1048        assert_eq!(apply_mode(0o755, &sym("+t"), true), 0o1755);
1049        assert_eq!(apply_mode(0o755, &sym("u+s"), false), 0o4755);
1050    }
1051    #[test]
1052    fn apply_mode_sticky_only_responds_to_other() {
1053        // u+t / g+t are no-ops; only o/a/bare set sticky (verified against real chmod)
1054        assert_eq!(apply_mode(0o755, &sym("u+t"), false), 0o755);
1055        assert_eq!(apply_mode(0o755, &sym("g+t"), false), 0o755);
1056        assert_eq!(apply_mode(0o755, &sym("ug+t"), false), 0o755);
1057        assert_eq!(apply_mode(0o755, &sym("o+t"), false), 0o1755);
1058        assert_eq!(apply_mode(0o755, &sym("+t"), false), 0o1755);
1059        assert_eq!(apply_mode(0o1755, &sym("u-t"), false), 0o1755);
1060        assert_eq!(apply_mode(0o1755, &sym("o-t"), false), 0o755);
1061    }
1062    #[test]
1063    fn apply_mode_octal_is_absolute() {
1064        assert_eq!(apply_mode(0o4755, &sym("644"), false), 0o644);
1065        assert_eq!(apply_mode(0o000, &sym("2775"), true), 0o2775);
1066    }
1067    #[test]
1068    fn mode_dsl_bare_applies_to_file_and_dir_not_symlink() {
1069        let prog = parse_mode_dsl("g+rwX").unwrap();
1070        assert!(prog.file.is_some());
1071        assert!(prog.dir.is_some());
1072        // symlinks are not part of ModeProgram at all
1073        assert!(prog.for_kind(EntryKind::Symlink).is_none());
1074    }
1075    #[test]
1076    fn mode_dsl_per_type() {
1077        let prog = parse_mode_dsl("f:g+rw d:g+rwxs").unwrap();
1078        assert_eq!(prog.file, Some(sym("g+rw")));
1079        assert_eq!(prog.dir, Some(sym("g+rwxs")));
1080    }
1081    #[test]
1082    fn mode_dsl_bare_plus_override() {
1083        let prog = parse_mode_dsl("g+r d:g+rwx").unwrap();
1084        assert_eq!(prog.file, Some(sym("g+r")));
1085        assert_eq!(prog.dir, Some(sym("g+rwx")));
1086    }
1087    #[test]
1088    fn mode_dsl_rejects_symlink_section() {
1089        assert!(parse_mode_dsl("l:g+w").is_err());
1090    }
1091    #[test]
1092    fn mode_dsl_rejects_multiple_bare() {
1093        assert!(parse_mode_dsl("g+r o+w").is_err());
1094    }
1095    #[test]
1096    fn mode_dsl_rejects_unknown_prefix() {
1097        assert!(parse_mode_dsl("z:644").is_err());
1098    }
1099    #[test]
1100    fn mode_dsl_single_type_leaves_other_none() {
1101        let prog_f = parse_mode_dsl("f:644").unwrap();
1102        assert!(prog_f.file.is_some());
1103        assert!(prog_f.dir.is_none());
1104        let prog_d = parse_mode_dsl("d:755").unwrap();
1105        assert!(prog_d.dir.is_some());
1106        assert!(prog_d.file.is_none());
1107    }
1108    fn settings_with(mode: &str, owner: Option<&str>, group: Option<&str>) -> Settings {
1109        Settings {
1110            mode: if mode.is_empty() {
1111                ModeProgram::default()
1112            } else {
1113                parse_mode_dsl(mode).unwrap()
1114            },
1115            owner: owner
1116                .map(|s| parse_owner_dsl(s, IdKind::User).unwrap())
1117                .unwrap_or_default(),
1118            group: group
1119                .map(|s| parse_owner_dsl(s, IdKind::Group).unwrap())
1120                .unwrap_or_default(),
1121            fail_early: false,
1122            defer_dir_changes: false,
1123            filter: None,
1124            time_filter: None,
1125            dry_run: None,
1126        }
1127    }
1128    #[test]
1129    fn plan_noop_when_already_correct() {
1130        let s = settings_with("g+r", None, None);
1131        // file already group-readable
1132        let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1133        assert!(plan.is_noop());
1134    }
1135    #[test]
1136    fn plan_chmod_when_mode_differs() {
1137        let s = settings_with("g+w", None, None);
1138        let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1139        assert_eq!(plan.chmod, Some(0o664));
1140        assert!(plan.chown.is_none());
1141    }
1142    #[test]
1143    fn plan_chown_only_changed_ids() {
1144        let s = settings_with("", None, Some("2000"));
1145        let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1146        // gid changes 1000 -> 2000, uid untouched
1147        assert_eq!(plan.chown, Some((None, Some(2000))));
1148        assert!(plan.chmod.is_none());
1149    }
1150    #[test]
1151    fn plan_preserves_setgid_across_chgrp() {
1152        // file is setgid (0o2755), only --group given; chown clears setgid, so we
1153        // must re-chmod to the original mode to keep it.
1154        let s = settings_with("", None, Some("2000"));
1155        let plan = compute_plan(0o2755, 1000, 1000, EntryKind::File, &s);
1156        assert_eq!(plan.chown, Some((None, Some(2000))));
1157        assert_eq!(plan.chmod, Some(0o2755));
1158    }
1159    #[test]
1160    fn plan_symlink_never_chmods_but_chowns() {
1161        let s = settings_with("g+w", None, Some("2000"));
1162        let plan = compute_plan(0o777, 1000, 1000, EntryKind::Symlink, &s);
1163        assert!(plan.chmod.is_none());
1164        assert_eq!(plan.chown, Some((None, Some(2000))));
1165    }
1166    #[test]
1167    fn plan_preserves_setuid_when_mode_rule_noop_but_chown_runs() {
1168        // g+r is a no-op on 0o4755 (group already readable), but the chown clears
1169        // setuid, so the mode-rule branch must still emit a chmod to restore it.
1170        let s = settings_with("g+r", Some("2000"), None);
1171        let plan = compute_plan(0o4755, 1000, 1000, EntryKind::File, &s);
1172        assert_eq!(plan.chown, Some((Some(2000), None)));
1173        assert_eq!(plan.chmod, Some(0o4755));
1174    }
1175    #[test]
1176    fn plan_preserves_setgid_dir_across_chgrp() {
1177        // setgid dir, only --group given; chown clears setgid so chmod restores it.
1178        let s = settings_with("", None, Some("2000"));
1179        let plan = compute_plan(0o2770, 1000, 1000, EntryKind::Dir, &s);
1180        assert_eq!(plan.chown, Some((None, Some(2000))));
1181        assert_eq!(plan.chmod, Some(0o2770));
1182    }
1183}