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