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::preserve::Metadata as _;
7use crate::progress::Progress;
8use crate::safedir::{self, Dir, FileMeta, Handle};
9use crate::walk::{EntryKind, LeafPermit, PermitKind};
10use crate::walk_driver::{
11    DirAction, DirPreResult, EntryCx, ProcessedChildren, WalkVisitor, process_entry,
12};
13use anyhow::{Context, anyhow};
14use std::ffi::OsStr;
15use std::os::unix::fs::PermissionsExt;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use tracing::instrument;
19
20const SETID_BITS: u32 = 0o6000;
21
22/// The full 12-bit (`0o7777`) mode of a metadata snapshot, including the
23/// setuid/setgid/sticky bits. [`FileMeta`] exposes its mode only through
24/// `permissions()`, so this is the canonical way `compute_plan`'s inputs are
25/// derived from an fd-pinned [`Handle`].
26fn mode_of(meta: &FileMeta) -> u32 {
27    meta.permissions().mode() & 0o7777
28}
29
30/// Error type for chmod operations. See [`crate::error::OperationError`].
31pub type Error = crate::error::OperationError<Summary>;
32
33/// Which user/group id (if any) to apply to each entry type. `None` leaves that
34/// type unchanged for this operation.
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36pub struct OwnerProgram {
37    pub file: Option<u32>,
38    pub dir: Option<u32>,
39    pub symlink: Option<u32>,
40}
41
42impl OwnerProgram {
43    #[must_use]
44    pub fn for_kind(&self, kind: EntryKind) -> Option<u32> {
45        match kind {
46            EntryKind::Dir => self.dir,
47            EntryKind::Symlink => self.symlink,
48            EntryKind::File | EntryKind::Special => self.file,
49        }
50    }
51    #[must_use]
52    pub fn is_empty(&self) -> bool {
53        self.file.is_none() && self.dir.is_none() && self.symlink.is_none()
54    }
55}
56
57/// A parsed `chmod` mode expression: either a symbolic program (applied relative
58/// to the current mode) or an absolute octal value (12-bit).
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub enum ModeSpec {
61    Symbolic(Vec<SymbolicClause>),
62    Octal(u32),
63}
64
65/// One `[ugoa][+-=][rwxXst]` clause. `who`/`perms` are bitmasks (see `WHO_*` /
66/// `PERM_*`).
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub struct SymbolicClause {
69    pub who: u8,
70    pub op: ModeOp,
71    pub perms: u8,
72}
73
74/// The operator in a symbolic mode clause: add, remove, or set permissions.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum ModeOp {
77    Add,
78    Remove,
79    Set,
80}
81
82pub(crate) const WHO_U: u8 = 0b001;
83pub(crate) const WHO_G: u8 = 0b010;
84pub(crate) const WHO_O: u8 = 0b100;
85pub(crate) const WHO_A: u8 = WHO_U | WHO_G | WHO_O;
86pub(crate) const PERM_R: u8 = 0b00_0001;
87pub(crate) const PERM_W: u8 = 0b00_0010;
88pub(crate) const PERM_X: u8 = 0b00_0100;
89pub(crate) const PERM_BIGX: u8 = 0b00_1000;
90pub(crate) const PERM_S: u8 = 0b01_0000;
91pub(crate) const PERM_T: u8 = 0b10_0000;
92
93/// Per-type mode rules. Symlinks are never included (mode bits aren't settable
94/// on Linux symlinks).
95#[derive(Clone, Debug, Default, PartialEq, Eq)]
96pub struct ModeProgram {
97    pub file: Option<ModeSpec>,
98    pub dir: Option<ModeSpec>,
99}
100
101impl ModeProgram {
102    #[must_use]
103    pub fn for_kind(&self, kind: EntryKind) -> Option<&ModeSpec> {
104        match kind {
105            EntryKind::Dir => self.dir.as_ref(),
106            EntryKind::Symlink => None,
107            EntryKind::File | EntryKind::Special => self.file.as_ref(),
108        }
109    }
110    #[must_use]
111    pub fn is_empty(&self) -> bool {
112        self.file.is_none() && self.dir.is_none()
113    }
114}
115
116/// Configuration for a recursive chmod/chgrp/chown run.
117#[derive(Clone, Debug)]
118pub struct Settings {
119    pub mode: ModeProgram,
120    pub owner: OwnerProgram,
121    pub group: OwnerProgram,
122    /// Clear set-user-ID and set-group-ID from every selected non-symlink's final mode.
123    pub no_setid: bool,
124    pub fail_early: bool,
125    /// Apply directory mode/owner changes after their contents (post-order) instead of
126    /// before (the default). Needed when recursively removing the owner's own traversal
127    /// permission from directories.
128    pub defer_dir_changes: bool,
129    pub filter: Option<crate::filter::FilterSettings>,
130    pub time_filter: Option<TimeFilter>,
131    pub dry_run: Option<crate::config::DryRunMode>,
132}
133
134#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
135pub struct Summary {
136    pub files_changed: usize,
137    pub symlinks_changed: usize,
138    pub directories_changed: usize,
139    pub files_unchanged: usize,
140    pub symlinks_unchanged: usize,
141    pub directories_unchanged: usize,
142    pub files_skipped: usize,
143    pub symlinks_skipped: usize,
144    pub directories_skipped: usize,
145}
146
147impl std::ops::Add for Summary {
148    type Output = Self;
149    fn add(self, other: Self) -> Self {
150        Self {
151            files_changed: self.files_changed + other.files_changed,
152            symlinks_changed: self.symlinks_changed + other.symlinks_changed,
153            directories_changed: self.directories_changed + other.directories_changed,
154            files_unchanged: self.files_unchanged + other.files_unchanged,
155            symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
156            directories_unchanged: self.directories_unchanged + other.directories_unchanged,
157            files_skipped: self.files_skipped + other.files_skipped,
158            symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
159            directories_skipped: self.directories_skipped + other.directories_skipped,
160        }
161    }
162}
163
164impl std::fmt::Display for Summary {
165    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
166        write!(
167            f,
168            "files changed: {}\n\
169            symlinks changed: {}\n\
170            directories changed: {}\n\
171            files unchanged: {}\n\
172            symlinks unchanged: {}\n\
173            directories unchanged: {}\n\
174            files skipped: {}\n\
175            symlinks skipped: {}\n\
176            directories skipped: {}\n",
177            self.files_changed,
178            self.symlinks_changed,
179            self.directories_changed,
180            self.files_unchanged,
181            self.symlinks_unchanged,
182            self.directories_unchanged,
183            self.files_skipped,
184            self.symlinks_skipped,
185            self.directories_skipped
186        )
187    }
188}
189
190/// Whether a DSL id token refers to a user or group.
191#[derive(Clone, Copy, Debug, PartialEq, Eq)]
192pub enum IdKind {
193    User,
194    Group,
195}
196
197impl IdKind {
198    /// The getent database to query for this kind.
199    fn getent_database(self) -> &'static str {
200        match self {
201            IdKind::User => "passwd",
202            IdKind::Group => "group",
203        }
204    }
205    /// Human label for error messages.
206    fn label(self) -> &'static str {
207        match self {
208            IdKind::User => "user",
209            IdKind::Group => "group",
210        }
211    }
212}
213
214/// Resolve a DSL id token to a numeric id. All-numeric tokens are used directly;
215/// otherwise the token is looked up as a user/group name (matching `chown`/`chgrp`):
216/// first in-process (reads /etc/passwd & /etc/group; full NSS on dynamic-glibc
217/// builds), then via the host `getent` tool (located per `getent`), whose full NSS
218/// sees directory-service (LDAP/SSSD/NIS) names that static musl builds cannot.
219fn resolve_id(token: &str, kind: IdKind, getent: &GetentResolver) -> anyhow::Result<u32> {
220    if let Ok(n) = token.parse::<u32>() {
221        return Ok(n);
222    }
223    let in_process = match kind {
224        IdKind::User => {
225            nix::unistd::User::from_name(token).map(|user| user.map(|u| u.uid.as_raw()))
226        }
227        IdKind::Group => {
228            nix::unistd::Group::from_name(token).map(|group| group.map(|g| g.gid.as_raw()))
229        }
230    };
231    match in_process {
232        Ok(Some(id)) => Ok(id),
233        // a miss or an in-process lookup error both fall through to getent: the host
234        // tool is the authoritative lookup (full NSS), and on a real miss its
235        // "unknown user/group" verdict is the one worth reporting.
236        Ok(None) | Err(_) => resolve_via_getent(token, kind, getent),
237    }
238}
239
240/// `getent` exit status for "key not found" (glibc convention; other getent
241/// implementations may report a miss differently — those land in the generic
242/// failure arm, which still errors out but with the raw exit status).
243const GETENT_NOT_FOUND: i32 = 2;
244
245/// Resolve a name through the host `getent` tool. The host's getent is linked
246/// against the host libc with full NSS, so it sees directory-service entries
247/// (LDAP/SSSD/NIS) that are invisible to the in-process lookup in static builds
248/// (musl has no NSS and reads only /etc/passwd and /etc/group).
249///
250/// The binary to spawn comes from `getent` (see [`GetentResolver`]): an explicit
251/// `--getent-path`, a trusted-directory probe when privileged, or a normal PATH
252/// search when unprivileged. PATH is consulted only in that last, unprivileged case.
253fn resolve_via_getent(token: &str, kind: IdKind, getent: &GetentResolver) -> anyhow::Result<u32> {
254    match getent.program()? {
255        Some(path) => resolve_via_getent_cmd(path.as_os_str(), token, kind),
256        None => resolve_via_getent_cmd(OsStr::new("getent"), token, kind),
257    }
258}
259
260/// The [`resolve_via_getent`] body with the getent program injectable for tests.
261///
262/// `getent_program` is passed to [`std::process::Command::new`] as an `OsStr` — never
263/// lossily stringified first — so an exact `--getent-path` is spawned byte-for-byte even
264/// if it is not valid UTF-8. The lossy form is used only to render diagnostics.
265fn resolve_via_getent_cmd(
266    getent_program: &OsStr,
267    token: &str,
268    kind: IdKind,
269) -> anyhow::Result<u32> {
270    let database = kind.getent_database();
271    let label = kind.label();
272    // diagnostics only — the spawn above uses the OsStr verbatim.
273    let prog = getent_program.to_string_lossy();
274    // `--` terminates getent's option parsing, so a name that looks like an option (e.g.
275    // `--service=files`, reachable via `--group=--service=files`) is treated as the lookup
276    // key, not an option. Without it, GNU getent would honor the option, print the whole
277    // database, and this parser would take the first line's id — silently resolving a bogus
278    // name to uid/gid 0 instead of failing.
279    let output = std::process::Command::new(getent_program)
280        .args(["--", database, token])
281        .output()
282        .with_context(|| {
283            format!(
284                "cannot run `{prog} {database} {token}` to look up the {label} name; \
285                 use a numeric id instead"
286            )
287        })?;
288    if output.status.code() == Some(GETENT_NOT_FOUND) {
289        return Err(anyhow!("unknown {label}: {token}"));
290    }
291    if !output.status.success() {
292        let status = output.status;
293        let stderr = String::from_utf8_lossy(&output.stderr);
294        let stderr = stderr.trim();
295        let detail = if stderr.is_empty() {
296            String::new()
297        } else {
298            format!(": {stderr}")
299        };
300        return Err(anyhow!(
301            "`{prog} {database} {token}` failed with {status}{detail}; \
302             use a numeric id instead"
303        ));
304    }
305    let stdout = String::from_utf8_lossy(&output.stdout);
306    let line = stdout
307        .lines()
308        .next()
309        .ok_or_else(|| anyhow!("`{prog} {database} {token}` produced no output"))?;
310    parse_getent_id(line).with_context(|| format!("unexpected getent output {line:?}"))
311}
312
313/// Directories searched for `getent` when running privileged, in order. PATH is
314/// **not** consulted in that case: a privileged `rchm` (e.g. via sudo) must never exec
315/// a binary that an unprivileged caller could have planted earlier on PATH. These dirs
316/// are root-owned on a sane system; `/run/current-system/sw/bin` covers NixOS, where
317/// system tools do not live in `/usr/bin`.
318const TRUSTED_GETENT_DIRS: &[&str] = &["/usr/bin", "/bin", "/run/current-system/sw/bin"];
319
320/// Whether this process runs with elevated privilege: effective-root, or a setuid
321/// mismatch (real ≠ effective uid). rchm is not installed setuid, but the mismatch
322/// check is cheap defense-in-depth. The check keys on uid, not Linux capabilities, so a
323/// `CAP_CHOWN` deployment without effective-root is treated as unprivileged (there the
324/// PATH is the operator's own, not a third party's).
325#[must_use]
326pub fn is_privileged() -> bool {
327    let euid = nix::unistd::geteuid();
328    euid.as_raw() == 0 || nix::unistd::getuid() != euid
329}
330
331/// Decides which `getent` binary name resolution spawns, hardened against PATH
332/// attacks when privileged. Built once from the CLI via [`GetentResolver::from_cli`].
333///
334/// The decision is deliberately *not* made at construction time: a numeric-only
335/// invocation (`--owner 0`) never needs `getent`, so the trusted-directory probe — and
336/// its "getent not found" error — must not fire then. The probe runs only when a name is
337/// actually looked up.
338#[derive(Clone, Debug)]
339pub struct GetentResolver {
340    /// An explicit `--getent-path`, validated absolute. Used verbatim, bypassing PATH.
341    explicit: Option<PathBuf>,
342    /// Whether to harden the unset case (privileged → trusted-dir probe, not PATH).
343    privileged: bool,
344}
345
346/// The unprivileged, no-override default: a normal PATH search. Used by tests and as a
347/// sensible base; production builds the resolver from the CLI via [`GetentResolver::from_cli`].
348impl Default for GetentResolver {
349    fn default() -> Self {
350        Self {
351            explicit: None,
352            privileged: false,
353        }
354    }
355}
356
357impl GetentResolver {
358    /// Build from the parsed CLI. `getent_path` is an explicit `--getent-path` (already
359    /// reduced to at most one by the caller); `privileged` is [`is_privileged`]. A
360    /// relative `--getent-path` is rejected here (fail-fast) — a relative program would
361    /// re-introduce a PATH/cwd lookup, defeating the point.
362    pub fn from_cli(getent_path: Option<PathBuf>, privileged: bool) -> anyhow::Result<Self> {
363        if let Some(path) = &getent_path
364            && !path.is_absolute()
365        {
366            return Err(anyhow!(
367                "--getent-path must be an absolute path, got {path:?}"
368            ));
369        }
370        Ok(Self {
371            explicit: getent_path,
372            privileged,
373        })
374    }
375
376    /// The `getent` binary to spawn, resolved on demand. `None` means "search PATH
377    /// normally" — returned only in the unprivileged, no-override case.
378    fn program(&self) -> anyhow::Result<Option<PathBuf>> {
379        self.program_in(TRUSTED_GETENT_DIRS)
380    }
381
382    /// [`Self::program`] with the trusted-directory list injected for tests.
383    fn program_in(&self, trusted_dirs: &[&str]) -> anyhow::Result<Option<PathBuf>> {
384        if let Some(path) = &self.explicit {
385            return Ok(Some(path.clone()));
386        }
387        if !self.privileged {
388            // unprivileged: a normal PATH search is fine — there is no privilege boundary
389            // to protect, and the caller's PATH (e.g. a nix profile) is what they expect.
390            return Ok(None);
391        }
392        // privileged: never consult PATH — find getent in a trusted, root-owned directory.
393        for dir in trusted_dirs {
394            let candidate = Path::new(dir).join("getent");
395            if candidate.is_file() {
396                return Ok(Some(candidate));
397            }
398        }
399        Err(anyhow!(
400            "running with elevated privilege and could not find `getent` in any trusted \
401             directory ({}); PATH is intentionally ignored when privileged so a name lookup \
402             cannot exec an attacker-controlled binary as root — pass an absolute \
403             --getent-path, or use numeric ids",
404            trusted_dirs.join(", ")
405        ))
406    }
407}
408
409/// Parse the numeric id out of a `getent passwd`/`getent group` line: the third
410/// colon-separated field is the uid (passwd) or gid (group).
411fn parse_getent_id(line: &str) -> anyhow::Result<u32> {
412    let field = line
413        .split(':')
414        .nth(2)
415        .ok_or_else(|| anyhow!("expected at least 3 ':'-separated fields"))?;
416    field
417        .parse::<u32>()
418        .with_context(|| format!("parsing id field {field:?}"))
419}
420
421/// Parse a `--group`/`--owner` DSL string. A bare token is the default for all
422/// types; `f:`/`d:`/`l:` sections override per type. `getent` locates the `getent`
423/// binary used to resolve any non-numeric, NSS-only names (see [`GetentResolver`]).
424pub fn parse_owner_dsl(
425    s: &str,
426    kind: IdKind,
427    getent: &GetentResolver,
428) -> anyhow::Result<OwnerProgram> {
429    let mut prog = OwnerProgram::default();
430    let mut bare: Option<u32> = None;
431    for clause in s.split_whitespace() {
432        if let Some((ty, rest)) = clause.split_once(':') {
433            let id = resolve_id(rest, kind, getent)?;
434            match ty {
435                "f" | "file" => prog.file = Some(id),
436                "d" | "dir" | "directory" => prog.dir = Some(id),
437                "l" | "link" | "symlink" => prog.symlink = Some(id),
438                _ => return Err(anyhow!("unknown type prefix {ty:?} (expected f:/d:/l:)")),
439            }
440        } else if bare.is_some() {
441            return Err(anyhow!(
442                "multiple bare values in {s:?}; use f:/d:/l: prefixes to set different types"
443            ));
444        } else {
445            bare = Some(resolve_id(clause, kind, getent)?);
446        }
447    }
448    if let Some(b) = bare {
449        prog.file.get_or_insert(b);
450        prog.dir.get_or_insert(b);
451        prog.symlink.get_or_insert(b);
452    }
453    Ok(prog)
454}
455
456/// Apply a mode spec to a current 12-bit mode, returning the new 12-bit mode.
457/// `is_dir` drives the conditional `X` permission.
458#[must_use]
459pub fn apply_mode(current: u32, spec: &ModeSpec, is_dir: bool) -> u32 {
460    match spec {
461        ModeSpec::Octal(m) => m & 0o7777,
462        ModeSpec::Symbolic(clauses) => {
463            let mut mode = current & 0o7777;
464            for clause in clauses {
465                mode = apply_clause(mode, *clause, is_dir);
466            }
467            mode
468        }
469    }
470}
471
472fn apply_clause(current: u32, clause: SymbolicClause, is_dir: bool) -> u32 {
473    let any_exec = current & 0o111 != 0;
474    let exec =
475        (clause.perms & PERM_X != 0) || (clause.perms & PERM_BIGX != 0 && (is_dir || any_exec));
476    let r = clause.perms & PERM_R != 0;
477    let w = clause.perms & PERM_W != 0;
478    let s = clause.perms & PERM_S != 0;
479    let t = clause.perms & PERM_T != 0;
480    let mut value: u32 = 0;
481    if clause.who & WHO_U != 0 {
482        if r {
483            value |= 0o400;
484        }
485        if w {
486            value |= 0o200;
487        }
488        if exec {
489            value |= 0o100;
490        }
491        if s {
492            value |= 0o4000;
493        }
494    }
495    if clause.who & WHO_G != 0 {
496        if r {
497            value |= 0o040;
498        }
499        if w {
500            value |= 0o020;
501        }
502        if exec {
503            value |= 0o010;
504        }
505        if s {
506            value |= 0o2000;
507        }
508    }
509    if clause.who & WHO_O != 0 {
510        if r {
511            value |= 0o004;
512        }
513        if w {
514            value |= 0o002;
515        }
516        if exec {
517            value |= 0o001;
518        }
519    }
520    if t && clause.who & WHO_O != 0 {
521        // sticky (t) responds only to 'o' (and 'a', which includes 'o') — matches chmod
522        value |= 0o1000;
523    }
524    match clause.op {
525        ModeOp::Add => current | value,
526        ModeOp::Remove => current & !value,
527        ModeOp::Set => {
528            let mut clear: u32 = 0;
529            if clause.who & WHO_U != 0 {
530                clear |= 0o4700;
531            }
532            if clause.who & WHO_G != 0 {
533                clear |= 0o2070;
534            }
535            if clause.who & WHO_O != 0 {
536                clear |= 0o1007;
537            }
538            (current & !clear) | value
539        }
540    }
541}
542
543/// Parse one mode token: an octal literal (all octal digits) or a comma-chained
544/// symbolic expression.
545fn parse_mode_token(token: &str) -> anyhow::Result<ModeSpec> {
546    if token.is_empty() {
547        return Err(anyhow!("empty mode"));
548    }
549    if token.bytes().all(|b| b.is_ascii_digit()) {
550        if token.bytes().any(|b| b > b'7') {
551            return Err(anyhow!("invalid octal mode {token:?} (digits must be 0-7)"));
552        }
553        let value = u32::from_str_radix(token, 8)
554            .with_context(|| format!("parsing octal mode {token:?}"))?;
555        if value > 0o7777 {
556            return Err(anyhow!("octal mode {token:?} out of range (max 0o7777)"));
557        }
558        return Ok(ModeSpec::Octal(value));
559    }
560    let clauses = token
561        .split(',')
562        .map(parse_symbolic_clause)
563        .collect::<anyhow::Result<Vec<_>>>()?;
564    Ok(ModeSpec::Symbolic(clauses))
565}
566
567fn parse_symbolic_clause(clause: &str) -> anyhow::Result<SymbolicClause> {
568    let op_pos = clause
569        .find(['+', '-', '='])
570        .ok_or_else(|| anyhow!("mode clause {clause:?} missing +, - or ="))?;
571    let (who_str, rest) = clause.split_at(op_pos);
572    let op = match &rest[..1] {
573        "+" => ModeOp::Add,
574        "-" => ModeOp::Remove,
575        "=" => ModeOp::Set,
576        _ => unreachable!("find guaranteed one of +-="),
577    };
578    let perms_str = &rest[1..];
579    let mut who = 0u8;
580    for ch in who_str.chars() {
581        who |= match ch {
582            'u' => WHO_U,
583            'g' => WHO_G,
584            'o' => WHO_O,
585            'a' => WHO_A,
586            other => {
587                return Err(anyhow!(
588                    "invalid 'who' {other:?} in {clause:?} (expected u/g/o/a)"
589                ));
590            }
591        };
592    }
593    if who == 0 {
594        who = WHO_A;
595    }
596    let mut perms = 0u8;
597    for ch in perms_str.chars() {
598        perms |= match ch {
599            'r' => PERM_R,
600            'w' => PERM_W,
601            'x' => PERM_X,
602            'X' => PERM_BIGX,
603            's' => PERM_S,
604            't' => PERM_T,
605            other => return Err(anyhow!("invalid permission {other:?} in {clause:?}")),
606        };
607    }
608    Ok(SymbolicClause { who, op, perms })
609}
610
611/// Parse a `--mode` DSL string. A bare token is the default for files+dirs;
612/// `f:`/`d:` sections override per type. `l:` is rejected (symlink mode bits
613/// are not settable on Linux).
614pub fn parse_mode_dsl(s: &str) -> anyhow::Result<ModeProgram> {
615    let mut prog = ModeProgram::default();
616    let mut bare: Option<ModeSpec> = None;
617    for clause in s.split_whitespace() {
618        if let Some((ty, rest)) = clause.split_once(':') {
619            let spec = parse_mode_token(rest)?;
620            match ty {
621                "f" | "file" => prog.file = Some(spec),
622                "d" | "dir" | "directory" => prog.dir = Some(spec),
623                "l" | "link" | "symlink" => {
624                    return Err(anyhow!(
625                        "symlink mode (l:) is not settable on Linux; remove the l: section"
626                    ));
627                }
628                _ => return Err(anyhow!("unknown type prefix {ty:?} (expected f:/d:)")),
629            }
630        } else if bare.is_some() {
631            return Err(anyhow!(
632                "multiple bare mode expressions in {s:?}; chain sub-ops with commas (e.g. g+r,o+w)"
633            ));
634        } else {
635            bare = Some(parse_mode_token(clause)?);
636        }
637    }
638    if let Some(b) = bare {
639        prog.file.get_or_insert(b.clone());
640        prog.dir.get_or_insert(b);
641    }
642    Ok(prog)
643}
644
645/// The concrete syscalls to perform for one entry. `chmod_before_chown` clears
646/// set-ID bits before an ownership change when required by policy. `chown`
647/// carries the `(uid, gid)` to pass to `fchownat` (each `None` means "leave
648/// unchanged"); `chmod` is the final target 12-bit mode. An all-`None` plan is
649/// a no-op.
650#[derive(Clone, Copy, Debug, PartialEq, Eq)]
651pub(crate) struct EntryPlan {
652    pub chmod_before_chown: Option<u32>,
653    pub chown: Option<(Option<u32>, Option<u32>)>,
654    pub chmod: Option<u32>,
655}
656
657impl EntryPlan {
658    pub(crate) fn is_noop(&self) -> bool {
659        self.chmod_before_chown.is_none() && self.chown.is_none() && self.chmod.is_none()
660    }
661}
662
663/// Compute the plan for one entry from its current mode/uid/gid. Pure: performs
664/// no I/O. `cur_mode` is the full `st_mode` (only the low 12 bits are used).
665pub(crate) fn compute_plan(
666    cur_mode: u32,
667    cur_uid: u32,
668    cur_gid: u32,
669    kind: EntryKind,
670    settings: &Settings,
671) -> EntryPlan {
672    let cur_mode = cur_mode & 0o7777;
673    let requested_uid = settings.owner.for_kind(kind);
674    let requested_gid = settings.group.for_kind(kind);
675    let mode_spec = settings.mode.for_kind(kind);
676    let selected = mode_spec.is_some() || requested_uid.is_some() || requested_gid.is_some();
677    let uid_change = requested_uid.filter(|&u| u != cur_uid);
678    let gid_change = requested_gid.filter(|&g| g != cur_gid);
679    let need_chown = uid_change.is_some() || gid_change.is_some();
680    let chown = need_chown.then_some((uid_change, gid_change));
681    let chmod_before_chown = (kind != EntryKind::Symlink
682        && settings.no_setid
683        && need_chown
684        && cur_mode & SETID_BITS != 0)
685        .then_some(cur_mode & !SETID_BITS);
686    let chmod = if kind == EntryKind::Symlink {
687        // symlink mode bits are not settable; never chmod a symlink
688        None
689    } else if let Some(spec) = mode_spec {
690        let requested = apply_mode(cur_mode, spec, kind == EntryKind::Dir);
691        let desired = if settings.no_setid {
692            requested & !SETID_BITS
693        } else {
694            requested
695        };
696        // chmod if the value changes, a chown would clear set-ID bits we keep, or the
697        // no-set-ID policy requires an authoritative masked chmod after chown.
698        if desired != cur_mode || (need_chown && (settings.no_setid || desired & SETID_BITS != 0)) {
699            Some(desired)
700        } else {
701            None
702        }
703    } else if settings.no_setid && selected && (need_chown || cur_mode & SETID_BITS != 0) {
704        // owner/group rules select an entry even when its id is already correct. after an
705        // actual chown, always issue the masked chmod so a concurrent set-ID change made
706        // after the metadata snapshot cannot survive the privileged ownership change.
707        Some(cur_mode & !SETID_BITS)
708    } else if need_chown && cur_mode & SETID_BITS != 0 {
709        // no mode rule for this type, but chown clears setuid/setgid -> restore.
710        Some(cur_mode)
711    } else {
712        None
713    };
714    EntryPlan {
715        chmod_before_chown,
716        chown,
717        chmod,
718    }
719}
720
721fn inc_changed(prog: &Progress, kind: EntryKind) -> Summary {
722    match kind {
723        EntryKind::Dir => {
724            prog.directories_changed.inc();
725            Summary {
726                directories_changed: 1,
727                ..Default::default()
728            }
729        }
730        EntryKind::Symlink => {
731            prog.symlinks_changed.inc();
732            Summary {
733                symlinks_changed: 1,
734                ..Default::default()
735            }
736        }
737        EntryKind::File | EntryKind::Special => {
738            prog.files_changed.inc();
739            Summary {
740                files_changed: 1,
741                ..Default::default()
742            }
743        }
744    }
745}
746
747fn inc_unchanged(prog: &Progress, kind: EntryKind) -> Summary {
748    match kind {
749        EntryKind::Dir => {
750            prog.directories_unchanged.inc();
751            Summary {
752                directories_unchanged: 1,
753                ..Default::default()
754            }
755        }
756        EntryKind::Symlink => {
757            prog.symlinks_unchanged.inc();
758            Summary {
759                symlinks_unchanged: 1,
760                ..Default::default()
761            }
762        }
763        EntryKind::File | EntryKind::Special => {
764            prog.files_unchanged.inc();
765            Summary {
766                files_unchanged: 1,
767                ..Default::default()
768            }
769        }
770    }
771}
772
773fn skipped_summary_for(kind: EntryKind) -> Summary {
774    match kind {
775        EntryKind::Dir => Summary {
776            directories_skipped: 1,
777            ..Default::default()
778        },
779        EntryKind::Symlink => Summary {
780            symlinks_skipped: 1,
781            ..Default::default()
782        },
783        EntryKind::File | EntryKind::Special => Summary {
784            files_skipped: 1,
785            ..Default::default()
786        },
787    }
788}
789
790/// Human-readable description of what a plan changes, for dry-run output.
791fn describe_change(cur_mode: u32, cur_uid: u32, cur_gid: u32, plan: &EntryPlan) -> String {
792    let mut parts = Vec::new();
793    if let Some(mode) = plan.chmod {
794        if mode == cur_mode & 0o7777 {
795            // chmod re-applied after chown to restore or enforce the selected mode policy
796            parts.push(format!("mode {mode:04o} (re-applied after chown)"));
797        } else {
798            parts.push(format!("mode {:04o}->{:04o}", cur_mode & 0o7777, mode));
799        }
800    }
801    if let Some((uid, gid)) = plan.chown {
802        if let Some(uid) = uid {
803            parts.push(format!("owner {cur_uid}->{uid}"));
804        }
805        if let Some(gid) = gid {
806            parts.push(format!("group {cur_gid}->{gid}"));
807        }
808    }
809    parts.join(", ")
810}
811
812/// Apply a computed plan to a single entry through the `O_PATH` [`Handle`] we
813/// already hold, never re-resolving the entry by path.
814///
815/// The handle is pinned to the exact inode (opened `O_NOFOLLOW`), so both syscalls
816/// act on that inode rather than on a name: there is no TOCTOU window and no
817/// `recheck` is needed (unlike copy's name-based overwrite). A concurrent
818/// rename/symlink swap of the directory entry cannot redirect either operation to a
819/// different target.
820///
821/// Syscall choice, normally following the documented chown → chmod ordering:
822/// * **pre-chown chmod** — under the no-set-ID policy, existing set-ID bits are
823///   cleared before changing ownership rather than deliberately carried into a
824///   new user or group. The final masked chmod enforces the completed state after
825///   chown; the separate syscalls do not freeze concurrent mode changes.
826/// * **chown** — [`safedir::fchown_handle`] (inode-exact `fchownat` with
827///   `AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW`). Applies to files, dirs, AND symlinks.
828/// * **chmod** — [`safedir::chmod_via_proc_fd`] (chmod of the inode via its
829///   `/proc/self/fd` magic symlink). Used for both files and directories: `fchmod`
830///   is `EBADF` on the `O_PATH` handle, and the `/proc` path is inode-exact, works
831///   on all kernels, and crucially works even on a `0000`-mode directory we own
832///   (so a pre-order `d:u+rwx` can recover an unreadable directory before we open
833///   it to recurse). Symlinks are never chmod'd — [`compute_plan`] guarantees
834///   `plan.chmod` is `None` for a symlink.
835async fn apply_plan(handle: &Handle, plan: &EntryPlan) -> anyhow::Result<()> {
836    if let Some(mode) = plan.chmod_before_chown {
837        safedir::chmod_via_proc_fd(handle, congestion::Side::Destination, mode)
838            .await
839            .with_context(|| {
840                format!("failed to clear set-ID bits via fd before chown (mode {mode:04o})")
841            })?;
842    }
843    if let Some((uid, gid)) = plan.chown {
844        safedir::fchown_handle(handle, congestion::Side::Destination, uid, gid)
845            .await
846            .with_context(|| format!("failed to chown via fd (uid={uid:?}, gid={gid:?})"))?;
847    }
848    if let Some(mode) = plan.chmod {
849        safedir::chmod_via_proc_fd(handle, congestion::Side::Destination, mode)
850            .await
851            .with_context(|| format!("failed to chmod via fd to {mode:04o}"))?;
852    }
853    Ok(())
854}
855
856/// Apply the change to a single entry (a leaf, or a directory after its children
857/// were processed). Handles the time filter, dry-run, no-op skip, and counters.
858///
859/// `handle` is the entry's fd-pinned `O_PATH` [`Handle`]; its [`Handle::meta`]
860/// snapshot feeds [`compute_plan`] (replacing the old path-based `symlink_metadata`)
861/// and is the target of the inode-exact chown/chmod. `path` is the reconstructed
862/// display path used purely for dry-run output and diagnostics.
863async fn apply_entry_change(
864    prog: &'static Progress,
865    path: &std::path::Path,
866    handle: &Handle,
867    kind: EntryKind,
868    settings: &Settings,
869) -> Result<Summary, Error> {
870    if let Some(ref time_filter) = settings.time_filter {
871        // the time filter needs full std metadata (btime for --created-before), which
872        // the fd snapshot does not carry; read it inode-exact through the pinned handle.
873        let metadata =
874            match safedir::stat_meta_via_proc_fd(handle, congestion::Side::Destination).await {
875                Ok(md) => md,
876                Err(err) => {
877                    let err = anyhow::Error::new(err).context(format!(
878                        "failed reading metadata for time filter on {path:?}"
879                    ));
880                    if settings.fail_early {
881                        return Err(Error::new(err, Default::default()));
882                    }
883                    tracing::warn!("time filter failed for {:?}, skipping: {:#}", path, &err);
884                    kind.inc_skipped(prog);
885                    return Ok(skipped_summary_for(kind));
886                }
887            };
888        match time_filter.matches(&metadata) {
889            Ok(result) => {
890                if let Some(reason) = result.as_skip_reason() {
891                    if let Some(mode) = settings.dry_run {
892                        crate::dry_run::report_time_skip(path, reason, mode, kind.label());
893                    }
894                    kind.inc_skipped(prog);
895                    return Ok(skipped_summary_for(kind));
896                }
897            }
898            Err(err) => {
899                let err = err.context(format!("failed evaluating time filter on {path:?}"));
900                if settings.fail_early {
901                    return Err(Error::new(err, Default::default()));
902                }
903                tracing::warn!("time filter failed for {:?}, skipping: {:#}", path, &err);
904                kind.inc_skipped(prog);
905                return Ok(skipped_summary_for(kind));
906            }
907        }
908    }
909    let meta = handle.meta();
910    let cur_mode = mode_of(meta);
911    let plan = compute_plan(cur_mode, meta.uid(), meta.gid(), kind, settings);
912    if plan.is_noop() {
913        if let Some(crate::config::DryRunMode::All) = settings.dry_run {
914            println!("unchanged {} {:?}", kind.label(), path);
915        }
916        return Ok(inc_unchanged(prog, kind));
917    }
918    if settings.dry_run.is_some() {
919        let desc = describe_change(cur_mode, meta.uid(), meta.gid(), &plan);
920        println!("would modify {} {:?}: {}", kind.label(), path, desc);
921        return Ok(inc_changed(prog, kind));
922    }
923    apply_plan(handle, &plan)
924        .await
925        .map_err(|err| Error::new(err, Default::default()))?;
926    Ok(inc_changed(prog, kind))
927}
928
929/// Public entry point. Applies metadata changes to `path` and, recursively, its
930/// contents. Mirrors [`crate::rm::rm`] for the root-filter check.
931///
932/// The walk is fd-based (see [`crate::safedir`]): the root operand is opened
933/// relative to its parent directory and every entry is classified and mutated
934/// through file-descriptor-relative syscalls. A privileged `rchm` therefore cannot
935/// be redirected by a concurrent symlink swap into chmod/chown'ing a target outside
936/// the intended tree — the `O_NOFOLLOW` opens and the inode-pinned `O_PATH` handles
937/// catch the swap and fail closed.
938#[instrument(skip(prog_track, settings))]
939pub async fn chmod(
940    prog_track: &'static Progress,
941    path: &std::path::Path,
942    settings: &Settings,
943) -> Result<Summary, Error> {
944    // decompose the operand into (parent dir, final component) so the root entry is opened and
945    // classified relative to a directory fd — the same fd-relative shape every nested entry takes.
946    // rchm mutates "the" tree in place, so the parent is opened on the Destination side. `.`/`..`
947    // operands (e.g. `rchm -R … .`) are canonicalized so they still name a directory; `/` is
948    // rejected.
949    let operand = crate::walk::split_root_operand(path)
950        .await
951        .map_err(|err| Error::new(err, Default::default()))?;
952    let parent_path = operand.parent.as_path();
953    let name = operand.name.as_os_str();
954    let path = operand.display.as_path();
955    // the operand's TRUSTED parent prefix is resolved following symlinks normally (the prefix is
956    // trusted up to and including the operand's container — only entries strictly below the named
957    // root are O_NOFOLLOW-hardened). a symlinked parent (e.g. `rchm symlinkdir/foo`) is followed; the
958    // operand itself is still classified via `child(name)` with O_NOFOLLOW (a symlink root is
959    // operated on as the link itself).
960    let parent = Dir::open_parent_dir(parent_path, congestion::Side::Destination)
961        .await
962        .with_context(|| format!("cannot open parent directory {parent_path:?}"))
963        .map_err(|err| Error::new(err, Default::default()))?;
964    // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below here).
965    let parent = Arc::new(parent.into_tree());
966    if let Some(ref filter) = settings.filter {
967        // classify the root via its parent fd purely to evaluate the root filter; the driver
968        // re-classifies the root authoritatively in `process_entry`, so this handle is just a probe.
969        let root_handle = parent
970            .child(name)
971            .await
972            .with_context(|| format!("failed reading metadata from {path:?}"))
973            .map_err(|err| Error::new(err, Default::default()))?;
974        let name_path = std::path::Path::new(name);
975        match filter.should_include_root_item(name_path, root_handle.kind() == EntryKind::Dir) {
976            crate::filter::FilterResult::Included => {}
977            result => {
978                let kind = root_handle.kind();
979                if let Some(mode) = settings.dry_run {
980                    crate::dry_run::report_skip(path, &result, mode, kind.label_long());
981                }
982                kind.inc_skipped(prog_track);
983                return Ok(skipped_summary_for(kind));
984            }
985        }
986    }
987    run_chmod_root(prog_track, &parent, name, path, settings).await
988}
989
990/// Build the [`ChmodVisitor`] and process the root entry through the generic
991/// [`crate::walk_driver`] driver. The root is processed exactly like a nested child: classified
992/// authoritatively, then dispatched to `visit_leaf` or `dir_pre`/recurse/`dir_post`.
993async fn run_chmod_root(
994    prog_track: &'static Progress,
995    parent: &Arc<Dir>,
996    name: &OsStr,
997    root: &std::path::Path,
998    settings: &Settings,
999) -> Result<Summary, Error> {
1000    let visitor = Arc::new(ChmodVisitor {
1001        prog_track,
1002        settings: settings.clone(),
1003    });
1004    // the root entry's owned context: rel_path/filter_path empty (the root), real_path = the root
1005    // operand. chmod has no filter base (no delegated subtree), so `filter_path == rel_path`.
1006    let root_cx = EntryCx {
1007        parent: Arc::clone(parent),
1008        name: name.to_owned(),
1009        rel_path: PathBuf::new(),
1010        filter_path: PathBuf::new(),
1011        real_path: root.to_path_buf(),
1012        dry_run: settings.dry_run.is_some(),
1013        prog_track,
1014    };
1015    process_entry(visitor, root_cx, (), None).await // chmod has no second tree → root context `()`
1016}
1017
1018/// The chmod walk's [`WalkVisitor`]. The driver owns enumeration, the leaf-permit lifecycle,
1019/// spawning, the single drop-before-recurse site, and the error fold; this visitor supplies
1020/// chmod's per-entry bodies (`apply_entry_change` for leaves, `apply_dir_self` for the directory's
1021/// own pre-/post-order change, `open_dir` for descent). chmod has no second tree, so
1022/// [`Self::DirContext`] is `()`.
1023struct ChmodVisitor {
1024    prog_track: &'static Progress,
1025    settings: Settings,
1026}
1027
1028/// State threaded from [`WalkVisitor::dir_pre`] to [`WalkVisitor::dir_post`] (same task) — what
1029/// `dir_post` needs to apply the directory's own change.
1030struct ChmodDirState {
1031    /// The directory's owned `O_PATH` handle; the post-order chmod goes through its `/proc/self/fd`
1032    /// magic symlink, inode-exact (works even on a `0000`-mode directory).
1033    handle: Handle,
1034    /// Only traversed to find include-matches (not directly matched), so its own mode/owner is left
1035    /// unchanged (mirrors rm.rs).
1036    traversed_only: bool,
1037    /// The directory's own pre-order contribution, folded into the final summary by `dir_post`.
1038    base: Summary,
1039    /// A pre-order change error captured in keep-going mode (the descent still proceeded, so
1040    /// `dir_post` surfaces it). `None` when the pre-order change succeeded or was deferred —
1041    /// mutually exclusive with the post-order change `dir_post` applies under `defer_dir_changes`.
1042    pre_order_error: Option<anyhow::Error>,
1043}
1044
1045impl WalkVisitor for ChmodVisitor {
1046    type Summary = Summary;
1047    type DirContext = ();
1048    type DirState = ChmodDirState;
1049
1050    fn root_dir_context(&self) {}
1051
1052    fn permit_kind(&self) -> PermitKind {
1053        // metadata-only walk: no open fd held across leaf work, so gate on the pending-meta pool.
1054        PermitKind::PendingMeta
1055    }
1056
1057    fn want_permit(&self, hint: Option<EntryKind>) -> bool {
1058        // known non-directory hint only (a hinted dir recurses; DT_UNKNOWN might be a dir) —
1059        // matches the old spawn loop's `known_leaf` pre-acquire policy.
1060        hint.is_some_and(|k| k != EntryKind::Dir)
1061    }
1062
1063    fn fail_early(&self) -> bool {
1064        self.settings.fail_early
1065    }
1066
1067    fn filter(&self) -> Option<&crate::filter::FilterSettings> {
1068        self.settings.filter.as_ref()
1069    }
1070
1071    fn on_skip(
1072        &self,
1073        cx: &EntryCx,
1074        kind: EntryKind,
1075        skip_result: &crate::filter::FilterResult,
1076    ) -> Summary {
1077        // mirror the old spawn loop's inline filter-skip: the dry-run "skip ..." line plus the
1078        // matching `*_skipped` counter. the driver already did the shared progress increment.
1079        if let Some(mode) = self.settings.dry_run {
1080            crate::dry_run::report_skip(&cx.real_path, skip_result, mode, kind.label());
1081        }
1082        skipped_summary_for(kind)
1083    }
1084
1085    async fn visit_leaf(
1086        &self,
1087        cx: &EntryCx,
1088        _parent_ctx: &(),
1089        handle: Handle,
1090        kind: EntryKind,
1091        permit: Option<LeafPermit>,
1092    ) -> Result<Summary, Error> {
1093        // the leaf change (time-filter + compute_plan + apply_plan), exactly as the old leaf branch.
1094        // the permit is held across this non-recursive work and dropped on return (the driver drops
1095        // it for directories, never here).
1096        let _permit = permit;
1097        apply_entry_change(
1098            self.prog_track,
1099            &cx.real_path,
1100            &handle,
1101            kind,
1102            &self.settings,
1103        )
1104        .await
1105    }
1106
1107    async fn dir_pre(&self, cx: &EntryCx, _parent_ctx: &(), handle: &Handle) -> DirPreResult<Self> {
1108        let path = &cx.real_path;
1109        // a "traversed-only" dir was entered only because it COULD contain include-matches; it does
1110        // not directly match an include pattern, so its own mode/owner is left unchanged (mirrors
1111        // rm.rs) — only directly-selected entries are modified.
1112        let traversed_only = self.settings.filter.as_ref().is_some_and(|f| {
1113            f.has_includes() && !f.directly_matches_include(&cx.filter_path, true)
1114        });
1115        let mut base = Summary::default();
1116        let mut pre_order_error: Option<anyhow::Error> = None;
1117        // pre-order (default, like `chmod -R`): change the dir BEFORE descending, via the O_PATH
1118        // handle's /proc path (works even on a 0000 dir) — so `--mode d:u+rwx` recovers an
1119        // unreadable dir before the open reads it, and a later child failure can't prevent it. a
1120        // restrictive change (e.g. `d:a-rwx`) makes the open fail and is reported (like `chmod -R`).
1121        if !self.settings.defer_dir_changes {
1122            match apply_dir_self(
1123                self.prog_track,
1124                path,
1125                handle,
1126                traversed_only,
1127                &self.settings,
1128            )
1129            .await
1130            {
1131                Ok(dir_summary) => base = base + dir_summary,
1132                // fail-early: report now, never open or descend. keep-going: stash the error for
1133                // `dir_post` to surface after the descent, and still open and recurse (old flow).
1134                Err(error) if self.settings.fail_early => {
1135                    return Err(Error::new(error.source, base + error.summary));
1136                }
1137                Err(error) => {
1138                    tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
1139                    base = base + error.summary;
1140                    pre_order_error = Some(error.source);
1141                }
1142            }
1143        }
1144        // dup the dir's O_PATH handle (a pure fd dup — no extra openat/fstatat) so `dir_post` can
1145        // apply the deferred/post-order change inode-exact: the driver lends `&handle` only for
1146        // `dir_pre`, so the post-order step needs its own owned handle to the same inode.
1147        let dir_handle = handle
1148            .try_clone()
1149            .with_context(|| format!("cannot duplicate directory handle for {path:?}"))
1150            .map_err(|err| Error::new(err, base))?;
1151        // open the directory's real fd for the driver to enumerate. in post-order the dir still has
1152        // its original mode, so the open succeeds and the held fd survives a later search-bit strip.
1153        // an open failure (restrictive pre-order change, or a pre-existing unreadable dir) is reported.
1154        match cx.parent.open_dir(&cx.name).await {
1155            Ok(dir) => Ok(DirAction::Descend {
1156                dir: Arc::new(dir),
1157                child_ctx: (),
1158                state: ChmodDirState {
1159                    handle: dir_handle,
1160                    traversed_only,
1161                    base,
1162                    pre_order_error,
1163                },
1164            }),
1165            Err(error) => {
1166                let error = anyhow::Error::new(error)
1167                    .context(format!("cannot open directory {path:?} for reading"));
1168                if self.settings.fail_early {
1169                    return Err(Error::new(error, base));
1170                }
1171                // keep-going: no children to walk. fold the pre-order error (if any) + the open
1172                // error, apply the deferred change (if any), and surface the combined error — the
1173                // net result of the old open-failure path (empty join loop, then deferred change).
1174                let errors = crate::error_collector::ErrorCollector::default();
1175                if let Some(pre_order_error) = pre_order_error {
1176                    errors.push(pre_order_error);
1177                }
1178                tracing::error!("chmod: {:#}", &error);
1179                errors.push(error);
1180                if self.settings.defer_dir_changes {
1181                    match apply_dir_self(
1182                        self.prog_track,
1183                        path,
1184                        handle,
1185                        traversed_only,
1186                        &self.settings,
1187                    )
1188                    .await
1189                    {
1190                        Ok(dir_summary) => base = base + dir_summary,
1191                        Err(error) => {
1192                            tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
1193                            base = base + error.summary;
1194                            errors.push(error.source);
1195                        }
1196                    }
1197                }
1198                // `into_error()` is `Some` here: at least the open error was pushed.
1199                Err(Error::new(errors.into_error().unwrap(), base))
1200            }
1201        }
1202    }
1203
1204    async fn dir_post(
1205        &self,
1206        cx: &EntryCx,
1207        state: ChmodDirState,
1208        _processed: &ProcessedChildren,
1209        child_result: Result<Summary, Error>,
1210    ) -> Result<Summary, Error> {
1211        let ChmodDirState {
1212            handle,
1213            traversed_only,
1214            base,
1215            pre_order_error,
1216        } = state;
1217        let path = &cx.real_path;
1218        // seed with the dir's own pre-order contribution, then fold the children (the driver passes
1219        // `Err` here only in keep-going mode — fail-early aborts before post-order).
1220        let (child_summary, child_error) = match child_result {
1221            Ok(summary) => (summary, None),
1222            Err(err) => (err.summary, Some(err.source)),
1223        };
1224        let mut summary = base + child_summary;
1225        // collect the deferred pre-order error and the child error (keep-going only).
1226        let errors = crate::error_collector::ErrorCollector::default();
1227        if let Some(pre_order_error) = pre_order_error {
1228            errors.push(pre_order_error);
1229        }
1230        if let Some(child_error) = child_error {
1231            errors.push(child_error);
1232        }
1233        // post-order (`--defer-dir-changes`): change the dir AFTER its contents (needed to remove
1234        // the owner's own traversal permission). the contents were read through a fd opened before
1235        // this change, so stripping the search bit here can't lock us out; the change is inode-exact
1236        // through the O_PATH handle. (`pre_order_error` is always `None` here, so the two are
1237        // mutually exclusive.)
1238        if self.settings.defer_dir_changes {
1239            match apply_dir_self(
1240                self.prog_track,
1241                path,
1242                &handle,
1243                traversed_only,
1244                &self.settings,
1245            )
1246            .await
1247            {
1248                Ok(dir_summary) => summary = summary + dir_summary,
1249                Err(error) => {
1250                    if self.settings.fail_early {
1251                        return Err(Error::new(error.source, summary + error.summary));
1252                    }
1253                    tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
1254                    summary = summary + error.summary;
1255                    errors.push(error.source);
1256                }
1257            }
1258        }
1259        if let Some(error) = errors.into_error() {
1260            return Err(Error::new(error, summary));
1261        }
1262        Ok(summary)
1263    }
1264}
1265
1266/// Apply the directory's own change, or skip it when the directory was only traversed to
1267/// find include-matches. Factored out so the walk can run it before (pre-order, default)
1268/// or after (post-order, `--defer-dir-changes`) descending into the contents.
1269///
1270/// `handle` is the directory's `O_PATH` handle; the chmod goes through its `/proc/self/fd`
1271/// magic symlink, so the change applies inode-exact and works even on a `0000`-mode
1272/// directory (a pre-order `d:u+rwx` recovers traversability before we open the dir to read).
1273async fn apply_dir_self(
1274    prog_track: &'static Progress,
1275    path: &std::path::Path,
1276    handle: &Handle,
1277    traversed_only: bool,
1278    settings: &Settings,
1279) -> Result<Summary, Error> {
1280    if traversed_only {
1281        if let Some(crate::config::DryRunMode::All) = settings.dry_run {
1282            println!("skip dir {path:?} (only traversed for include matches)");
1283        }
1284        prog_track.directories_skipped.inc();
1285        return Ok(skipped_summary_for(EntryKind::Dir));
1286    }
1287    apply_entry_change(prog_track, path, handle, EntryKind::Dir, settings).await
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292    use super::*;
1293    #[test]
1294    fn mode_token_octal() {
1295        assert_eq!(parse_mode_token("2775").unwrap(), ModeSpec::Octal(0o2775));
1296        assert_eq!(parse_mode_token("0644").unwrap(), ModeSpec::Octal(0o644));
1297    }
1298    #[test]
1299    fn mode_token_octal_out_of_range_errors() {
1300        assert!(parse_mode_token("9999").is_err()); // 9 is not octal
1301        assert!(parse_mode_token("77777").is_err()); // > 12 bits
1302    }
1303    #[test]
1304    fn mode_token_symbolic_simple() {
1305        let spec = parse_mode_token("g+w").unwrap();
1306        assert_eq!(
1307            spec,
1308            ModeSpec::Symbolic(vec![SymbolicClause {
1309                who: WHO_G,
1310                op: ModeOp::Add,
1311                perms: PERM_W
1312            }])
1313        );
1314    }
1315    #[test]
1316    fn mode_token_symbolic_omitted_who_means_all() {
1317        let spec = parse_mode_token("+x").unwrap();
1318        assert_eq!(
1319            spec,
1320            ModeSpec::Symbolic(vec![SymbolicClause {
1321                who: WHO_A,
1322                op: ModeOp::Add,
1323                perms: PERM_X
1324            }])
1325        );
1326    }
1327    #[test]
1328    fn mode_token_symbolic_comma_chained() {
1329        let spec = parse_mode_token("u+rw,g-w").unwrap();
1330        let ModeSpec::Symbolic(clauses) = spec else {
1331            panic!("expected symbolic")
1332        };
1333        assert_eq!(clauses.len(), 2);
1334        assert_eq!(
1335            clauses[0],
1336            SymbolicClause {
1337                who: WHO_U,
1338                op: ModeOp::Add,
1339                perms: PERM_R | PERM_W
1340            }
1341        );
1342        assert_eq!(
1343            clauses[1],
1344            SymbolicClause {
1345                who: WHO_G,
1346                op: ModeOp::Remove,
1347                perms: PERM_W
1348            }
1349        );
1350    }
1351    #[test]
1352    fn mode_token_symbolic_bigx_and_specials() {
1353        let spec = parse_mode_token("g+rwXs").unwrap();
1354        assert_eq!(
1355            spec,
1356            ModeSpec::Symbolic(vec![SymbolicClause {
1357                who: WHO_G,
1358                op: ModeOp::Add,
1359                perms: PERM_R | PERM_W | PERM_BIGX | PERM_S,
1360            }])
1361        );
1362    }
1363    #[test]
1364    fn mode_token_rejects_garbage() {
1365        assert!(parse_mode_token("q+z").is_err());
1366        assert!(parse_mode_token("g!w").is_err());
1367        assert!(parse_mode_token("").is_err());
1368    }
1369    #[test]
1370    fn summary_add_combines_fields() {
1371        let a = Summary {
1372            files_changed: 1,
1373            directories_changed: 2,
1374            files_unchanged: 3,
1375            ..Default::default()
1376        };
1377        let b = Summary {
1378            files_changed: 10,
1379            symlinks_skipped: 4,
1380            ..Default::default()
1381        };
1382        let sum = a + b;
1383        assert_eq!(sum.files_changed, 11);
1384        assert_eq!(sum.directories_changed, 2);
1385        assert_eq!(sum.files_unchanged, 3);
1386        assert_eq!(sum.symlinks_skipped, 4);
1387    }
1388    #[test]
1389    fn owner_dsl_bare_applies_to_all_types() {
1390        let prog = parse_owner_dsl("0", IdKind::User, &GetentResolver::default()).unwrap();
1391        assert_eq!(prog.file, Some(0));
1392        assert_eq!(prog.dir, Some(0));
1393        assert_eq!(prog.symlink, Some(0));
1394    }
1395    #[test]
1396    fn owner_dsl_per_type_overrides() {
1397        let prog = parse_owner_dsl("f:1 d:2", IdKind::User, &GetentResolver::default()).unwrap();
1398        assert_eq!(prog.file, Some(1));
1399        assert_eq!(prog.dir, Some(2));
1400        assert_eq!(prog.symlink, None);
1401    }
1402    #[test]
1403    fn owner_dsl_bare_plus_override() {
1404        let prog = parse_owner_dsl("5 d:2", IdKind::Group, &GetentResolver::default()).unwrap();
1405        assert_eq!(prog.file, Some(5));
1406        assert_eq!(prog.dir, Some(2));
1407        assert_eq!(prog.symlink, Some(5));
1408    }
1409    #[test]
1410    fn owner_dsl_explicit_before_bare_is_order_independent() {
1411        let prog = parse_owner_dsl("f:1 5", IdKind::User, &GetentResolver::default()).unwrap();
1412        assert_eq!(prog.file, Some(1));
1413        assert_eq!(prog.dir, Some(5));
1414        assert_eq!(prog.symlink, Some(5));
1415    }
1416    #[test]
1417    fn owner_dsl_rejects_multiple_bare() {
1418        assert!(parse_owner_dsl("1 2", IdKind::User, &GetentResolver::default()).is_err());
1419    }
1420    #[test]
1421    fn owner_dsl_rejects_unknown_id() {
1422        assert!(
1423            parse_owner_dsl(
1424                "definitely-no-such-group-xyz",
1425                IdKind::Group,
1426                &GetentResolver::default()
1427            )
1428            .is_err()
1429        );
1430    }
1431    #[test]
1432    fn owner_dsl_resolves_root_name() {
1433        // "root" is in /etc/passwd everywhere — covers the in-process from_name path
1434        let prog = parse_owner_dsl("root", IdKind::User, &GetentResolver::default()).unwrap();
1435        assert_eq!(prog.file, Some(0));
1436        assert_eq!(prog.dir, Some(0));
1437        assert_eq!(prog.symlink, Some(0));
1438    }
1439    #[test]
1440    fn getent_id_parses_passwd_and_group_lines() {
1441        // passwd: name:passwd:uid:gid:gecos:home:shell — field 3 is the uid
1442        assert_eq!(
1443            parse_getent_id("alice:x:1234:100:Alice:/home/alice:/bin/sh").unwrap(),
1444            1234
1445        );
1446        // group: name:passwd:gid:members — field 3 is the gid
1447        assert_eq!(parse_getent_id("data:*:5678:alice,bob").unwrap(), 5678);
1448    }
1449    #[test]
1450    fn getent_id_rejects_malformed_lines() {
1451        assert!(parse_getent_id("").is_err());
1452        assert!(parse_getent_id("alice").is_err());
1453        assert!(parse_getent_id("alice:x").is_err());
1454        assert!(parse_getent_id("alice:x:notanumber:100").is_err());
1455    }
1456    /// Write an executable stub script into `dir` and return its path.
1457    fn write_stub_getent(dir: &std::path::Path, name: &str, body: &str) -> std::path::PathBuf {
1458        use std::os::unix::fs::PermissionsExt;
1459        let path = dir.join(name);
1460        std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
1461        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1462        path
1463    }
1464    #[test]
1465    fn getent_stub_resolves_directory_service_names() {
1466        let tmp = tempfile::tempdir().unwrap();
1467        // simulates an SSSD/LDAP-served entry: visible to getent, absent from /etc files. the
1468        // guard asserts the args are `-- <database> <token>` — i.e. the `--` option terminator
1469        // is present and the database lands in the right slot (a wrong database would exit 99).
1470        let user_stub = write_stub_getent(
1471            tmp.path(),
1472            "getent-user",
1473            "[ \"$1\" = -- ] && [ \"$2\" = passwd ] || exit 99\necho 'ldapuser:*:4242:4242:LDAP User:/home/ldapuser:/bin/sh'",
1474        );
1475        let group_stub = write_stub_getent(
1476            tmp.path(),
1477            "getent-group",
1478            "[ \"$1\" = -- ] && [ \"$2\" = group ] || exit 99\necho 'ldapgroup:*:4343:ldapuser'",
1479        );
1480        assert_eq!(
1481            resolve_via_getent_cmd(user_stub.as_os_str(), "ldapuser", IdKind::User).unwrap(),
1482            4242
1483        );
1484        assert_eq!(
1485            resolve_via_getent_cmd(group_stub.as_os_str(), "ldapgroup", IdKind::Group).unwrap(),
1486            4343
1487        );
1488    }
1489    #[test]
1490    fn getent_stub_not_found_is_unknown() {
1491        let tmp = tempfile::tempdir().unwrap();
1492        let stub = write_stub_getent(tmp.path(), "getent-miss", "exit 2");
1493        let err = resolve_via_getent_cmd(stub.as_os_str(), "nosuch", IdKind::Group).unwrap_err();
1494        assert!(
1495            format!("{err:#}").contains("unknown group: nosuch"),
1496            "got: {err:#}"
1497        );
1498    }
1499    #[test]
1500    fn getent_missing_program_suggests_numeric_id() {
1501        let tmp = tempfile::tempdir().unwrap();
1502        let missing = tmp.path().join("no-such-getent");
1503        let err = resolve_via_getent_cmd(missing.as_os_str(), "alice", IdKind::User).unwrap_err();
1504        assert!(
1505            format!("{err:#}").contains("use a numeric id instead"),
1506            "got: {err:#}"
1507        );
1508    }
1509    #[test]
1510    fn getent_stub_garbled_output_errors() {
1511        let tmp = tempfile::tempdir().unwrap();
1512        let stub = write_stub_getent(tmp.path(), "getent-garbled", "echo 'not a passwd line'");
1513        let err = resolve_via_getent_cmd(stub.as_os_str(), "alice", IdKind::User).unwrap_err();
1514        assert!(
1515            format!("{err:#}").contains("unexpected getent output"),
1516            "got: {err:#}"
1517        );
1518    }
1519    #[test]
1520    fn getent_stub_exit0_no_output_errors() {
1521        let tmp = tempfile::tempdir().unwrap();
1522        let stub = write_stub_getent(tmp.path(), "getent-empty", "exit 0");
1523        let err = resolve_via_getent_cmd(stub.as_os_str(), "alice", IdKind::User).unwrap_err();
1524        assert!(
1525            format!("{err:#}").contains("produced no output"),
1526            "got: {err:#}"
1527        );
1528    }
1529    #[test]
1530    fn getent_real_resolves_root() {
1531        // root exists in /etc/passwd and /etc/group on every supported host; this
1532        // exercises the real `getent` end-to-end (spawn + parse).
1533        assert_eq!(
1534            resolve_via_getent("root", IdKind::User, &GetentResolver::default()).unwrap(),
1535            0
1536        );
1537        assert_eq!(
1538            resolve_via_getent("root", IdKind::Group, &GetentResolver::default()).unwrap(),
1539            0
1540        );
1541    }
1542    #[test]
1543    fn getent_real_option_like_name_fails_closed_no_injection() {
1544        // a name starting with `-` must NOT be parsed as a getent option. Without the `--`
1545        // terminator, `getent group --service=files` exits 0 and prints the whole database, so
1546        // this parser would take the first line and silently resolve to gid 0. With `--` the
1547        // name is a lookup key, found in no DB, so resolution fails closed. Reachable in the CLI
1548        // via `rchm --group=--service=files` — a bogus name must never map to root.
1549        let err = resolve_via_getent("--service=files", IdKind::Group, &GetentResolver::default())
1550            .unwrap_err();
1551        let msg = format!("{err:#}");
1552        assert!(
1553            msg.contains("unknown group") || msg.contains("produced no output"),
1554            "option-like name must fail closed (not resolve to an id), got: {msg}"
1555        );
1556    }
1557    #[test]
1558    fn getent_source_rejects_relative_explicit_path() {
1559        // a relative --getent-path would re-introduce a PATH/cwd lookup — rejected up front.
1560        let err = GetentResolver::from_cli(Some(PathBuf::from("getent")), false).unwrap_err();
1561        assert!(
1562            format!("{err:#}").contains("must be an absolute path"),
1563            "got: {err:#}"
1564        );
1565    }
1566    #[test]
1567    fn getent_source_explicit_absolute_used_even_when_privileged() {
1568        // an explicit absolute path is honored verbatim and bypasses the trusted-dir probe.
1569        let resolver =
1570            GetentResolver::from_cli(Some(PathBuf::from("/opt/nss/getent")), true).unwrap();
1571        assert_eq!(
1572            resolver.program_in(&["/usr/bin"]).unwrap(),
1573            Some(PathBuf::from("/opt/nss/getent"))
1574        );
1575    }
1576    #[test]
1577    fn getent_source_unprivileged_searches_path() {
1578        // unprivileged + no override → None means "search PATH normally".
1579        let resolver = GetentResolver::from_cli(None, false).unwrap();
1580        assert_eq!(resolver.program_in(&["/nonexistent"]).unwrap(), None);
1581    }
1582    #[test]
1583    fn getent_source_privileged_probes_trusted_dirs_not_path() {
1584        // privileged + no override → use getent found in a trusted dir; PATH is never consulted.
1585        let tmp = tempfile::tempdir().unwrap();
1586        let bin = tmp.path().join("getent");
1587        std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1588        let resolver = GetentResolver::from_cli(None, true).unwrap();
1589        let dirs = [tmp.path().to_str().unwrap()];
1590        assert_eq!(resolver.program_in(&dirs).unwrap(), Some(bin));
1591    }
1592    #[test]
1593    fn getent_source_privileged_missing_getent_errors_not_path_fallback() {
1594        // privileged + no override + getent absent from every trusted dir → hard error,
1595        // NOT a silent PATH fallback (the core anti-PATH-attack guarantee).
1596        let tmp = tempfile::tempdir().unwrap(); // empty: no getent inside
1597        let resolver = GetentResolver::from_cli(None, true).unwrap();
1598        let dir = tmp.path().to_str().unwrap();
1599        let err = resolver.program_in(&[dir]).unwrap_err();
1600        let msg = format!("{err:#}");
1601        assert!(
1602            msg.contains("PATH is intentionally ignored"),
1603            "should explain PATH is not consulted, got: {msg}"
1604        );
1605        assert!(
1606            msg.contains(dir),
1607            "should name the searched dir, got: {msg}"
1608        );
1609    }
1610    fn sym(s: &str) -> ModeSpec {
1611        parse_mode_token(s).unwrap()
1612    }
1613    #[test]
1614    fn apply_mode_group_add_remove() {
1615        assert_eq!(apply_mode(0o644, &sym("g+w"), false), 0o664);
1616        assert_eq!(apply_mode(0o664, &sym("g-w"), false), 0o644);
1617    }
1618    #[test]
1619    fn apply_mode_set_clears_other_bits() {
1620        // o= clears all 'other' bits (incl sticky), leaves user/group
1621        assert_eq!(apply_mode(0o755, &sym("o="), false), 0o750);
1622        // chained absolute-ish set from zero
1623        assert_eq!(apply_mode(0o000, &sym("u=rwx,go=rx"), false), 0o755);
1624        // o= on a sticky file clears the sticky bit too
1625        assert_eq!(apply_mode(0o1755, &sym("o="), false), 0o0750);
1626    }
1627    #[test]
1628    fn apply_mode_conditional_bigx() {
1629        // file without execute: X does nothing
1630        assert_eq!(apply_mode(0o644, &sym("a+X"), false), 0o644);
1631        // file with a user-execute bit: X applies to all
1632        assert_eq!(apply_mode(0o744, &sym("a+X"), false), 0o755);
1633        // directory: X always applies
1634        assert_eq!(apply_mode(0o644, &sym("a+X"), true), 0o755);
1635    }
1636    #[test]
1637    fn apply_mode_setgid_and_sticky() {
1638        assert_eq!(apply_mode(0o750, &sym("g+rwxs"), true), 0o2770);
1639        assert_eq!(apply_mode(0o755, &sym("+t"), true), 0o1755);
1640        assert_eq!(apply_mode(0o755, &sym("u+s"), false), 0o4755);
1641    }
1642    #[test]
1643    fn apply_mode_sticky_only_responds_to_other() {
1644        // u+t / g+t are no-ops; only o/a/bare set sticky (verified against real chmod)
1645        assert_eq!(apply_mode(0o755, &sym("u+t"), false), 0o755);
1646        assert_eq!(apply_mode(0o755, &sym("g+t"), false), 0o755);
1647        assert_eq!(apply_mode(0o755, &sym("ug+t"), false), 0o755);
1648        assert_eq!(apply_mode(0o755, &sym("o+t"), false), 0o1755);
1649        assert_eq!(apply_mode(0o755, &sym("+t"), false), 0o1755);
1650        assert_eq!(apply_mode(0o1755, &sym("u-t"), false), 0o1755);
1651        assert_eq!(apply_mode(0o1755, &sym("o-t"), false), 0o755);
1652    }
1653    #[test]
1654    fn apply_mode_octal_is_absolute() {
1655        assert_eq!(apply_mode(0o4755, &sym("644"), false), 0o644);
1656        assert_eq!(apply_mode(0o000, &sym("2775"), true), 0o2775);
1657    }
1658    #[test]
1659    fn mode_dsl_bare_applies_to_file_and_dir_not_symlink() {
1660        let prog = parse_mode_dsl("g+rwX").unwrap();
1661        assert!(prog.file.is_some());
1662        assert!(prog.dir.is_some());
1663        // symlinks are not part of ModeProgram at all
1664        assert!(prog.for_kind(EntryKind::Symlink).is_none());
1665    }
1666    #[test]
1667    fn mode_dsl_per_type() {
1668        let prog = parse_mode_dsl("f:g+rw d:g+rwxs").unwrap();
1669        assert_eq!(prog.file, Some(sym("g+rw")));
1670        assert_eq!(prog.dir, Some(sym("g+rwxs")));
1671    }
1672    #[test]
1673    fn mode_dsl_bare_plus_override() {
1674        let prog = parse_mode_dsl("g+r d:g+rwx").unwrap();
1675        assert_eq!(prog.file, Some(sym("g+r")));
1676        assert_eq!(prog.dir, Some(sym("g+rwx")));
1677    }
1678    #[test]
1679    fn mode_dsl_rejects_symlink_section() {
1680        assert!(parse_mode_dsl("l:g+w").is_err());
1681    }
1682    #[test]
1683    fn mode_dsl_rejects_multiple_bare() {
1684        assert!(parse_mode_dsl("g+r o+w").is_err());
1685    }
1686    #[test]
1687    fn mode_dsl_rejects_unknown_prefix() {
1688        assert!(parse_mode_dsl("z:644").is_err());
1689    }
1690    #[test]
1691    fn mode_dsl_single_type_leaves_other_none() {
1692        let prog_f = parse_mode_dsl("f:644").unwrap();
1693        assert!(prog_f.file.is_some());
1694        assert!(prog_f.dir.is_none());
1695        let prog_d = parse_mode_dsl("d:755").unwrap();
1696        assert!(prog_d.dir.is_some());
1697        assert!(prog_d.file.is_none());
1698    }
1699    fn settings_with(mode: &str, owner: Option<&str>, group: Option<&str>) -> Settings {
1700        Settings {
1701            mode: if mode.is_empty() {
1702                ModeProgram::default()
1703            } else {
1704                parse_mode_dsl(mode).unwrap()
1705            },
1706            owner: owner
1707                .map(|s| parse_owner_dsl(s, IdKind::User, &GetentResolver::default()).unwrap())
1708                .unwrap_or_default(),
1709            group: group
1710                .map(|s| parse_owner_dsl(s, IdKind::Group, &GetentResolver::default()).unwrap())
1711                .unwrap_or_default(),
1712            no_setid: false,
1713            fail_early: false,
1714            defer_dir_changes: false,
1715            filter: None,
1716            time_filter: None,
1717            dry_run: None,
1718        }
1719    }
1720    fn settings_with_no_setid(mode: &str, owner: Option<&str>, group: Option<&str>) -> Settings {
1721        let mut settings = settings_with(mode, owner, group);
1722        settings.no_setid = true;
1723        settings
1724    }
1725    #[test]
1726    fn plan_noop_when_already_correct() {
1727        let s = settings_with("g+r", None, None);
1728        // file already group-readable
1729        let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1730        assert!(plan.is_noop());
1731    }
1732    #[test]
1733    fn plan_chmod_when_mode_differs() {
1734        let s = settings_with("g+w", None, None);
1735        let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1736        assert_eq!(plan.chmod, Some(0o664));
1737        assert!(plan.chown.is_none());
1738    }
1739    #[test]
1740    fn plan_chown_only_changed_ids() {
1741        let s = settings_with("", None, Some("2000"));
1742        let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1743        // gid changes 1000 -> 2000, uid untouched
1744        assert_eq!(plan.chown, Some((None, Some(2000))));
1745        assert!(plan.chmod.is_none());
1746    }
1747    #[test]
1748    fn plan_preserves_setgid_across_chgrp() {
1749        // file is setgid (0o2755), only --group given; chown clears setgid, so we
1750        // must re-chmod to the original mode to keep it.
1751        let s = settings_with("", None, Some("2000"));
1752        let plan = compute_plan(0o2755, 1000, 1000, EntryKind::File, &s);
1753        assert_eq!(plan.chown, Some((None, Some(2000))));
1754        assert_eq!(plan.chmod, Some(0o2755));
1755    }
1756    #[test]
1757    fn plan_symlink_never_chmods_but_chowns() {
1758        let s = settings_with("g+w", None, Some("2000"));
1759        let plan = compute_plan(0o777, 1000, 1000, EntryKind::Symlink, &s);
1760        assert!(plan.chmod.is_none());
1761        assert_eq!(plan.chown, Some((None, Some(2000))));
1762    }
1763    #[test]
1764    fn plan_preserves_setuid_when_mode_rule_noop_but_chown_runs() {
1765        // g+r is a no-op on 0o4755 (group already readable), but the chown clears
1766        // setuid, so the mode-rule branch must still emit a chmod to restore it.
1767        let s = settings_with("g+r", Some("2000"), None);
1768        let plan = compute_plan(0o4755, 1000, 1000, EntryKind::File, &s);
1769        assert_eq!(plan.chown, Some((Some(2000), None)));
1770        assert_eq!(plan.chmod, Some(0o4755));
1771    }
1772    #[test]
1773    fn plan_preserves_setgid_dir_across_chgrp() {
1774        // setgid dir, only --group given; chown clears setgid so chmod restores it.
1775        let s = settings_with("", None, Some("2000"));
1776        let plan = compute_plan(0o2770, 1000, 1000, EntryKind::Dir, &s);
1777        assert_eq!(plan.chown, Some((None, Some(2000))));
1778        assert_eq!(plan.chmod, Some(0o2770));
1779    }
1780    #[test]
1781    fn no_setid_drops_symbolic_setuid_request() {
1782        let s = settings_with_no_setid("u+s", None, None);
1783        let plan = compute_plan(0o755, 1000, 1000, EntryKind::File, &s);
1784        assert!(plan.is_noop());
1785    }
1786    #[test]
1787    fn no_setid_masks_octal_setid_bits() {
1788        let s = settings_with_no_setid("6755", None, None);
1789        let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1790        assert_eq!(plan.chmod, Some(0o755));
1791    }
1792    #[test]
1793    fn no_setid_clears_inherited_bits_on_unrelated_mode_change() {
1794        let s = settings_with_no_setid("g+w", None, None);
1795        let plan = compute_plan(0o6755, 1000, 1000, EntryKind::File, &s);
1796        assert_eq!(plan.chmod, Some(0o775));
1797    }
1798    #[test]
1799    fn no_setid_clears_bits_before_and_after_ownership_change() {
1800        let s = settings_with_no_setid("", Some("2000"), None);
1801        let plan = compute_plan(0o4755, 1000, 1000, EntryKind::File, &s);
1802        assert_eq!(plan.chmod_before_chown, Some(0o755));
1803        assert_eq!(plan.chown, Some((Some(2000), None)));
1804        assert_eq!(plan.chmod, Some(0o755));
1805    }
1806    #[test]
1807    fn no_setid_reapplies_mask_after_chown_without_snapshot_bits() {
1808        let s = settings_with_no_setid("", Some("2000"), None);
1809        let plan = compute_plan(0o755, 1000, 1000, EntryKind::File, &s);
1810        assert!(plan.chmod_before_chown.is_none());
1811        assert_eq!(plan.chown, Some((Some(2000), None)));
1812        assert_eq!(plan.chmod, Some(0o755));
1813    }
1814    #[test]
1815    fn no_setid_clears_bits_when_selected_owner_is_unchanged() {
1816        let s = settings_with_no_setid("", Some("1000"), None);
1817        let plan = compute_plan(0o4755, 1000, 1000, EntryKind::File, &s);
1818        assert!(plan.chmod_before_chown.is_none());
1819        assert!(plan.chown.is_none());
1820        assert_eq!(plan.chmod, Some(0o755));
1821    }
1822    #[test]
1823    fn no_setid_retains_sticky_bit_on_setgid_directory() {
1824        let s = settings_with_no_setid("", None, Some("2000"));
1825        let plan = compute_plan(0o3775, 1000, 1000, EntryKind::Dir, &s);
1826        assert_eq!(plan.chmod_before_chown, Some(0o1775));
1827        assert_eq!(plan.chown, Some((None, Some(2000))));
1828        assert_eq!(plan.chmod, Some(0o1775));
1829    }
1830    #[test]
1831    fn no_setid_never_chmods_symlink() {
1832        let s = settings_with_no_setid("", None, Some("2000"));
1833        let plan = compute_plan(0o6777, 1000, 1000, EntryKind::Symlink, &s);
1834        assert!(plan.chmod_before_chown.is_none());
1835        assert_eq!(plan.chown, Some((None, Some(2000))));
1836        assert!(plan.chmod.is_none());
1837    }
1838
1839    static RACE_PROGRESS: std::sync::LazyLock<Progress> = std::sync::LazyLock::new(Progress::new);
1840
1841    // Repeatedly swap `dir/entry` between a real regular file (mode 0644) and a symlink
1842    // pointing at `sentinel`, using rename so each individual state is atomic. Two staging
1843    // names live alongside `entry` and are renamed over it in a tight loop until `stop` is
1844    // set. Runs on a dedicated OS thread so it makes progress regardless of the tokio
1845    // runtime's scheduling. Mirrors copy's `spawn_file_symlink_swapper`.
1846    fn spawn_file_symlink_swapper(
1847        dir: std::path::PathBuf,
1848        entry_name: &'static str,
1849        sentinel: std::path::PathBuf,
1850        stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
1851    ) -> std::thread::JoinHandle<()> {
1852        use std::os::unix::fs::PermissionsExt;
1853        std::thread::spawn(move || {
1854            let entry = dir.join(entry_name);
1855            let staged_real = dir.join("__staged_real");
1856            let staged_link = dir.join("__staged_link");
1857            while !stop.load(std::sync::atomic::Ordering::Relaxed) {
1858                // prepare a real file (mode 0644) at a staging name, then rename it over `entry`.
1859                let _ = std::fs::remove_file(&staged_real);
1860                if std::fs::write(&staged_real, b"REAL").is_err() {
1861                    continue;
1862                }
1863                let _ =
1864                    std::fs::set_permissions(&staged_real, std::fs::Permissions::from_mode(0o644));
1865                let _ = std::fs::rename(&staged_real, &entry);
1866                // prepare a symlink-to-sentinel at the other staging name, then rename it over.
1867                let _ = std::fs::remove_file(&staged_link);
1868                let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
1869                let _ = std::fs::rename(&staged_link, &entry);
1870            }
1871        })
1872    }
1873
1874    // TOCTOU race: while rchm runs `--mode g+w` over a directory, the entry inside it is
1875    // rapidly flipped between a real file (0644) and a symlink to a SENTINEL file that lives
1876    // OUTSIDE the tree with a distinctive mode (0600). rchm classifies each entry through an
1877    // O_PATH/O_NOFOLLOW handle and chmods that exact pinned inode — so it may chmod the real
1878    // file (0644 -> 0664), or see a symlink (which it never chmods), or fail closed, but it
1879    // must NEVER follow the link and change the sentinel's mode. The sentinel staying 0600 on
1880    // every iteration is the safety assertion. Also confirms the run terminates (timeout).
1881    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1882    async fn entry_symlink_swap_never_changes_sentinel_mode() -> anyhow::Result<()> {
1883        use std::os::unix::fs::PermissionsExt;
1884        let tmp = crate::testutils::create_temp_dir().await?;
1885        let root = tmp.as_path();
1886        // sentinel lives OUTSIDE the rchm target tree, with a distinctive mode we must not touch.
1887        let sentinel = root.join("sentinel_secret");
1888        tokio::fs::write(&sentinel, b"SENTINEL").await?;
1889        std::fs::set_permissions(&sentinel, std::fs::Permissions::from_mode(0o600))?;
1890        let sentinel_uid_before =
1891            std::os::unix::fs::MetadataExt::uid(&std::fs::symlink_metadata(&sentinel)?);
1892        // the tree rchm operates on: a directory containing the entry that gets swapped.
1893        let target_dir = root.join("tree");
1894        tokio::fs::create_dir(&target_dir).await?;
1895        let entry_path = target_dir.join("entry");
1896        tokio::fs::write(&entry_path, b"REAL").await?;
1897        std::fs::set_permissions(&entry_path, std::fs::Permissions::from_mode(0o644))?;
1898
1899        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1900        let swapper =
1901            spawn_file_symlink_swapper(target_dir.clone(), "entry", sentinel.clone(), stop.clone());
1902
1903        // g+w on a file would turn the sentinel 0600 -> 0620 if the link were ever followed.
1904        let settings = settings_with("g+w", None, None);
1905        let mut caught = 0usize;
1906        let mut changed_real = 0usize;
1907        for i in 0..300 {
1908            let result = tokio::time::timeout(
1909                std::time::Duration::from_secs(30),
1910                chmod(&RACE_PROGRESS, &target_dir, &settings),
1911            )
1912            .await
1913            .expect("rchm must not hang under concurrent swapping");
1914            match result {
1915                Ok(summary) => changed_real += summary.files_changed,
1916                Err(_) => caught += 1, // a swap was caught mid-run (failed closed for that entry)
1917            }
1918            // CORE SAFETY ASSERTION (holds on every iteration regardless of timing): the
1919            // out-of-tree sentinel's mode and owner are never modified by rchm.
1920            let sentinel_md = std::fs::symlink_metadata(&sentinel)?;
1921            assert_eq!(
1922                sentinel_md.permissions().mode() & 0o7777,
1923                0o600,
1924                "iteration {i}: sentinel mode changed — rchm followed the symlink to chmod it"
1925            );
1926            assert_eq!(
1927                std::os::unix::fs::MetadataExt::uid(&sentinel_md),
1928                sentinel_uid_before,
1929                "iteration {i}: sentinel owner changed — rchm followed the symlink to chown it"
1930            );
1931        }
1932
1933        stop.store(true, std::sync::atomic::Ordering::Relaxed);
1934        swapper.join().expect("swapper thread panicked");
1935        // sanity (not the safety assertion): the run did observable work across the iterations.
1936        tracing::info!("entry swap: caught={caught}, changed_real={changed_real}");
1937        assert!(
1938            caught + changed_real > 0,
1939            "expected at least one observable outcome across the iterations"
1940        );
1941        Ok(())
1942    }
1943
1944    /// Stress tests exercising `pending_meta` (max-open-files) saturation during rchm. The module
1945    /// name carries the `max_open_files` substring so nextest's serial test-group isolates these
1946    /// from anything else that mutates the process-wide throttle limit (see `.config/nextest.toml`).
1947    mod max_open_files_tests {
1948        use super::*;
1949        use crate::walk_driver::process_entry;
1950
1951        static PROGRESS: std::sync::LazyLock<Progress> = std::sync::LazyLock::new(Progress::new);
1952
1953        /// Regression for the hold-and-wait deadlock when a getdents leaf-hint entry is actually a
1954        /// directory (the DT_UNKNOWN edge, or a getdents-vs-`child()` swap). A `pending_meta` permit
1955        /// is pre-acquired for hinted leaves only; if such an entry is really a directory, the
1956        /// permit must be DROPPED before recursing, or it is held while its children block acquiring
1957        /// one and a saturated pool hangs the walk.
1958        ///
1959        /// Reproduced deterministically by driving a directory entry through the driver's
1960        /// [`process_entry`] with the real [`ChmodVisitor`] and the one-and-only permit pre-acquired
1961        /// (as the spawn loop does for a hinted leaf): with the bug the held permit strands the
1962        /// child's pre-acquire and the timeout fires; with the driver's single drop-before-recurse
1963        /// site the child acquires it and the walk completes. (Runs the real `ChmodVisitor` through
1964        /// the driver — the chmod-side coverage the old direct `chmod_entry` call gave, now that
1965        /// `chmod_entry` no longer exists after the Phase E conversion.)
1966        #[tokio::test]
1967        async fn hinted_leaf_that_is_dir_drops_permit_before_recursion() -> anyhow::Result<()> {
1968            let root = crate::testutils::create_temp_dir().await?;
1969            // `d` is a directory (the authoritative type) holding one child file `c`.
1970            let dir_path = root.join("d");
1971            tokio::fs::create_dir(&dir_path).await?;
1972            let child_path = dir_path.join("c");
1973            tokio::fs::write(&child_path, b"x").await?;
1974            std::fs::set_permissions(&child_path, std::fs::Permissions::from_mode(0o644))?;
1975            // size the pending-meta pool to a single permit so a held-across-recursion permit
1976            // strands the child's pre-acquire — the saturation the fd-walk must tolerate.
1977            throttle::set_max_open_files(1);
1978            // open the container of `d` and classify `d` itself: an authoritative directory handle.
1979            let parent = Dir::open_parent_dir(&root, congestion::Side::Destination)
1980                .await
1981                .context("open parent dir")?;
1982            let parent = Arc::new(parent.into_tree());
1983            let name = std::ffi::OsStr::new("d");
1984            let handle = parent.child(name).await.context("classify d")?;
1985            assert_eq!(
1986                handle.kind(),
1987                EntryKind::Dir,
1988                "fixture `d` must be a directory"
1989            );
1990            drop(handle);
1991            // build the visitor + root context for `d`, pre-acquire the single permit as the spawn
1992            // loop does for a hinted leaf, and hand it to `process_entry` (the fix drops it before
1993            // recursing).
1994            let visitor = Arc::new(ChmodVisitor {
1995                prog_track: &PROGRESS,
1996                settings: settings_with("g+w", None, None),
1997            });
1998            let cx = EntryCx {
1999                parent: Arc::clone(&parent),
2000                name: name.to_owned(),
2001                rel_path: PathBuf::new(),
2002                filter_path: PathBuf::new(),
2003                real_path: dir_path.clone(),
2004                dry_run: false,
2005                prog_track: &PROGRESS,
2006            };
2007            let permit = crate::walk::preacquire_leaf_permit(
2008                PermitKind::PendingMeta,
2009                Some(EntryKind::File),
2010                |_| true,
2011            )
2012            .await;
2013            assert!(permit.is_some(), "the pre-acquire must take the one permit");
2014            let result = tokio::time::timeout(
2015                std::time::Duration::from_secs(20),
2016                process_entry(visitor, cx, (), permit),
2017            )
2018            .await;
2019            // restore the default (disabled) pool before asserting so a failure here can't strand
2020            // the tiny limit for any concurrent test (the serial group already isolates us, but
2021            // this keeps the process-global knob clean on the failure path too).
2022            throttle::set_max_open_files(0);
2023            let summary = result
2024                .context(
2025                    "process_entry hung — leaf permit held across directory recursion (deadlock)",
2026                )?
2027                .map_err(|e| e.source)
2028                .context("process_entry failed")?;
2029            // both the directory and its child get g+w (0644 -> 0664 on the file).
2030            assert_eq!(
2031                summary.files_changed, 1,
2032                "child file should have its mode changed"
2033            );
2034            assert_eq!(
2035                summary.directories_changed, 1,
2036                "directory should have its mode changed"
2037            );
2038            assert_eq!(
2039                std::fs::symlink_metadata(&child_path)?.permissions().mode() & 0o777,
2040                0o664,
2041                "child file mode should be g+w applied"
2042            );
2043            Ok(())
2044        }
2045    }
2046}