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::{AdmittedLeaf, 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 every syscall acts on that inode
816/// rather than on a name. A concurrent rename or symlink swap of the directory entry cannot redirect
817/// these operations. Copy overwrite has a different contract: its by-name mutation is contained by
818/// a pinned parent but is not bound to the inode classified earlier.
819///
820/// Syscall choice, normally following the documented chown → chmod ordering:
821/// * **pre-chown chmod** — under the no-set-ID policy, existing set-ID bits are
822///   cleared before changing ownership rather than deliberately carried into a
823///   new user or group. The final masked chmod enforces the completed state after
824///   chown; the separate syscalls do not freeze concurrent mode changes.
825/// * **chown** — [`safedir::fchown_handle`] (inode-exact `fchownat` with
826///   `AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW`). Applies to files, dirs, AND symlinks.
827/// * **chmod** — [`safedir::chmod_via_proc_fd`] (chmod of the inode via its
828///   `/proc/self/fd` magic symlink). Used for both files and directories: `fchmod`
829///   is `EBADF` on the `O_PATH` handle, and the `/proc` path is inode-exact, works
830///   on all kernels, and crucially works even on a `0000`-mode directory we own
831///   (so a pre-order `d:u+rwx` can recover an unreadable directory before we open
832///   it to recurse). Symlinks are never chmod'd — [`compute_plan`] guarantees
833///   `plan.chmod` is `None` for a symlink.
834async fn apply_plan(handle: &Handle, plan: &EntryPlan) -> anyhow::Result<()> {
835    if let Some(mode) = plan.chmod_before_chown {
836        safedir::chmod_via_proc_fd(handle, congestion::Side::Destination, mode)
837            .await
838            .with_context(|| {
839                format!("failed to clear set-ID bits via fd before chown (mode {mode:04o})")
840            })?;
841    }
842    if let Some((uid, gid)) = plan.chown {
843        safedir::fchown_handle(handle, congestion::Side::Destination, uid, gid)
844            .await
845            .with_context(|| format!("failed to chown via fd (uid={uid:?}, gid={gid:?})"))?;
846    }
847    if let Some(mode) = plan.chmod {
848        safedir::chmod_via_proc_fd(handle, congestion::Side::Destination, mode)
849            .await
850            .with_context(|| format!("failed to chmod via fd to {mode:04o}"))?;
851    }
852    Ok(())
853}
854
855/// Apply the change to a single entry (a leaf, or a directory after its children
856/// were processed). Handles the time filter, dry-run, no-op skip, and counters.
857///
858/// `handle` is the entry's fd-pinned `O_PATH` [`Handle`]; its [`Handle::meta`]
859/// snapshot feeds [`compute_plan`] (replacing the old path-based `symlink_metadata`)
860/// and is the target of the inode-exact chown/chmod. `path` is the reconstructed
861/// display path used purely for dry-run output and diagnostics.
862async fn apply_entry_change(
863    prog: &'static Progress,
864    path: &std::path::Path,
865    handle: &Handle,
866    kind: EntryKind,
867    settings: &Settings,
868) -> Result<Summary, Error> {
869    if let Some(ref time_filter) = settings.time_filter {
870        // the time filter needs full std metadata (btime for --created-before), which
871        // the fd snapshot does not carry; read it inode-exact through the pinned handle.
872        let metadata =
873            match safedir::stat_meta_via_proc_fd(handle, congestion::Side::Destination).await {
874                Ok(md) => md,
875                Err(err) => {
876                    let err = anyhow::Error::new(err).context(format!(
877                        "failed reading metadata for time filter on {path:?}"
878                    ));
879                    if settings.fail_early {
880                        return Err(Error::new(err, Default::default()));
881                    }
882                    tracing::warn!("time filter failed for {:?}, skipping: {:#}", path, &err);
883                    kind.inc_skipped(prog);
884                    return Ok(skipped_summary_for(kind));
885                }
886            };
887        match time_filter.matches(&metadata) {
888            Ok(result) => {
889                if let Some(reason) = result.as_skip_reason() {
890                    if let Some(mode) = settings.dry_run {
891                        crate::dry_run::report_time_skip(path, reason, mode, kind.label());
892                    }
893                    kind.inc_skipped(prog);
894                    return Ok(skipped_summary_for(kind));
895                }
896            }
897            Err(err) => {
898                let err = err.context(format!("failed evaluating time filter on {path:?}"));
899                if settings.fail_early {
900                    return Err(Error::new(err, Default::default()));
901                }
902                tracing::warn!("time filter failed for {:?}, skipping: {:#}", path, &err);
903                kind.inc_skipped(prog);
904                return Ok(skipped_summary_for(kind));
905            }
906        }
907    }
908    let meta = handle.meta();
909    let cur_mode = mode_of(meta);
910    let plan = compute_plan(cur_mode, meta.uid(), meta.gid(), kind, settings);
911    if plan.is_noop() {
912        if let Some(crate::config::DryRunMode::All) = settings.dry_run {
913            println!("unchanged {} {:?}", kind.label(), path);
914        }
915        return Ok(inc_unchanged(prog, kind));
916    }
917    if settings.dry_run.is_some() {
918        let desc = describe_change(cur_mode, meta.uid(), meta.gid(), &plan);
919        println!("would modify {} {:?}: {}", kind.label(), path, desc);
920        return Ok(inc_changed(prog, kind));
921    }
922    apply_plan(handle, &plan)
923        .await
924        .map_err(|err| Error::new(err, Default::default()))?;
925    Ok(inc_changed(prog, kind))
926}
927
928/// Public entry point. Applies metadata changes to `path` and, recursively, its
929/// contents. Mirrors [`crate::rm::rm`] for the root-filter check.
930///
931/// The walk is fd-based (see [`crate::safedir`]): the root operand is opened
932/// relative to its parent directory and every entry is classified and mutated
933/// through file-descriptor-relative syscalls. A privileged `rchm` therefore cannot
934/// be redirected by a concurrent symlink swap into chmod/chown'ing a target outside
935/// the intended tree — the `O_NOFOLLOW` opens and the inode-pinned `O_PATH` handles
936/// catch the swap and fail closed.
937#[instrument(skip(prog_track, settings))]
938pub async fn chmod(
939    prog_track: &'static Progress,
940    path: &std::path::Path,
941    settings: &Settings,
942) -> Result<Summary, Error> {
943    // command-line callers may run many root operands concurrently. admit each one before opening
944    // its parent or performing the optional fd-based filter probe, then transfer that same permit
945    // into the shared driver. an authoritative directory releases it before descent.
946    let permit = crate::walk::ensure_leaf_permit(PermitKind::PendingMeta, None).await;
947    enum ChmodRootSetup {
948        Complete(Summary),
949        Ready {
950            operand: crate::walk::RootOperand,
951            parent: Arc<Dir>,
952        },
953    }
954    let setup =
955        safedir::with_optional_fd_admission(permit.as_ref().map(LeafPermit::admission), async {
956            // decompose the operand into (parent dir, final component) so the root entry is opened and
957            // classified relative to a directory fd — the same fd-relative shape every nested entry takes.
958            // rchm mutates "the" tree in place, so the parent is opened on the Destination side. `.`/`..`
959            // operands (e.g. `rchm -R … .`) are canonicalized so they still name a directory; `/` is
960            // rejected.
961            let operand = crate::walk::split_root_operand(path)
962                .await
963                .map_err(|err| Error::new(err, Default::default()))?;
964            let parent_path = operand.parent.as_path();
965            let name = operand.name.as_os_str();
966            let path = operand.display.as_path();
967            // the operand's TRUSTED parent prefix is resolved following symlinks normally (the prefix is
968            // trusted up to and including the operand's container — only entries strictly below the named
969            // root are O_NOFOLLOW-hardened). a symlinked parent (e.g. `rchm symlinkdir/foo`) is followed; the
970            // operand itself is still classified via `child(name)` with O_NOFOLLOW (a symlink root is
971            // operated on as the link itself).
972            let parent = Dir::open_parent_dir(parent_path, congestion::Side::Destination)
973                .await
974                .with_context(|| format!("cannot open parent directory {parent_path:?}"))
975                .map_err(|err| Error::new(err, Default::default()))?;
976            // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below here).
977            let parent = Arc::new(parent.into_tree());
978            if let Some(ref filter) = settings.filter {
979                // classify the root via its parent fd purely to evaluate the root filter; the driver
980                // re-classifies the root authoritatively in `process_entry`, so this handle is just a probe.
981                let root_handle = parent
982                    .child(name)
983                    .await
984                    .with_context(|| format!("failed reading metadata from {path:?}"))
985                    .map_err(|err| Error::new(err, Default::default()))?;
986                let name_path = std::path::Path::new(name);
987                match filter
988                    .should_include_root_item(name_path, root_handle.kind() == EntryKind::Dir)
989                {
990                    crate::filter::FilterResult::Included => {}
991                    result => {
992                        let kind = root_handle.kind();
993                        if let Some(mode) = settings.dry_run {
994                            crate::dry_run::report_skip(path, &result, mode, kind.label_long());
995                        }
996                        kind.inc_skipped(prog_track);
997                        return Ok(ChmodRootSetup::Complete(skipped_summary_for(kind)));
998                    }
999                }
1000            }
1001            Ok(ChmodRootSetup::Ready { operand, parent })
1002        })
1003        .await?;
1004    let (operand, parent) = match setup {
1005        ChmodRootSetup::Complete(summary) => return Ok(summary),
1006        ChmodRootSetup::Ready { operand, parent } => (operand, parent),
1007    };
1008    let name = operand.name.as_os_str();
1009    let path = operand.display.as_path();
1010    run_chmod_root(prog_track, &parent, name, path, settings, permit).await
1011}
1012
1013/// Build the [`ChmodVisitor`] and process the root entry through the generic
1014/// [`crate::walk_driver`] driver. The root is processed exactly like a nested child: classified
1015/// authoritatively, then dispatched to `visit_leaf` or `dir_pre`/recurse/`dir_post`.
1016async fn run_chmod_root(
1017    prog_track: &'static Progress,
1018    parent: &Arc<Dir>,
1019    name: &OsStr,
1020    root: &std::path::Path,
1021    settings: &Settings,
1022    permit: Option<LeafPermit>,
1023) -> Result<Summary, Error> {
1024    let visitor = Arc::new(ChmodVisitor {
1025        prog_track,
1026        settings: settings.clone(),
1027    });
1028    // the root entry's owned context: rel_path/filter_path empty (the root), real_path = the root
1029    // operand. chmod has no filter base (no delegated subtree), so `filter_path == rel_path`.
1030    let root_cx = EntryCx {
1031        parent: Arc::clone(parent),
1032        name: name.to_owned(),
1033        rel_path: PathBuf::new(),
1034        filter_path: PathBuf::new(),
1035        real_path: root.to_path_buf(),
1036        dry_run: settings.dry_run.is_some(),
1037        prog_track,
1038    };
1039    process_entry(visitor, root_cx, (), permit).await // chmod has no second tree → root context `()`
1040}
1041
1042/// The chmod walk's [`WalkVisitor`]. The driver owns enumeration, the leaf-permit lifecycle,
1043/// spawning, the single drop-before-recurse site, and the error fold; this visitor supplies
1044/// chmod's per-entry bodies (`apply_entry_change` for leaves, `apply_dir_self` for the directory's
1045/// own pre-/post-order change, `open_dir` for descent). chmod has no second tree, so
1046/// [`Self::DirContext`] is `()`.
1047struct ChmodVisitor {
1048    prog_track: &'static Progress,
1049    settings: Settings,
1050}
1051
1052/// State threaded from [`WalkVisitor::dir_pre`] to [`WalkVisitor::dir_post`] (same task) — what
1053/// `dir_post` needs to apply the directory's own change.
1054struct ChmodDirState {
1055    /// The directory's owned `O_PATH` handle; the post-order chmod goes through its `/proc/self/fd`
1056    /// magic symlink, inode-exact (works even on a `0000`-mode directory).
1057    handle: Handle,
1058    /// Only traversed to find include-matches (not directly matched), so its own mode/owner is left
1059    /// unchanged (mirrors rm.rs).
1060    traversed_only: bool,
1061    /// The directory's own pre-order contribution, folded into the final summary by `dir_post`.
1062    base: Summary,
1063    /// A pre-order change error captured in keep-going mode (the descent still proceeded, so
1064    /// `dir_post` surfaces it). `None` when the pre-order change succeeded or was deferred —
1065    /// mutually exclusive with the post-order change `dir_post` applies under `defer_dir_changes`.
1066    pre_order_error: Option<anyhow::Error>,
1067}
1068
1069impl WalkVisitor for ChmodVisitor {
1070    type Summary = Summary;
1071    type DirContext = ();
1072    type DirState = ChmodDirState;
1073
1074    fn root_dir_context(&self) {}
1075
1076    fn permit_kind(&self) -> PermitKind {
1077        // use the separate pending-meta pool to bound fd-backed entry work without coupling chmod
1078        // to copy's open-file pool.
1079        PermitKind::PendingMeta
1080    }
1081
1082    fn fail_early(&self) -> bool {
1083        self.settings.fail_early
1084    }
1085
1086    fn filter(&self) -> Option<&crate::filter::FilterSettings> {
1087        self.settings.filter.as_ref()
1088    }
1089
1090    fn on_skip(
1091        &self,
1092        cx: &EntryCx,
1093        kind: EntryKind,
1094        skip_result: &crate::filter::FilterResult,
1095    ) -> Summary {
1096        // mirror the old spawn loop's inline filter-skip: the dry-run "skip ..." line plus the
1097        // matching `*_skipped` counter. the driver already did the shared progress increment.
1098        if let Some(mode) = self.settings.dry_run {
1099            crate::dry_run::report_skip(&cx.real_path, skip_result, mode, kind.label());
1100        }
1101        skipped_summary_for(kind)
1102    }
1103
1104    async fn visit_leaf(
1105        &self,
1106        cx: &EntryCx,
1107        _parent_ctx: &(),
1108        leaf: AdmittedLeaf,
1109    ) -> Result<Summary, Error> {
1110        // the leaf change (time-filter + compute_plan + apply_plan), exactly as the old leaf branch.
1111        apply_entry_change(
1112            self.prog_track,
1113            &cx.real_path,
1114            leaf.handle(),
1115            leaf.kind(),
1116            &self.settings,
1117        )
1118        .await
1119    }
1120
1121    async fn dir_pre(&self, cx: &EntryCx, _parent_ctx: &(), handle: &Handle) -> DirPreResult<Self> {
1122        let path = &cx.real_path;
1123        // a "traversed-only" dir was entered only because it COULD contain include-matches; it does
1124        // not directly match an include pattern, so its own mode/owner is left unchanged (mirrors
1125        // rm.rs) — only directly-selected entries are modified.
1126        let traversed_only = self.settings.filter.as_ref().is_some_and(|f| {
1127            f.has_includes() && !f.directly_matches_include(&cx.filter_path, true)
1128        });
1129        let mut base = Summary::default();
1130        let mut pre_order_error: Option<anyhow::Error> = None;
1131        // pre-order (default, like `chmod -R`): change the dir BEFORE descending, via the O_PATH
1132        // handle's /proc path (works even on a 0000 dir) — so `--mode d:u+rwx` recovers an
1133        // unreadable dir before the open reads it, and a later child failure can't prevent it. a
1134        // restrictive change (e.g. `d:a-rwx`) makes the open fail and is reported (like `chmod -R`).
1135        if !self.settings.defer_dir_changes {
1136            match apply_dir_self(
1137                self.prog_track,
1138                path,
1139                handle,
1140                traversed_only,
1141                &self.settings,
1142            )
1143            .await
1144            {
1145                Ok(dir_summary) => base = base + dir_summary,
1146                // fail-early: report now, never open or descend. keep-going: stash the error for
1147                // `dir_post` to surface after the descent, and still open and recurse (old flow).
1148                Err(error) if self.settings.fail_early => {
1149                    return Err(Error::new(error.source, base + error.summary));
1150                }
1151                Err(error) => {
1152                    tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
1153                    base = base + error.summary;
1154                    pre_order_error = Some(error.source);
1155                }
1156            }
1157        }
1158        // dup the dir's O_PATH handle (a pure fd dup — no extra openat/fstatat) so `dir_post` can
1159        // apply the deferred/post-order change inode-exact: the driver lends `&handle` only for
1160        // `dir_pre`, so the post-order step needs its own owned handle to the same inode.
1161        let dir_handle = handle
1162            .try_clone()
1163            .with_context(|| format!("cannot duplicate directory handle for {path:?}"))
1164            .map_err(|err| Error::new(err, base))?;
1165        // open the directory's real fd for the driver to enumerate. in post-order the dir still has
1166        // its original mode, so the open succeeds and the held fd survives a later search-bit strip.
1167        // an open failure (restrictive pre-order change, or a pre-existing unreadable dir) is reported.
1168        match cx.parent.open_dir(&cx.name).await {
1169            Ok(dir) => Ok(DirAction::Descend {
1170                dir: Arc::new(dir),
1171                child_ctx: (),
1172                state: ChmodDirState {
1173                    handle: dir_handle,
1174                    traversed_only,
1175                    base,
1176                    pre_order_error,
1177                },
1178            }),
1179            Err(error) => {
1180                let error = anyhow::Error::new(error)
1181                    .context(format!("cannot open directory {path:?} for reading"));
1182                if self.settings.fail_early {
1183                    return Err(Error::new(error, base));
1184                }
1185                // keep-going: no children to walk. fold the pre-order error (if any) + the open
1186                // error, apply the deferred change (if any), and surface the combined error — the
1187                // net result of the old open-failure path (empty join loop, then deferred change).
1188                let errors = crate::error_collector::ErrorCollector::default();
1189                if let Some(pre_order_error) = pre_order_error {
1190                    errors.push(pre_order_error);
1191                }
1192                tracing::error!("chmod: {:#}", &error);
1193                errors.push(error);
1194                if self.settings.defer_dir_changes {
1195                    match apply_dir_self(
1196                        self.prog_track,
1197                        path,
1198                        handle,
1199                        traversed_only,
1200                        &self.settings,
1201                    )
1202                    .await
1203                    {
1204                        Ok(dir_summary) => base = base + dir_summary,
1205                        Err(error) => {
1206                            tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
1207                            base = base + error.summary;
1208                            errors.push(error.source);
1209                        }
1210                    }
1211                }
1212                // `into_error()` is `Some` here: at least the open error was pushed.
1213                Err(Error::new(errors.into_error().unwrap(), base))
1214            }
1215        }
1216    }
1217
1218    async fn dir_post(
1219        &self,
1220        cx: &EntryCx,
1221        state: ChmodDirState,
1222        _processed: &ProcessedChildren,
1223        child_result: Result<Summary, Error>,
1224    ) -> Result<Summary, Error> {
1225        let ChmodDirState {
1226            handle,
1227            traversed_only,
1228            base,
1229            pre_order_error,
1230        } = state;
1231        let path = &cx.real_path;
1232        // seed with the dir's own pre-order contribution, then fold the children (the driver passes
1233        // `Err` here only in keep-going mode — fail-early aborts before post-order).
1234        let (child_summary, child_error) = match child_result {
1235            Ok(summary) => (summary, None),
1236            Err(err) => (err.summary, Some(err.source)),
1237        };
1238        let mut summary = base + child_summary;
1239        // collect the deferred pre-order error and the child error (keep-going only).
1240        let errors = crate::error_collector::ErrorCollector::default();
1241        if let Some(pre_order_error) = pre_order_error {
1242            errors.push(pre_order_error);
1243        }
1244        if let Some(child_error) = child_error {
1245            errors.push(child_error);
1246        }
1247        // post-order (`--defer-dir-changes`): change the dir AFTER its contents (needed to remove
1248        // the owner's own traversal permission). the contents were read through a fd opened before
1249        // this change, so stripping the search bit here can't lock us out; the change is inode-exact
1250        // through the O_PATH handle. (`pre_order_error` is always `None` here, so the two are
1251        // mutually exclusive.)
1252        if self.settings.defer_dir_changes {
1253            match apply_dir_self(
1254                self.prog_track,
1255                path,
1256                &handle,
1257                traversed_only,
1258                &self.settings,
1259            )
1260            .await
1261            {
1262                Ok(dir_summary) => summary = summary + dir_summary,
1263                Err(error) => {
1264                    if self.settings.fail_early {
1265                        return Err(Error::new(error.source, summary + error.summary));
1266                    }
1267                    tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
1268                    summary = summary + error.summary;
1269                    errors.push(error.source);
1270                }
1271            }
1272        }
1273        if let Some(error) = errors.into_error() {
1274            return Err(Error::new(error, summary));
1275        }
1276        Ok(summary)
1277    }
1278}
1279
1280/// Apply the directory's own change, or skip it when the directory was only traversed to
1281/// find include-matches. Factored out so the walk can run it before (pre-order, default)
1282/// or after (post-order, `--defer-dir-changes`) descending into the contents.
1283///
1284/// `handle` is the directory's `O_PATH` handle; the chmod goes through its `/proc/self/fd`
1285/// magic symlink, so the change applies inode-exact and works even on a `0000`-mode
1286/// directory (a pre-order `d:u+rwx` recovers traversability before we open the dir to read).
1287async fn apply_dir_self(
1288    prog_track: &'static Progress,
1289    path: &std::path::Path,
1290    handle: &Handle,
1291    traversed_only: bool,
1292    settings: &Settings,
1293) -> Result<Summary, Error> {
1294    if traversed_only {
1295        if let Some(crate::config::DryRunMode::All) = settings.dry_run {
1296            println!("skip dir {path:?} (only traversed for include matches)");
1297        }
1298        prog_track.directories_skipped.inc();
1299        return Ok(skipped_summary_for(EntryKind::Dir));
1300    }
1301    apply_entry_change(prog_track, path, handle, EntryKind::Dir, settings).await
1302}
1303
1304#[cfg(test)]
1305mod tests {
1306    use super::*;
1307    #[test]
1308    fn mode_token_octal() {
1309        assert_eq!(parse_mode_token("2775").unwrap(), ModeSpec::Octal(0o2775));
1310        assert_eq!(parse_mode_token("0644").unwrap(), ModeSpec::Octal(0o644));
1311    }
1312    #[test]
1313    fn mode_token_octal_out_of_range_errors() {
1314        assert!(parse_mode_token("9999").is_err()); // 9 is not octal
1315        assert!(parse_mode_token("77777").is_err()); // > 12 bits
1316    }
1317    #[test]
1318    fn mode_token_symbolic_simple() {
1319        let spec = parse_mode_token("g+w").unwrap();
1320        assert_eq!(
1321            spec,
1322            ModeSpec::Symbolic(vec![SymbolicClause {
1323                who: WHO_G,
1324                op: ModeOp::Add,
1325                perms: PERM_W
1326            }])
1327        );
1328    }
1329    #[test]
1330    fn mode_token_symbolic_omitted_who_means_all() {
1331        let spec = parse_mode_token("+x").unwrap();
1332        assert_eq!(
1333            spec,
1334            ModeSpec::Symbolic(vec![SymbolicClause {
1335                who: WHO_A,
1336                op: ModeOp::Add,
1337                perms: PERM_X
1338            }])
1339        );
1340    }
1341    #[test]
1342    fn mode_token_symbolic_comma_chained() {
1343        let spec = parse_mode_token("u+rw,g-w").unwrap();
1344        let ModeSpec::Symbolic(clauses) = spec else {
1345            panic!("expected symbolic")
1346        };
1347        assert_eq!(clauses.len(), 2);
1348        assert_eq!(
1349            clauses[0],
1350            SymbolicClause {
1351                who: WHO_U,
1352                op: ModeOp::Add,
1353                perms: PERM_R | PERM_W
1354            }
1355        );
1356        assert_eq!(
1357            clauses[1],
1358            SymbolicClause {
1359                who: WHO_G,
1360                op: ModeOp::Remove,
1361                perms: PERM_W
1362            }
1363        );
1364    }
1365    #[test]
1366    fn mode_token_symbolic_bigx_and_specials() {
1367        let spec = parse_mode_token("g+rwXs").unwrap();
1368        assert_eq!(
1369            spec,
1370            ModeSpec::Symbolic(vec![SymbolicClause {
1371                who: WHO_G,
1372                op: ModeOp::Add,
1373                perms: PERM_R | PERM_W | PERM_BIGX | PERM_S,
1374            }])
1375        );
1376    }
1377    #[test]
1378    fn mode_token_rejects_garbage() {
1379        assert!(parse_mode_token("q+z").is_err());
1380        assert!(parse_mode_token("g!w").is_err());
1381        assert!(parse_mode_token("").is_err());
1382    }
1383    #[test]
1384    fn summary_add_combines_fields() {
1385        let a = Summary {
1386            files_changed: 1,
1387            directories_changed: 2,
1388            files_unchanged: 3,
1389            ..Default::default()
1390        };
1391        let b = Summary {
1392            files_changed: 10,
1393            symlinks_skipped: 4,
1394            ..Default::default()
1395        };
1396        let sum = a + b;
1397        assert_eq!(sum.files_changed, 11);
1398        assert_eq!(sum.directories_changed, 2);
1399        assert_eq!(sum.files_unchanged, 3);
1400        assert_eq!(sum.symlinks_skipped, 4);
1401    }
1402    #[test]
1403    fn owner_dsl_bare_applies_to_all_types() {
1404        let prog = parse_owner_dsl("0", IdKind::User, &GetentResolver::default()).unwrap();
1405        assert_eq!(prog.file, Some(0));
1406        assert_eq!(prog.dir, Some(0));
1407        assert_eq!(prog.symlink, Some(0));
1408    }
1409    #[test]
1410    fn owner_dsl_per_type_overrides() {
1411        let prog = parse_owner_dsl("f:1 d:2", IdKind::User, &GetentResolver::default()).unwrap();
1412        assert_eq!(prog.file, Some(1));
1413        assert_eq!(prog.dir, Some(2));
1414        assert_eq!(prog.symlink, None);
1415    }
1416    #[test]
1417    fn owner_dsl_bare_plus_override() {
1418        let prog = parse_owner_dsl("5 d:2", IdKind::Group, &GetentResolver::default()).unwrap();
1419        assert_eq!(prog.file, Some(5));
1420        assert_eq!(prog.dir, Some(2));
1421        assert_eq!(prog.symlink, Some(5));
1422    }
1423    #[test]
1424    fn owner_dsl_explicit_before_bare_is_order_independent() {
1425        let prog = parse_owner_dsl("f:1 5", IdKind::User, &GetentResolver::default()).unwrap();
1426        assert_eq!(prog.file, Some(1));
1427        assert_eq!(prog.dir, Some(5));
1428        assert_eq!(prog.symlink, Some(5));
1429    }
1430    #[test]
1431    fn owner_dsl_rejects_multiple_bare() {
1432        assert!(parse_owner_dsl("1 2", IdKind::User, &GetentResolver::default()).is_err());
1433    }
1434    #[test]
1435    fn owner_dsl_rejects_unknown_id() {
1436        assert!(
1437            parse_owner_dsl(
1438                "definitely-no-such-group-xyz",
1439                IdKind::Group,
1440                &GetentResolver::default()
1441            )
1442            .is_err()
1443        );
1444    }
1445    #[test]
1446    fn owner_dsl_resolves_root_name() {
1447        // "root" is in /etc/passwd everywhere — covers the in-process from_name path
1448        let prog = parse_owner_dsl("root", IdKind::User, &GetentResolver::default()).unwrap();
1449        assert_eq!(prog.file, Some(0));
1450        assert_eq!(prog.dir, Some(0));
1451        assert_eq!(prog.symlink, Some(0));
1452    }
1453    #[test]
1454    fn getent_id_parses_passwd_and_group_lines() {
1455        // passwd: name:passwd:uid:gid:gecos:home:shell — field 3 is the uid
1456        assert_eq!(
1457            parse_getent_id("alice:x:1234:100:Alice:/home/alice:/bin/sh").unwrap(),
1458            1234
1459        );
1460        // group: name:passwd:gid:members — field 3 is the gid
1461        assert_eq!(parse_getent_id("data:*:5678:alice,bob").unwrap(), 5678);
1462    }
1463    #[test]
1464    fn getent_id_rejects_malformed_lines() {
1465        assert!(parse_getent_id("").is_err());
1466        assert!(parse_getent_id("alice").is_err());
1467        assert!(parse_getent_id("alice:x").is_err());
1468        assert!(parse_getent_id("alice:x:notanumber:100").is_err());
1469    }
1470    /// Write an executable stub script into `dir` and return its path.
1471    fn write_stub_getent(dir: &std::path::Path, name: &str, body: &str) -> std::path::PathBuf {
1472        use std::os::unix::fs::PermissionsExt;
1473        let path = dir.join(name);
1474        std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
1475        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1476        path
1477    }
1478    #[test]
1479    fn getent_stub_resolves_directory_service_names() {
1480        let tmp = tempfile::tempdir().unwrap();
1481        // simulates an SSSD/LDAP-served entry: visible to getent, absent from /etc files. the
1482        // guard asserts the args are `-- <database> <token>` — i.e. the `--` option terminator
1483        // is present and the database lands in the right slot (a wrong database would exit 99).
1484        let user_stub = write_stub_getent(
1485            tmp.path(),
1486            "getent-user",
1487            "[ \"$1\" = -- ] && [ \"$2\" = passwd ] || exit 99\necho 'ldapuser:*:4242:4242:LDAP User:/home/ldapuser:/bin/sh'",
1488        );
1489        let group_stub = write_stub_getent(
1490            tmp.path(),
1491            "getent-group",
1492            "[ \"$1\" = -- ] && [ \"$2\" = group ] || exit 99\necho 'ldapgroup:*:4343:ldapuser'",
1493        );
1494        assert_eq!(
1495            resolve_via_getent_cmd(user_stub.as_os_str(), "ldapuser", IdKind::User).unwrap(),
1496            4242
1497        );
1498        assert_eq!(
1499            resolve_via_getent_cmd(group_stub.as_os_str(), "ldapgroup", IdKind::Group).unwrap(),
1500            4343
1501        );
1502    }
1503    #[test]
1504    fn getent_stub_not_found_is_unknown() {
1505        let tmp = tempfile::tempdir().unwrap();
1506        let stub = write_stub_getent(tmp.path(), "getent-miss", "exit 2");
1507        let err = resolve_via_getent_cmd(stub.as_os_str(), "nosuch", IdKind::Group).unwrap_err();
1508        assert!(
1509            format!("{err:#}").contains("unknown group: nosuch"),
1510            "got: {err:#}"
1511        );
1512    }
1513    #[test]
1514    fn getent_missing_program_suggests_numeric_id() {
1515        let tmp = tempfile::tempdir().unwrap();
1516        let missing = tmp.path().join("no-such-getent");
1517        let err = resolve_via_getent_cmd(missing.as_os_str(), "alice", IdKind::User).unwrap_err();
1518        assert!(
1519            format!("{err:#}").contains("use a numeric id instead"),
1520            "got: {err:#}"
1521        );
1522    }
1523    #[test]
1524    fn getent_stub_garbled_output_errors() {
1525        let tmp = tempfile::tempdir().unwrap();
1526        let stub = write_stub_getent(tmp.path(), "getent-garbled", "echo 'not a passwd line'");
1527        let err = resolve_via_getent_cmd(stub.as_os_str(), "alice", IdKind::User).unwrap_err();
1528        assert!(
1529            format!("{err:#}").contains("unexpected getent output"),
1530            "got: {err:#}"
1531        );
1532    }
1533    #[test]
1534    fn getent_stub_exit0_no_output_errors() {
1535        let tmp = tempfile::tempdir().unwrap();
1536        let stub = write_stub_getent(tmp.path(), "getent-empty", "exit 0");
1537        let err = resolve_via_getent_cmd(stub.as_os_str(), "alice", IdKind::User).unwrap_err();
1538        assert!(
1539            format!("{err:#}").contains("produced no output"),
1540            "got: {err:#}"
1541        );
1542    }
1543    #[cfg_attr(
1544        rcp_nix_sandbox,
1545        ignore = "Nix sandbox has no getent/NSS lookup service"
1546    )]
1547    #[test]
1548    fn getent_real_resolves_root() {
1549        // root exists in /etc/passwd and /etc/group on every supported host; this
1550        // exercises the real `getent` end-to-end (spawn + parse).
1551        assert_eq!(
1552            resolve_via_getent("root", IdKind::User, &GetentResolver::default()).unwrap(),
1553            0
1554        );
1555        assert_eq!(
1556            resolve_via_getent("root", IdKind::Group, &GetentResolver::default()).unwrap(),
1557            0
1558        );
1559    }
1560    #[cfg_attr(
1561        rcp_nix_sandbox,
1562        ignore = "Nix sandbox has no getent/NSS lookup service"
1563    )]
1564    #[test]
1565    fn getent_real_option_like_name_fails_closed_no_injection() {
1566        // a name starting with `-` must NOT be parsed as a getent option. Without the `--`
1567        // terminator, `getent group --service=files` exits 0 and prints the whole database, so
1568        // this parser would take the first line and silently resolve to gid 0. With `--` the
1569        // name is a lookup key, found in no DB, so resolution fails closed. Reachable in the CLI
1570        // via `rchm --group=--service=files` — a bogus name must never map to root.
1571        let err = resolve_via_getent("--service=files", IdKind::Group, &GetentResolver::default())
1572            .unwrap_err();
1573        let msg = format!("{err:#}");
1574        assert!(
1575            msg.contains("unknown group") || msg.contains("produced no output"),
1576            "option-like name must fail closed (not resolve to an id), got: {msg}"
1577        );
1578    }
1579    #[test]
1580    fn getent_source_rejects_relative_explicit_path() {
1581        // a relative --getent-path would re-introduce a PATH/cwd lookup — rejected up front.
1582        let err = GetentResolver::from_cli(Some(PathBuf::from("getent")), false).unwrap_err();
1583        assert!(
1584            format!("{err:#}").contains("must be an absolute path"),
1585            "got: {err:#}"
1586        );
1587    }
1588    #[test]
1589    fn getent_source_explicit_absolute_used_even_when_privileged() {
1590        // an explicit absolute path is honored verbatim and bypasses the trusted-dir probe.
1591        let resolver =
1592            GetentResolver::from_cli(Some(PathBuf::from("/opt/nss/getent")), true).unwrap();
1593        assert_eq!(
1594            resolver.program_in(&["/usr/bin"]).unwrap(),
1595            Some(PathBuf::from("/opt/nss/getent"))
1596        );
1597    }
1598    #[test]
1599    fn getent_source_unprivileged_searches_path() {
1600        // unprivileged + no override → None means "search PATH normally".
1601        let resolver = GetentResolver::from_cli(None, false).unwrap();
1602        assert_eq!(resolver.program_in(&["/nonexistent"]).unwrap(), None);
1603    }
1604    #[test]
1605    fn getent_source_privileged_probes_trusted_dirs_not_path() {
1606        // privileged + no override → use getent found in a trusted dir; PATH is never consulted.
1607        let tmp = tempfile::tempdir().unwrap();
1608        let bin = tmp.path().join("getent");
1609        std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1610        let resolver = GetentResolver::from_cli(None, true).unwrap();
1611        let dirs = [tmp.path().to_str().unwrap()];
1612        assert_eq!(resolver.program_in(&dirs).unwrap(), Some(bin));
1613    }
1614    #[test]
1615    fn getent_source_privileged_missing_getent_errors_not_path_fallback() {
1616        // privileged + no override + getent absent from every trusted dir → hard error,
1617        // NOT a silent PATH fallback (the core anti-PATH-attack guarantee).
1618        let tmp = tempfile::tempdir().unwrap(); // empty: no getent inside
1619        let resolver = GetentResolver::from_cli(None, true).unwrap();
1620        let dir = tmp.path().to_str().unwrap();
1621        let err = resolver.program_in(&[dir]).unwrap_err();
1622        let msg = format!("{err:#}");
1623        assert!(
1624            msg.contains("PATH is intentionally ignored"),
1625            "should explain PATH is not consulted, got: {msg}"
1626        );
1627        assert!(
1628            msg.contains(dir),
1629            "should name the searched dir, got: {msg}"
1630        );
1631    }
1632    fn sym(s: &str) -> ModeSpec {
1633        parse_mode_token(s).unwrap()
1634    }
1635    #[test]
1636    fn apply_mode_group_add_remove() {
1637        assert_eq!(apply_mode(0o644, &sym("g+w"), false), 0o664);
1638        assert_eq!(apply_mode(0o664, &sym("g-w"), false), 0o644);
1639    }
1640    #[test]
1641    fn apply_mode_set_clears_other_bits() {
1642        // o= clears all 'other' bits (incl sticky), leaves user/group
1643        assert_eq!(apply_mode(0o755, &sym("o="), false), 0o750);
1644        // chained absolute-ish set from zero
1645        assert_eq!(apply_mode(0o000, &sym("u=rwx,go=rx"), false), 0o755);
1646        // o= on a sticky file clears the sticky bit too
1647        assert_eq!(apply_mode(0o1755, &sym("o="), false), 0o0750);
1648    }
1649    #[test]
1650    fn apply_mode_conditional_bigx() {
1651        // file without execute: X does nothing
1652        assert_eq!(apply_mode(0o644, &sym("a+X"), false), 0o644);
1653        // file with a user-execute bit: X applies to all
1654        assert_eq!(apply_mode(0o744, &sym("a+X"), false), 0o755);
1655        // directory: X always applies
1656        assert_eq!(apply_mode(0o644, &sym("a+X"), true), 0o755);
1657    }
1658    #[test]
1659    fn apply_mode_setgid_and_sticky() {
1660        assert_eq!(apply_mode(0o750, &sym("g+rwxs"), true), 0o2770);
1661        assert_eq!(apply_mode(0o755, &sym("+t"), true), 0o1755);
1662        assert_eq!(apply_mode(0o755, &sym("u+s"), false), 0o4755);
1663    }
1664    #[test]
1665    fn apply_mode_sticky_only_responds_to_other() {
1666        // u+t / g+t are no-ops; only o/a/bare set sticky (verified against real chmod)
1667        assert_eq!(apply_mode(0o755, &sym("u+t"), false), 0o755);
1668        assert_eq!(apply_mode(0o755, &sym("g+t"), false), 0o755);
1669        assert_eq!(apply_mode(0o755, &sym("ug+t"), false), 0o755);
1670        assert_eq!(apply_mode(0o755, &sym("o+t"), false), 0o1755);
1671        assert_eq!(apply_mode(0o755, &sym("+t"), false), 0o1755);
1672        assert_eq!(apply_mode(0o1755, &sym("u-t"), false), 0o1755);
1673        assert_eq!(apply_mode(0o1755, &sym("o-t"), false), 0o755);
1674    }
1675    #[test]
1676    fn apply_mode_octal_is_absolute() {
1677        assert_eq!(apply_mode(0o4755, &sym("644"), false), 0o644);
1678        assert_eq!(apply_mode(0o000, &sym("2775"), true), 0o2775);
1679    }
1680    #[test]
1681    fn mode_dsl_bare_applies_to_file_and_dir_not_symlink() {
1682        let prog = parse_mode_dsl("g+rwX").unwrap();
1683        assert!(prog.file.is_some());
1684        assert!(prog.dir.is_some());
1685        // symlinks are not part of ModeProgram at all
1686        assert!(prog.for_kind(EntryKind::Symlink).is_none());
1687    }
1688    #[test]
1689    fn mode_dsl_per_type() {
1690        let prog = parse_mode_dsl("f:g+rw d:g+rwxs").unwrap();
1691        assert_eq!(prog.file, Some(sym("g+rw")));
1692        assert_eq!(prog.dir, Some(sym("g+rwxs")));
1693    }
1694    #[test]
1695    fn mode_dsl_bare_plus_override() {
1696        let prog = parse_mode_dsl("g+r d:g+rwx").unwrap();
1697        assert_eq!(prog.file, Some(sym("g+r")));
1698        assert_eq!(prog.dir, Some(sym("g+rwx")));
1699    }
1700    #[test]
1701    fn mode_dsl_rejects_symlink_section() {
1702        assert!(parse_mode_dsl("l:g+w").is_err());
1703    }
1704    #[test]
1705    fn mode_dsl_rejects_multiple_bare() {
1706        assert!(parse_mode_dsl("g+r o+w").is_err());
1707    }
1708    #[test]
1709    fn mode_dsl_rejects_unknown_prefix() {
1710        assert!(parse_mode_dsl("z:644").is_err());
1711    }
1712    #[test]
1713    fn mode_dsl_single_type_leaves_other_none() {
1714        let prog_f = parse_mode_dsl("f:644").unwrap();
1715        assert!(prog_f.file.is_some());
1716        assert!(prog_f.dir.is_none());
1717        let prog_d = parse_mode_dsl("d:755").unwrap();
1718        assert!(prog_d.dir.is_some());
1719        assert!(prog_d.file.is_none());
1720    }
1721    fn settings_with(mode: &str, owner: Option<&str>, group: Option<&str>) -> Settings {
1722        Settings {
1723            mode: if mode.is_empty() {
1724                ModeProgram::default()
1725            } else {
1726                parse_mode_dsl(mode).unwrap()
1727            },
1728            owner: owner
1729                .map(|s| parse_owner_dsl(s, IdKind::User, &GetentResolver::default()).unwrap())
1730                .unwrap_or_default(),
1731            group: group
1732                .map(|s| parse_owner_dsl(s, IdKind::Group, &GetentResolver::default()).unwrap())
1733                .unwrap_or_default(),
1734            no_setid: false,
1735            fail_early: false,
1736            defer_dir_changes: false,
1737            filter: None,
1738            time_filter: None,
1739            dry_run: None,
1740        }
1741    }
1742    fn settings_with_no_setid(mode: &str, owner: Option<&str>, group: Option<&str>) -> Settings {
1743        let mut settings = settings_with(mode, owner, group);
1744        settings.no_setid = true;
1745        settings
1746    }
1747    #[test]
1748    fn plan_noop_when_already_correct() {
1749        let s = settings_with("g+r", None, None);
1750        // file already group-readable
1751        let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1752        assert!(plan.is_noop());
1753    }
1754    #[test]
1755    fn plan_chmod_when_mode_differs() {
1756        let s = settings_with("g+w", None, None);
1757        let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1758        assert_eq!(plan.chmod, Some(0o664));
1759        assert!(plan.chown.is_none());
1760    }
1761    #[test]
1762    fn plan_chown_only_changed_ids() {
1763        let s = settings_with("", None, Some("2000"));
1764        let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1765        // gid changes 1000 -> 2000, uid untouched
1766        assert_eq!(plan.chown, Some((None, Some(2000))));
1767        assert!(plan.chmod.is_none());
1768    }
1769    #[test]
1770    fn plan_preserves_setgid_across_chgrp() {
1771        // file is setgid (0o2755), only --group given; chown clears setgid, so we
1772        // must re-chmod to the original mode to keep it.
1773        let s = settings_with("", None, Some("2000"));
1774        let plan = compute_plan(0o2755, 1000, 1000, EntryKind::File, &s);
1775        assert_eq!(plan.chown, Some((None, Some(2000))));
1776        assert_eq!(plan.chmod, Some(0o2755));
1777    }
1778    #[test]
1779    fn plan_symlink_never_chmods_but_chowns() {
1780        let s = settings_with("g+w", None, Some("2000"));
1781        let plan = compute_plan(0o777, 1000, 1000, EntryKind::Symlink, &s);
1782        assert!(plan.chmod.is_none());
1783        assert_eq!(plan.chown, Some((None, Some(2000))));
1784    }
1785    #[test]
1786    fn plan_preserves_setuid_when_mode_rule_noop_but_chown_runs() {
1787        // g+r is a no-op on 0o4755 (group already readable), but the chown clears
1788        // setuid, so the mode-rule branch must still emit a chmod to restore it.
1789        let s = settings_with("g+r", Some("2000"), None);
1790        let plan = compute_plan(0o4755, 1000, 1000, EntryKind::File, &s);
1791        assert_eq!(plan.chown, Some((Some(2000), None)));
1792        assert_eq!(plan.chmod, Some(0o4755));
1793    }
1794    #[test]
1795    fn plan_preserves_setgid_dir_across_chgrp() {
1796        // setgid dir, only --group given; chown clears setgid so chmod restores it.
1797        let s = settings_with("", None, Some("2000"));
1798        let plan = compute_plan(0o2770, 1000, 1000, EntryKind::Dir, &s);
1799        assert_eq!(plan.chown, Some((None, Some(2000))));
1800        assert_eq!(plan.chmod, Some(0o2770));
1801    }
1802    #[test]
1803    fn no_setid_drops_symbolic_setuid_request() {
1804        let s = settings_with_no_setid("u+s", None, None);
1805        let plan = compute_plan(0o755, 1000, 1000, EntryKind::File, &s);
1806        assert!(plan.is_noop());
1807    }
1808    #[test]
1809    fn no_setid_masks_octal_setid_bits() {
1810        let s = settings_with_no_setid("6755", None, None);
1811        let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1812        assert_eq!(plan.chmod, Some(0o755));
1813    }
1814    #[test]
1815    fn no_setid_clears_inherited_bits_on_unrelated_mode_change() {
1816        let s = settings_with_no_setid("g+w", None, None);
1817        let plan = compute_plan(0o6755, 1000, 1000, EntryKind::File, &s);
1818        assert_eq!(plan.chmod, Some(0o775));
1819    }
1820    #[test]
1821    fn no_setid_clears_bits_before_and_after_ownership_change() {
1822        let s = settings_with_no_setid("", Some("2000"), None);
1823        let plan = compute_plan(0o4755, 1000, 1000, EntryKind::File, &s);
1824        assert_eq!(plan.chmod_before_chown, Some(0o755));
1825        assert_eq!(plan.chown, Some((Some(2000), None)));
1826        assert_eq!(plan.chmod, Some(0o755));
1827    }
1828    #[test]
1829    fn no_setid_reapplies_mask_after_chown_without_snapshot_bits() {
1830        let s = settings_with_no_setid("", Some("2000"), None);
1831        let plan = compute_plan(0o755, 1000, 1000, EntryKind::File, &s);
1832        assert!(plan.chmod_before_chown.is_none());
1833        assert_eq!(plan.chown, Some((Some(2000), None)));
1834        assert_eq!(plan.chmod, Some(0o755));
1835    }
1836    #[test]
1837    fn no_setid_clears_bits_when_selected_owner_is_unchanged() {
1838        let s = settings_with_no_setid("", Some("1000"), None);
1839        let plan = compute_plan(0o4755, 1000, 1000, EntryKind::File, &s);
1840        assert!(plan.chmod_before_chown.is_none());
1841        assert!(plan.chown.is_none());
1842        assert_eq!(plan.chmod, Some(0o755));
1843    }
1844    #[test]
1845    fn no_setid_retains_sticky_bit_on_setgid_directory() {
1846        let s = settings_with_no_setid("", None, Some("2000"));
1847        let plan = compute_plan(0o3775, 1000, 1000, EntryKind::Dir, &s);
1848        assert_eq!(plan.chmod_before_chown, Some(0o1775));
1849        assert_eq!(plan.chown, Some((None, Some(2000))));
1850        assert_eq!(plan.chmod, Some(0o1775));
1851    }
1852    #[test]
1853    fn no_setid_never_chmods_symlink() {
1854        let s = settings_with_no_setid("", None, Some("2000"));
1855        let plan = compute_plan(0o6777, 1000, 1000, EntryKind::Symlink, &s);
1856        assert!(plan.chmod_before_chown.is_none());
1857        assert_eq!(plan.chown, Some((None, Some(2000))));
1858        assert!(plan.chmod.is_none());
1859    }
1860
1861    static RACE_PROGRESS: std::sync::LazyLock<Progress> = std::sync::LazyLock::new(Progress::new);
1862
1863    // Repeatedly swap `dir/entry` between a real regular file (mode 0644) and a symlink
1864    // pointing at `sentinel`, using rename so each individual state is atomic. Two staging
1865    // names live alongside `entry` and are renamed over it in a tight loop until `stop` is
1866    // set. Runs on a dedicated OS thread so it makes progress regardless of the tokio
1867    // runtime's scheduling. Mirrors copy's `spawn_file_symlink_swapper`.
1868    fn spawn_file_symlink_swapper(
1869        dir: std::path::PathBuf,
1870        entry_name: &'static str,
1871        sentinel: std::path::PathBuf,
1872        stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
1873    ) -> std::thread::JoinHandle<()> {
1874        use std::os::unix::fs::PermissionsExt;
1875        std::thread::spawn(move || {
1876            let entry = dir.join(entry_name);
1877            let staged_real = dir.join("__staged_real");
1878            let staged_link = dir.join("__staged_link");
1879            while !stop.load(std::sync::atomic::Ordering::Relaxed) {
1880                // prepare a real file (mode 0644) at a staging name, then rename it over `entry`.
1881                let _ = std::fs::remove_file(&staged_real);
1882                if std::fs::write(&staged_real, b"REAL").is_err() {
1883                    continue;
1884                }
1885                let _ =
1886                    std::fs::set_permissions(&staged_real, std::fs::Permissions::from_mode(0o644));
1887                let _ = std::fs::rename(&staged_real, &entry);
1888                // prepare a symlink-to-sentinel at the other staging name, then rename it over.
1889                let _ = std::fs::remove_file(&staged_link);
1890                let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
1891                let _ = std::fs::rename(&staged_link, &entry);
1892            }
1893        })
1894    }
1895
1896    // TOCTOU race: while rchm runs `--mode g+w` over a directory, the entry inside it is
1897    // rapidly flipped between a real file (0644) and a symlink to a SENTINEL file that lives
1898    // OUTSIDE the tree with a distinctive mode (0600). rchm classifies each entry through an
1899    // O_PATH/O_NOFOLLOW handle and chmods that exact pinned inode — so it may chmod the real
1900    // file (0644 -> 0664), or see a symlink (which it never chmods), or fail closed, but it
1901    // must NEVER follow the link and change the sentinel's mode. The sentinel staying 0600 on
1902    // every iteration is the safety assertion. Also confirms the run terminates (timeout).
1903    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1904    async fn entry_symlink_swap_never_changes_sentinel_mode() -> anyhow::Result<()> {
1905        use std::os::unix::fs::PermissionsExt;
1906        let tmp = crate::testutils::create_temp_dir().await?;
1907        let root = tmp.as_path();
1908        // sentinel lives OUTSIDE the rchm target tree, with a distinctive mode we must not touch.
1909        let sentinel = root.join("sentinel_secret");
1910        tokio::fs::write(&sentinel, b"SENTINEL").await?;
1911        std::fs::set_permissions(&sentinel, std::fs::Permissions::from_mode(0o600))?;
1912        let sentinel_uid_before =
1913            std::os::unix::fs::MetadataExt::uid(&std::fs::symlink_metadata(&sentinel)?);
1914        // the tree rchm operates on: a directory containing the entry that gets swapped.
1915        let target_dir = root.join("tree");
1916        tokio::fs::create_dir(&target_dir).await?;
1917        let entry_path = target_dir.join("entry");
1918        tokio::fs::write(&entry_path, b"REAL").await?;
1919        std::fs::set_permissions(&entry_path, std::fs::Permissions::from_mode(0o644))?;
1920
1921        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1922        let swapper =
1923            spawn_file_symlink_swapper(target_dir.clone(), "entry", sentinel.clone(), stop.clone());
1924
1925        // g+w on a file would turn the sentinel 0600 -> 0620 if the link were ever followed.
1926        let settings = settings_with("g+w", None, None);
1927        let mut caught = 0usize;
1928        let mut changed_real = 0usize;
1929        for i in 0..300 {
1930            let result = tokio::time::timeout(
1931                std::time::Duration::from_secs(30),
1932                chmod(&RACE_PROGRESS, &target_dir, &settings),
1933            )
1934            .await
1935            .expect("rchm must not hang under concurrent swapping");
1936            match result {
1937                Ok(summary) => changed_real += summary.files_changed,
1938                Err(_) => caught += 1, // a swap was caught mid-run (failed closed for that entry)
1939            }
1940            // CORE SAFETY ASSERTION (holds on every iteration regardless of timing): the
1941            // out-of-tree sentinel's mode and owner are never modified by rchm.
1942            let sentinel_md = std::fs::symlink_metadata(&sentinel)?;
1943            assert_eq!(
1944                sentinel_md.permissions().mode() & 0o7777,
1945                0o600,
1946                "iteration {i}: sentinel mode changed — rchm followed the symlink to chmod it"
1947            );
1948            assert_eq!(
1949                std::os::unix::fs::MetadataExt::uid(&sentinel_md),
1950                sentinel_uid_before,
1951                "iteration {i}: sentinel owner changed — rchm followed the symlink to chown it"
1952            );
1953        }
1954
1955        stop.store(true, std::sync::atomic::Ordering::Relaxed);
1956        swapper.join().expect("swapper thread panicked");
1957        // sanity (not the safety assertion): the run did observable work across the iterations.
1958        tracing::info!("entry swap: caught={caught}, changed_real={changed_real}");
1959        assert!(
1960            caught + changed_real > 0,
1961            "expected at least one observable outcome across the iterations"
1962        );
1963        Ok(())
1964    }
1965
1966    /// Stress tests exercising `pending_meta` (max-files-in-flight) saturation during rchm.
1967    /// Each test scopes process-wide admission changes with an `AdmissionLimit` guard.
1968    mod max_files_in_flight_tests {
1969        use super::*;
1970        use crate::walk_driver::process_entry;
1971
1972        static PROGRESS: std::sync::LazyLock<Progress> = std::sync::LazyLock::new(Progress::new);
1973
1974        /// Public root setup must reserve metadata capacity before opening the operand parent or
1975        /// performing the fd-based root filter probe. Otherwise many CLI operands can bypass the
1976        /// shared driver's later admission point concurrently.
1977        #[tokio::test]
1978        async fn filtered_root_waits_for_admission_before_setup() -> anyhow::Result<()> {
1979            let root = crate::testutils::create_temp_dir().await?;
1980            let path = root.join("victim");
1981            tokio::fs::write(&path, b"x").await?;
1982            let mut settings = settings_with("f:600", None, None);
1983            let mut filter = crate::filter::FilterSettings::new();
1984            filter.add_exclude("victim")?;
1985            settings.filter = Some(filter);
1986            let admission = crate::testutils::AdmissionLimit::new().await;
1987            admission.set_files_in_flight(1);
1988            let stat_resource =
1989                throttle::Resource::meta(throttle::Side::Destination, throttle::MetadataOp::Stat);
1990            admission.set_max_ops_in_flight(stat_resource, 1);
1991            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
1992            let operation = chmod(&PROGRESS, &path, &settings);
1993            tokio::pin!(operation);
1994            let stopped_at_stat_gate = futures::poll!(operation.as_mut()).is_pending();
1995            let mut second_permit = Box::pin(throttle::pending_meta_permit());
1996            let setup_bypassed_admission = futures::poll!(second_permit.as_mut()).is_ready();
1997            drop(second_permit);
1998            drop(held_stat);
1999            let result = admission
2000                .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
2001                .await;
2002            assert!(
2003                stopped_at_stat_gate,
2004                "chmod root did not reach the held stat gate"
2005            );
2006            let summary =
2007                result.context("chmod root did not resume after stat capacity was released")??;
2008            assert!(
2009                !setup_bypassed_admission,
2010                "chmod performed root setup before pending-metadata admission"
2011            );
2012            assert_eq!(summary.files_skipped, 1);
2013            Ok(())
2014        }
2015
2016        /// Regression for the hold-and-wait deadlock when a getdents leaf-hint entry is actually a
2017        /// directory (the DT_UNKNOWN edge, or a getdents-vs-`child()` swap). A `pending_meta` permit
2018        /// is pre-acquired for every possible leaf; if such an entry is really a directory, the
2019        /// permit must be DROPPED before recursing, or it is held while its children block acquiring
2020        /// one and a saturated pool hangs the walk.
2021        ///
2022        /// Reproduced deterministically by driving a directory entry through the driver's
2023        /// [`process_entry`] with the real [`ChmodVisitor`] and the one-and-only permit pre-acquired
2024        /// (as the spawn loop does for a hinted leaf): with the bug the held permit strands the
2025        /// child's pre-acquire and the timeout fires; with the driver's single drop-before-recurse
2026        /// site the child acquires it and the walk completes. (Runs the real `ChmodVisitor` through
2027        /// the driver — the chmod-side coverage the old direct `chmod_entry` call gave, now that
2028        /// `chmod_entry` no longer exists after the Phase E conversion.)
2029        #[tokio::test]
2030        async fn hinted_leaf_that_is_dir_drops_permit_before_recursion() -> anyhow::Result<()> {
2031            let root = crate::testutils::create_temp_dir().await?;
2032            // `d` is a directory (the authoritative type) holding one child file `c`.
2033            let dir_path = root.join("d");
2034            tokio::fs::create_dir(&dir_path).await?;
2035            let child_path = dir_path.join("c");
2036            tokio::fs::write(&child_path, b"x").await?;
2037            std::fs::set_permissions(&child_path, std::fs::Permissions::from_mode(0o644))?;
2038            // size the pending-meta pool to a single permit so a held-across-recursion permit
2039            // strands the child's pre-acquire — the saturation the fd-walk must tolerate.
2040            let admission = crate::testutils::AdmissionLimit::new().await;
2041            admission.set_files_in_flight(1);
2042            // open the container of `d` and classify `d` itself: an authoritative directory handle.
2043            let parent = Dir::open_parent_dir(&root, congestion::Side::Destination)
2044                .await
2045                .context("open parent dir")?;
2046            let parent = Arc::new(parent.into_tree());
2047            let name = std::ffi::OsStr::new("d");
2048            let handle = parent.child(name).await.context("classify d")?;
2049            assert_eq!(
2050                handle.kind(),
2051                EntryKind::Dir,
2052                "fixture `d` must be a directory"
2053            );
2054            drop(handle);
2055            // build the visitor + root context for `d`, pre-acquire the single permit as the spawn
2056            // loop does for a hinted leaf, and hand it to `process_entry` (the fix drops it before
2057            // recursing).
2058            let visitor = Arc::new(ChmodVisitor {
2059                prog_track: &PROGRESS,
2060                settings: settings_with("g+w", None, None),
2061            });
2062            let cx = EntryCx {
2063                parent: Arc::clone(&parent),
2064                name: name.to_owned(),
2065                rel_path: PathBuf::new(),
2066                filter_path: PathBuf::new(),
2067                real_path: dir_path.clone(),
2068                dry_run: false,
2069                prog_track: &PROGRESS,
2070            };
2071            let permit =
2072                crate::walk::preacquire_leaf_permit(PermitKind::PendingMeta, Some(EntryKind::File))
2073                    .await;
2074            assert!(permit.is_some(), "the pre-acquire must take the one permit");
2075            let result = admission
2076                .run_with_timeout(
2077                    std::time::Duration::from_secs(20),
2078                    process_entry(visitor, cx, (), permit),
2079                )
2080                .await;
2081            let summary = result
2082                .context(
2083                    "process_entry hung — leaf permit held across directory recursion (deadlock)",
2084                )?
2085                .map_err(|e| e.source)
2086                .context("process_entry failed")?;
2087            // both the directory and its child get g+w (0644 -> 0664 on the file).
2088            assert_eq!(
2089                summary.files_changed, 1,
2090                "child file should have its mode changed"
2091            );
2092            assert_eq!(
2093                summary.directories_changed, 1,
2094                "directory should have its mode changed"
2095            );
2096            assert_eq!(
2097                std::fs::symlink_metadata(&child_path)?.permissions().mode() & 0o777,
2098                0o664,
2099                "child file mode should be g+w applied"
2100            );
2101            Ok(())
2102        }
2103    }
2104}