1use 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
22fn mode_of(meta: &FileMeta) -> u32 {
27 meta.permissions().mode() & 0o7777
28}
29
30pub type Error = crate::error::OperationError<Summary>;
32
33#[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#[derive(Clone, Debug, PartialEq, Eq)]
60pub enum ModeSpec {
61 Symbolic(Vec<SymbolicClause>),
62 Octal(u32),
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub struct SymbolicClause {
69 pub who: u8,
70 pub op: ModeOp,
71 pub perms: u8,
72}
73
74#[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#[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#[derive(Clone, Debug)]
118pub struct Settings {
119 pub mode: ModeProgram,
120 pub owner: OwnerProgram,
121 pub group: OwnerProgram,
122 pub no_setid: bool,
124 pub fail_early: bool,
125 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
192pub enum IdKind {
193 User,
194 Group,
195}
196
197impl IdKind {
198 fn getent_database(self) -> &'static str {
200 match self {
201 IdKind::User => "passwd",
202 IdKind::Group => "group",
203 }
204 }
205 fn label(self) -> &'static str {
207 match self {
208 IdKind::User => "user",
209 IdKind::Group => "group",
210 }
211 }
212}
213
214fn 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 Ok(None) | Err(_) => resolve_via_getent(token, kind, getent),
237 }
238}
239
240const GETENT_NOT_FOUND: i32 = 2;
244
245fn 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
260fn 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 let prog = getent_program.to_string_lossy();
274 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
313const TRUSTED_GETENT_DIRS: &[&str] = &["/usr/bin", "/bin", "/run/current-system/sw/bin"];
319
320#[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#[derive(Clone, Debug)]
339pub struct GetentResolver {
340 explicit: Option<PathBuf>,
342 privileged: bool,
344}
345
346impl Default for GetentResolver {
349 fn default() -> Self {
350 Self {
351 explicit: None,
352 privileged: false,
353 }
354 }
355}
356
357impl GetentResolver {
358 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 fn program(&self) -> anyhow::Result<Option<PathBuf>> {
379 self.program_in(TRUSTED_GETENT_DIRS)
380 }
381
382 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 return Ok(None);
391 }
392 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
409fn 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
421pub 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#[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 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
543fn 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
611pub 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#[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
663pub(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 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 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 Some(cur_mode & !SETID_BITS)
708 } else if need_chown && cur_mode & SETID_BITS != 0 {
709 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
790fn 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 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
812async 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
855async 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 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#[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 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 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 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 let parent = Arc::new(parent.into_tree());
978 if let Some(ref filter) = settings.filter {
979 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
1013async 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 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 }
1041
1042struct ChmodVisitor {
1048 prog_track: &'static Progress,
1049 settings: Settings,
1050}
1051
1052struct ChmodDirState {
1055 handle: Handle,
1058 traversed_only: bool,
1061 base: Summary,
1063 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1280async 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()); assert!(parse_mode_token("77777").is_err()); }
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 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 assert_eq!(
1457 parse_getent_id("alice:x:1234:100:Alice:/home/alice:/bin/sh").unwrap(),
1458 1234
1459 );
1460 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 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 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 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 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 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 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 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 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 let tmp = tempfile::tempdir().unwrap(); 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 assert_eq!(apply_mode(0o755, &sym("o="), false), 0o750);
1644 assert_eq!(apply_mode(0o000, &sym("u=rwx,go=rx"), false), 0o755);
1646 assert_eq!(apply_mode(0o1755, &sym("o="), false), 0o0750);
1648 }
1649 #[test]
1650 fn apply_mode_conditional_bigx() {
1651 assert_eq!(apply_mode(0o644, &sym("a+X"), false), 0o644);
1653 assert_eq!(apply_mode(0o744, &sym("a+X"), false), 0o755);
1655 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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, }
1940 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 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 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 #[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 #[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 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 let admission = crate::testutils::AdmissionLimit::new().await;
2041 admission.set_files_in_flight(1);
2042 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 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 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}