1use crate::filter::TimeFilter;
6use crate::preserve::Metadata as _;
7use crate::progress::Progress;
8use crate::safedir::{self, Dir, FileMeta, Handle};
9use crate::walk::{EntryKind, LeafPermit, PermitKind};
10use crate::walk_driver::{
11 DirAction, DirPreResult, EntryCx, ProcessedChildren, WalkVisitor, process_entry,
12};
13use anyhow::{Context, anyhow};
14use std::ffi::OsStr;
15use std::os::unix::fs::PermissionsExt;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use tracing::instrument;
19
20const SETID_BITS: u32 = 0o6000;
21
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<()> {
836 if let Some(mode) = plan.chmod_before_chown {
837 safedir::chmod_via_proc_fd(handle, congestion::Side::Destination, mode)
838 .await
839 .with_context(|| {
840 format!("failed to clear set-ID bits via fd before chown (mode {mode:04o})")
841 })?;
842 }
843 if let Some((uid, gid)) = plan.chown {
844 safedir::fchown_handle(handle, congestion::Side::Destination, uid, gid)
845 .await
846 .with_context(|| format!("failed to chown via fd (uid={uid:?}, gid={gid:?})"))?;
847 }
848 if let Some(mode) = plan.chmod {
849 safedir::chmod_via_proc_fd(handle, congestion::Side::Destination, mode)
850 .await
851 .with_context(|| format!("failed to chmod via fd to {mode:04o}"))?;
852 }
853 Ok(())
854}
855
856async fn apply_entry_change(
864 prog: &'static Progress,
865 path: &std::path::Path,
866 handle: &Handle,
867 kind: EntryKind,
868 settings: &Settings,
869) -> Result<Summary, Error> {
870 if let Some(ref time_filter) = settings.time_filter {
871 let metadata =
874 match safedir::stat_meta_via_proc_fd(handle, congestion::Side::Destination).await {
875 Ok(md) => md,
876 Err(err) => {
877 let err = anyhow::Error::new(err).context(format!(
878 "failed reading metadata for time filter on {path:?}"
879 ));
880 if settings.fail_early {
881 return Err(Error::new(err, Default::default()));
882 }
883 tracing::warn!("time filter failed for {:?}, skipping: {:#}", path, &err);
884 kind.inc_skipped(prog);
885 return Ok(skipped_summary_for(kind));
886 }
887 };
888 match time_filter.matches(&metadata) {
889 Ok(result) => {
890 if let Some(reason) = result.as_skip_reason() {
891 if let Some(mode) = settings.dry_run {
892 crate::dry_run::report_time_skip(path, reason, mode, kind.label());
893 }
894 kind.inc_skipped(prog);
895 return Ok(skipped_summary_for(kind));
896 }
897 }
898 Err(err) => {
899 let err = err.context(format!("failed evaluating time filter on {path:?}"));
900 if settings.fail_early {
901 return Err(Error::new(err, Default::default()));
902 }
903 tracing::warn!("time filter failed for {:?}, skipping: {:#}", path, &err);
904 kind.inc_skipped(prog);
905 return Ok(skipped_summary_for(kind));
906 }
907 }
908 }
909 let meta = handle.meta();
910 let cur_mode = mode_of(meta);
911 let plan = compute_plan(cur_mode, meta.uid(), meta.gid(), kind, settings);
912 if plan.is_noop() {
913 if let Some(crate::config::DryRunMode::All) = settings.dry_run {
914 println!("unchanged {} {:?}", kind.label(), path);
915 }
916 return Ok(inc_unchanged(prog, kind));
917 }
918 if settings.dry_run.is_some() {
919 let desc = describe_change(cur_mode, meta.uid(), meta.gid(), &plan);
920 println!("would modify {} {:?}: {}", kind.label(), path, desc);
921 return Ok(inc_changed(prog, kind));
922 }
923 apply_plan(handle, &plan)
924 .await
925 .map_err(|err| Error::new(err, Default::default()))?;
926 Ok(inc_changed(prog, kind))
927}
928
929#[instrument(skip(prog_track, settings))]
939pub async fn chmod(
940 prog_track: &'static Progress,
941 path: &std::path::Path,
942 settings: &Settings,
943) -> Result<Summary, Error> {
944 let operand = crate::walk::split_root_operand(path)
950 .await
951 .map_err(|err| Error::new(err, Default::default()))?;
952 let parent_path = operand.parent.as_path();
953 let name = operand.name.as_os_str();
954 let path = operand.display.as_path();
955 let parent = Dir::open_parent_dir(parent_path, congestion::Side::Destination)
961 .await
962 .with_context(|| format!("cannot open parent directory {parent_path:?}"))
963 .map_err(|err| Error::new(err, Default::default()))?;
964 let parent = Arc::new(parent.into_tree());
966 if let Some(ref filter) = settings.filter {
967 let root_handle = parent
970 .child(name)
971 .await
972 .with_context(|| format!("failed reading metadata from {path:?}"))
973 .map_err(|err| Error::new(err, Default::default()))?;
974 let name_path = std::path::Path::new(name);
975 match filter.should_include_root_item(name_path, root_handle.kind() == EntryKind::Dir) {
976 crate::filter::FilterResult::Included => {}
977 result => {
978 let kind = root_handle.kind();
979 if let Some(mode) = settings.dry_run {
980 crate::dry_run::report_skip(path, &result, mode, kind.label_long());
981 }
982 kind.inc_skipped(prog_track);
983 return Ok(skipped_summary_for(kind));
984 }
985 }
986 }
987 run_chmod_root(prog_track, &parent, name, path, settings).await
988}
989
990async fn run_chmod_root(
994 prog_track: &'static Progress,
995 parent: &Arc<Dir>,
996 name: &OsStr,
997 root: &std::path::Path,
998 settings: &Settings,
999) -> Result<Summary, Error> {
1000 let visitor = Arc::new(ChmodVisitor {
1001 prog_track,
1002 settings: settings.clone(),
1003 });
1004 let root_cx = EntryCx {
1007 parent: Arc::clone(parent),
1008 name: name.to_owned(),
1009 rel_path: PathBuf::new(),
1010 filter_path: PathBuf::new(),
1011 real_path: root.to_path_buf(),
1012 dry_run: settings.dry_run.is_some(),
1013 prog_track,
1014 };
1015 process_entry(visitor, root_cx, (), None).await }
1017
1018struct ChmodVisitor {
1024 prog_track: &'static Progress,
1025 settings: Settings,
1026}
1027
1028struct ChmodDirState {
1031 handle: Handle,
1034 traversed_only: bool,
1037 base: Summary,
1039 pre_order_error: Option<anyhow::Error>,
1043}
1044
1045impl WalkVisitor for ChmodVisitor {
1046 type Summary = Summary;
1047 type DirContext = ();
1048 type DirState = ChmodDirState;
1049
1050 fn root_dir_context(&self) {}
1051
1052 fn permit_kind(&self) -> PermitKind {
1053 PermitKind::PendingMeta
1055 }
1056
1057 fn want_permit(&self, hint: Option<EntryKind>) -> bool {
1058 hint.is_some_and(|k| k != EntryKind::Dir)
1061 }
1062
1063 fn fail_early(&self) -> bool {
1064 self.settings.fail_early
1065 }
1066
1067 fn filter(&self) -> Option<&crate::filter::FilterSettings> {
1068 self.settings.filter.as_ref()
1069 }
1070
1071 fn on_skip(
1072 &self,
1073 cx: &EntryCx,
1074 kind: EntryKind,
1075 skip_result: &crate::filter::FilterResult,
1076 ) -> Summary {
1077 if let Some(mode) = self.settings.dry_run {
1080 crate::dry_run::report_skip(&cx.real_path, skip_result, mode, kind.label());
1081 }
1082 skipped_summary_for(kind)
1083 }
1084
1085 async fn visit_leaf(
1086 &self,
1087 cx: &EntryCx,
1088 _parent_ctx: &(),
1089 handle: Handle,
1090 kind: EntryKind,
1091 permit: Option<LeafPermit>,
1092 ) -> Result<Summary, Error> {
1093 let _permit = permit;
1097 apply_entry_change(
1098 self.prog_track,
1099 &cx.real_path,
1100 &handle,
1101 kind,
1102 &self.settings,
1103 )
1104 .await
1105 }
1106
1107 async fn dir_pre(&self, cx: &EntryCx, _parent_ctx: &(), handle: &Handle) -> DirPreResult<Self> {
1108 let path = &cx.real_path;
1109 let traversed_only = self.settings.filter.as_ref().is_some_and(|f| {
1113 f.has_includes() && !f.directly_matches_include(&cx.filter_path, true)
1114 });
1115 let mut base = Summary::default();
1116 let mut pre_order_error: Option<anyhow::Error> = None;
1117 if !self.settings.defer_dir_changes {
1122 match apply_dir_self(
1123 self.prog_track,
1124 path,
1125 handle,
1126 traversed_only,
1127 &self.settings,
1128 )
1129 .await
1130 {
1131 Ok(dir_summary) => base = base + dir_summary,
1132 Err(error) if self.settings.fail_early => {
1135 return Err(Error::new(error.source, base + error.summary));
1136 }
1137 Err(error) => {
1138 tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
1139 base = base + error.summary;
1140 pre_order_error = Some(error.source);
1141 }
1142 }
1143 }
1144 let dir_handle = handle
1148 .try_clone()
1149 .with_context(|| format!("cannot duplicate directory handle for {path:?}"))
1150 .map_err(|err| Error::new(err, base))?;
1151 match cx.parent.open_dir(&cx.name).await {
1155 Ok(dir) => Ok(DirAction::Descend {
1156 dir: Arc::new(dir),
1157 child_ctx: (),
1158 state: ChmodDirState {
1159 handle: dir_handle,
1160 traversed_only,
1161 base,
1162 pre_order_error,
1163 },
1164 }),
1165 Err(error) => {
1166 let error = anyhow::Error::new(error)
1167 .context(format!("cannot open directory {path:?} for reading"));
1168 if self.settings.fail_early {
1169 return Err(Error::new(error, base));
1170 }
1171 let errors = crate::error_collector::ErrorCollector::default();
1175 if let Some(pre_order_error) = pre_order_error {
1176 errors.push(pre_order_error);
1177 }
1178 tracing::error!("chmod: {:#}", &error);
1179 errors.push(error);
1180 if self.settings.defer_dir_changes {
1181 match apply_dir_self(
1182 self.prog_track,
1183 path,
1184 handle,
1185 traversed_only,
1186 &self.settings,
1187 )
1188 .await
1189 {
1190 Ok(dir_summary) => base = base + dir_summary,
1191 Err(error) => {
1192 tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
1193 base = base + error.summary;
1194 errors.push(error.source);
1195 }
1196 }
1197 }
1198 Err(Error::new(errors.into_error().unwrap(), base))
1200 }
1201 }
1202 }
1203
1204 async fn dir_post(
1205 &self,
1206 cx: &EntryCx,
1207 state: ChmodDirState,
1208 _processed: &ProcessedChildren,
1209 child_result: Result<Summary, Error>,
1210 ) -> Result<Summary, Error> {
1211 let ChmodDirState {
1212 handle,
1213 traversed_only,
1214 base,
1215 pre_order_error,
1216 } = state;
1217 let path = &cx.real_path;
1218 let (child_summary, child_error) = match child_result {
1221 Ok(summary) => (summary, None),
1222 Err(err) => (err.summary, Some(err.source)),
1223 };
1224 let mut summary = base + child_summary;
1225 let errors = crate::error_collector::ErrorCollector::default();
1227 if let Some(pre_order_error) = pre_order_error {
1228 errors.push(pre_order_error);
1229 }
1230 if let Some(child_error) = child_error {
1231 errors.push(child_error);
1232 }
1233 if self.settings.defer_dir_changes {
1239 match apply_dir_self(
1240 self.prog_track,
1241 path,
1242 &handle,
1243 traversed_only,
1244 &self.settings,
1245 )
1246 .await
1247 {
1248 Ok(dir_summary) => summary = summary + dir_summary,
1249 Err(error) => {
1250 if self.settings.fail_early {
1251 return Err(Error::new(error.source, summary + error.summary));
1252 }
1253 tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
1254 summary = summary + error.summary;
1255 errors.push(error.source);
1256 }
1257 }
1258 }
1259 if let Some(error) = errors.into_error() {
1260 return Err(Error::new(error, summary));
1261 }
1262 Ok(summary)
1263 }
1264}
1265
1266async fn apply_dir_self(
1274 prog_track: &'static Progress,
1275 path: &std::path::Path,
1276 handle: &Handle,
1277 traversed_only: bool,
1278 settings: &Settings,
1279) -> Result<Summary, Error> {
1280 if traversed_only {
1281 if let Some(crate::config::DryRunMode::All) = settings.dry_run {
1282 println!("skip dir {path:?} (only traversed for include matches)");
1283 }
1284 prog_track.directories_skipped.inc();
1285 return Ok(skipped_summary_for(EntryKind::Dir));
1286 }
1287 apply_entry_change(prog_track, path, handle, EntryKind::Dir, settings).await
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292 use super::*;
1293 #[test]
1294 fn mode_token_octal() {
1295 assert_eq!(parse_mode_token("2775").unwrap(), ModeSpec::Octal(0o2775));
1296 assert_eq!(parse_mode_token("0644").unwrap(), ModeSpec::Octal(0o644));
1297 }
1298 #[test]
1299 fn mode_token_octal_out_of_range_errors() {
1300 assert!(parse_mode_token("9999").is_err()); assert!(parse_mode_token("77777").is_err()); }
1303 #[test]
1304 fn mode_token_symbolic_simple() {
1305 let spec = parse_mode_token("g+w").unwrap();
1306 assert_eq!(
1307 spec,
1308 ModeSpec::Symbolic(vec![SymbolicClause {
1309 who: WHO_G,
1310 op: ModeOp::Add,
1311 perms: PERM_W
1312 }])
1313 );
1314 }
1315 #[test]
1316 fn mode_token_symbolic_omitted_who_means_all() {
1317 let spec = parse_mode_token("+x").unwrap();
1318 assert_eq!(
1319 spec,
1320 ModeSpec::Symbolic(vec![SymbolicClause {
1321 who: WHO_A,
1322 op: ModeOp::Add,
1323 perms: PERM_X
1324 }])
1325 );
1326 }
1327 #[test]
1328 fn mode_token_symbolic_comma_chained() {
1329 let spec = parse_mode_token("u+rw,g-w").unwrap();
1330 let ModeSpec::Symbolic(clauses) = spec else {
1331 panic!("expected symbolic")
1332 };
1333 assert_eq!(clauses.len(), 2);
1334 assert_eq!(
1335 clauses[0],
1336 SymbolicClause {
1337 who: WHO_U,
1338 op: ModeOp::Add,
1339 perms: PERM_R | PERM_W
1340 }
1341 );
1342 assert_eq!(
1343 clauses[1],
1344 SymbolicClause {
1345 who: WHO_G,
1346 op: ModeOp::Remove,
1347 perms: PERM_W
1348 }
1349 );
1350 }
1351 #[test]
1352 fn mode_token_symbolic_bigx_and_specials() {
1353 let spec = parse_mode_token("g+rwXs").unwrap();
1354 assert_eq!(
1355 spec,
1356 ModeSpec::Symbolic(vec![SymbolicClause {
1357 who: WHO_G,
1358 op: ModeOp::Add,
1359 perms: PERM_R | PERM_W | PERM_BIGX | PERM_S,
1360 }])
1361 );
1362 }
1363 #[test]
1364 fn mode_token_rejects_garbage() {
1365 assert!(parse_mode_token("q+z").is_err());
1366 assert!(parse_mode_token("g!w").is_err());
1367 assert!(parse_mode_token("").is_err());
1368 }
1369 #[test]
1370 fn summary_add_combines_fields() {
1371 let a = Summary {
1372 files_changed: 1,
1373 directories_changed: 2,
1374 files_unchanged: 3,
1375 ..Default::default()
1376 };
1377 let b = Summary {
1378 files_changed: 10,
1379 symlinks_skipped: 4,
1380 ..Default::default()
1381 };
1382 let sum = a + b;
1383 assert_eq!(sum.files_changed, 11);
1384 assert_eq!(sum.directories_changed, 2);
1385 assert_eq!(sum.files_unchanged, 3);
1386 assert_eq!(sum.symlinks_skipped, 4);
1387 }
1388 #[test]
1389 fn owner_dsl_bare_applies_to_all_types() {
1390 let prog = parse_owner_dsl("0", IdKind::User, &GetentResolver::default()).unwrap();
1391 assert_eq!(prog.file, Some(0));
1392 assert_eq!(prog.dir, Some(0));
1393 assert_eq!(prog.symlink, Some(0));
1394 }
1395 #[test]
1396 fn owner_dsl_per_type_overrides() {
1397 let prog = parse_owner_dsl("f:1 d:2", IdKind::User, &GetentResolver::default()).unwrap();
1398 assert_eq!(prog.file, Some(1));
1399 assert_eq!(prog.dir, Some(2));
1400 assert_eq!(prog.symlink, None);
1401 }
1402 #[test]
1403 fn owner_dsl_bare_plus_override() {
1404 let prog = parse_owner_dsl("5 d:2", IdKind::Group, &GetentResolver::default()).unwrap();
1405 assert_eq!(prog.file, Some(5));
1406 assert_eq!(prog.dir, Some(2));
1407 assert_eq!(prog.symlink, Some(5));
1408 }
1409 #[test]
1410 fn owner_dsl_explicit_before_bare_is_order_independent() {
1411 let prog = parse_owner_dsl("f:1 5", IdKind::User, &GetentResolver::default()).unwrap();
1412 assert_eq!(prog.file, Some(1));
1413 assert_eq!(prog.dir, Some(5));
1414 assert_eq!(prog.symlink, Some(5));
1415 }
1416 #[test]
1417 fn owner_dsl_rejects_multiple_bare() {
1418 assert!(parse_owner_dsl("1 2", IdKind::User, &GetentResolver::default()).is_err());
1419 }
1420 #[test]
1421 fn owner_dsl_rejects_unknown_id() {
1422 assert!(
1423 parse_owner_dsl(
1424 "definitely-no-such-group-xyz",
1425 IdKind::Group,
1426 &GetentResolver::default()
1427 )
1428 .is_err()
1429 );
1430 }
1431 #[test]
1432 fn owner_dsl_resolves_root_name() {
1433 let prog = parse_owner_dsl("root", IdKind::User, &GetentResolver::default()).unwrap();
1435 assert_eq!(prog.file, Some(0));
1436 assert_eq!(prog.dir, Some(0));
1437 assert_eq!(prog.symlink, Some(0));
1438 }
1439 #[test]
1440 fn getent_id_parses_passwd_and_group_lines() {
1441 assert_eq!(
1443 parse_getent_id("alice:x:1234:100:Alice:/home/alice:/bin/sh").unwrap(),
1444 1234
1445 );
1446 assert_eq!(parse_getent_id("data:*:5678:alice,bob").unwrap(), 5678);
1448 }
1449 #[test]
1450 fn getent_id_rejects_malformed_lines() {
1451 assert!(parse_getent_id("").is_err());
1452 assert!(parse_getent_id("alice").is_err());
1453 assert!(parse_getent_id("alice:x").is_err());
1454 assert!(parse_getent_id("alice:x:notanumber:100").is_err());
1455 }
1456 fn write_stub_getent(dir: &std::path::Path, name: &str, body: &str) -> std::path::PathBuf {
1458 use std::os::unix::fs::PermissionsExt;
1459 let path = dir.join(name);
1460 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
1461 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1462 path
1463 }
1464 #[test]
1465 fn getent_stub_resolves_directory_service_names() {
1466 let tmp = tempfile::tempdir().unwrap();
1467 let user_stub = write_stub_getent(
1471 tmp.path(),
1472 "getent-user",
1473 "[ \"$1\" = -- ] && [ \"$2\" = passwd ] || exit 99\necho 'ldapuser:*:4242:4242:LDAP User:/home/ldapuser:/bin/sh'",
1474 );
1475 let group_stub = write_stub_getent(
1476 tmp.path(),
1477 "getent-group",
1478 "[ \"$1\" = -- ] && [ \"$2\" = group ] || exit 99\necho 'ldapgroup:*:4343:ldapuser'",
1479 );
1480 assert_eq!(
1481 resolve_via_getent_cmd(user_stub.as_os_str(), "ldapuser", IdKind::User).unwrap(),
1482 4242
1483 );
1484 assert_eq!(
1485 resolve_via_getent_cmd(group_stub.as_os_str(), "ldapgroup", IdKind::Group).unwrap(),
1486 4343
1487 );
1488 }
1489 #[test]
1490 fn getent_stub_not_found_is_unknown() {
1491 let tmp = tempfile::tempdir().unwrap();
1492 let stub = write_stub_getent(tmp.path(), "getent-miss", "exit 2");
1493 let err = resolve_via_getent_cmd(stub.as_os_str(), "nosuch", IdKind::Group).unwrap_err();
1494 assert!(
1495 format!("{err:#}").contains("unknown group: nosuch"),
1496 "got: {err:#}"
1497 );
1498 }
1499 #[test]
1500 fn getent_missing_program_suggests_numeric_id() {
1501 let tmp = tempfile::tempdir().unwrap();
1502 let missing = tmp.path().join("no-such-getent");
1503 let err = resolve_via_getent_cmd(missing.as_os_str(), "alice", IdKind::User).unwrap_err();
1504 assert!(
1505 format!("{err:#}").contains("use a numeric id instead"),
1506 "got: {err:#}"
1507 );
1508 }
1509 #[test]
1510 fn getent_stub_garbled_output_errors() {
1511 let tmp = tempfile::tempdir().unwrap();
1512 let stub = write_stub_getent(tmp.path(), "getent-garbled", "echo 'not a passwd line'");
1513 let err = resolve_via_getent_cmd(stub.as_os_str(), "alice", IdKind::User).unwrap_err();
1514 assert!(
1515 format!("{err:#}").contains("unexpected getent output"),
1516 "got: {err:#}"
1517 );
1518 }
1519 #[test]
1520 fn getent_stub_exit0_no_output_errors() {
1521 let tmp = tempfile::tempdir().unwrap();
1522 let stub = write_stub_getent(tmp.path(), "getent-empty", "exit 0");
1523 let err = resolve_via_getent_cmd(stub.as_os_str(), "alice", IdKind::User).unwrap_err();
1524 assert!(
1525 format!("{err:#}").contains("produced no output"),
1526 "got: {err:#}"
1527 );
1528 }
1529 #[test]
1530 fn getent_real_resolves_root() {
1531 assert_eq!(
1534 resolve_via_getent("root", IdKind::User, &GetentResolver::default()).unwrap(),
1535 0
1536 );
1537 assert_eq!(
1538 resolve_via_getent("root", IdKind::Group, &GetentResolver::default()).unwrap(),
1539 0
1540 );
1541 }
1542 #[test]
1543 fn getent_real_option_like_name_fails_closed_no_injection() {
1544 let err = resolve_via_getent("--service=files", IdKind::Group, &GetentResolver::default())
1550 .unwrap_err();
1551 let msg = format!("{err:#}");
1552 assert!(
1553 msg.contains("unknown group") || msg.contains("produced no output"),
1554 "option-like name must fail closed (not resolve to an id), got: {msg}"
1555 );
1556 }
1557 #[test]
1558 fn getent_source_rejects_relative_explicit_path() {
1559 let err = GetentResolver::from_cli(Some(PathBuf::from("getent")), false).unwrap_err();
1561 assert!(
1562 format!("{err:#}").contains("must be an absolute path"),
1563 "got: {err:#}"
1564 );
1565 }
1566 #[test]
1567 fn getent_source_explicit_absolute_used_even_when_privileged() {
1568 let resolver =
1570 GetentResolver::from_cli(Some(PathBuf::from("/opt/nss/getent")), true).unwrap();
1571 assert_eq!(
1572 resolver.program_in(&["/usr/bin"]).unwrap(),
1573 Some(PathBuf::from("/opt/nss/getent"))
1574 );
1575 }
1576 #[test]
1577 fn getent_source_unprivileged_searches_path() {
1578 let resolver = GetentResolver::from_cli(None, false).unwrap();
1580 assert_eq!(resolver.program_in(&["/nonexistent"]).unwrap(), None);
1581 }
1582 #[test]
1583 fn getent_source_privileged_probes_trusted_dirs_not_path() {
1584 let tmp = tempfile::tempdir().unwrap();
1586 let bin = tmp.path().join("getent");
1587 std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1588 let resolver = GetentResolver::from_cli(None, true).unwrap();
1589 let dirs = [tmp.path().to_str().unwrap()];
1590 assert_eq!(resolver.program_in(&dirs).unwrap(), Some(bin));
1591 }
1592 #[test]
1593 fn getent_source_privileged_missing_getent_errors_not_path_fallback() {
1594 let tmp = tempfile::tempdir().unwrap(); let resolver = GetentResolver::from_cli(None, true).unwrap();
1598 let dir = tmp.path().to_str().unwrap();
1599 let err = resolver.program_in(&[dir]).unwrap_err();
1600 let msg = format!("{err:#}");
1601 assert!(
1602 msg.contains("PATH is intentionally ignored"),
1603 "should explain PATH is not consulted, got: {msg}"
1604 );
1605 assert!(
1606 msg.contains(dir),
1607 "should name the searched dir, got: {msg}"
1608 );
1609 }
1610 fn sym(s: &str) -> ModeSpec {
1611 parse_mode_token(s).unwrap()
1612 }
1613 #[test]
1614 fn apply_mode_group_add_remove() {
1615 assert_eq!(apply_mode(0o644, &sym("g+w"), false), 0o664);
1616 assert_eq!(apply_mode(0o664, &sym("g-w"), false), 0o644);
1617 }
1618 #[test]
1619 fn apply_mode_set_clears_other_bits() {
1620 assert_eq!(apply_mode(0o755, &sym("o="), false), 0o750);
1622 assert_eq!(apply_mode(0o000, &sym("u=rwx,go=rx"), false), 0o755);
1624 assert_eq!(apply_mode(0o1755, &sym("o="), false), 0o0750);
1626 }
1627 #[test]
1628 fn apply_mode_conditional_bigx() {
1629 assert_eq!(apply_mode(0o644, &sym("a+X"), false), 0o644);
1631 assert_eq!(apply_mode(0o744, &sym("a+X"), false), 0o755);
1633 assert_eq!(apply_mode(0o644, &sym("a+X"), true), 0o755);
1635 }
1636 #[test]
1637 fn apply_mode_setgid_and_sticky() {
1638 assert_eq!(apply_mode(0o750, &sym("g+rwxs"), true), 0o2770);
1639 assert_eq!(apply_mode(0o755, &sym("+t"), true), 0o1755);
1640 assert_eq!(apply_mode(0o755, &sym("u+s"), false), 0o4755);
1641 }
1642 #[test]
1643 fn apply_mode_sticky_only_responds_to_other() {
1644 assert_eq!(apply_mode(0o755, &sym("u+t"), false), 0o755);
1646 assert_eq!(apply_mode(0o755, &sym("g+t"), false), 0o755);
1647 assert_eq!(apply_mode(0o755, &sym("ug+t"), false), 0o755);
1648 assert_eq!(apply_mode(0o755, &sym("o+t"), false), 0o1755);
1649 assert_eq!(apply_mode(0o755, &sym("+t"), false), 0o1755);
1650 assert_eq!(apply_mode(0o1755, &sym("u-t"), false), 0o1755);
1651 assert_eq!(apply_mode(0o1755, &sym("o-t"), false), 0o755);
1652 }
1653 #[test]
1654 fn apply_mode_octal_is_absolute() {
1655 assert_eq!(apply_mode(0o4755, &sym("644"), false), 0o644);
1656 assert_eq!(apply_mode(0o000, &sym("2775"), true), 0o2775);
1657 }
1658 #[test]
1659 fn mode_dsl_bare_applies_to_file_and_dir_not_symlink() {
1660 let prog = parse_mode_dsl("g+rwX").unwrap();
1661 assert!(prog.file.is_some());
1662 assert!(prog.dir.is_some());
1663 assert!(prog.for_kind(EntryKind::Symlink).is_none());
1665 }
1666 #[test]
1667 fn mode_dsl_per_type() {
1668 let prog = parse_mode_dsl("f:g+rw d:g+rwxs").unwrap();
1669 assert_eq!(prog.file, Some(sym("g+rw")));
1670 assert_eq!(prog.dir, Some(sym("g+rwxs")));
1671 }
1672 #[test]
1673 fn mode_dsl_bare_plus_override() {
1674 let prog = parse_mode_dsl("g+r d:g+rwx").unwrap();
1675 assert_eq!(prog.file, Some(sym("g+r")));
1676 assert_eq!(prog.dir, Some(sym("g+rwx")));
1677 }
1678 #[test]
1679 fn mode_dsl_rejects_symlink_section() {
1680 assert!(parse_mode_dsl("l:g+w").is_err());
1681 }
1682 #[test]
1683 fn mode_dsl_rejects_multiple_bare() {
1684 assert!(parse_mode_dsl("g+r o+w").is_err());
1685 }
1686 #[test]
1687 fn mode_dsl_rejects_unknown_prefix() {
1688 assert!(parse_mode_dsl("z:644").is_err());
1689 }
1690 #[test]
1691 fn mode_dsl_single_type_leaves_other_none() {
1692 let prog_f = parse_mode_dsl("f:644").unwrap();
1693 assert!(prog_f.file.is_some());
1694 assert!(prog_f.dir.is_none());
1695 let prog_d = parse_mode_dsl("d:755").unwrap();
1696 assert!(prog_d.dir.is_some());
1697 assert!(prog_d.file.is_none());
1698 }
1699 fn settings_with(mode: &str, owner: Option<&str>, group: Option<&str>) -> Settings {
1700 Settings {
1701 mode: if mode.is_empty() {
1702 ModeProgram::default()
1703 } else {
1704 parse_mode_dsl(mode).unwrap()
1705 },
1706 owner: owner
1707 .map(|s| parse_owner_dsl(s, IdKind::User, &GetentResolver::default()).unwrap())
1708 .unwrap_or_default(),
1709 group: group
1710 .map(|s| parse_owner_dsl(s, IdKind::Group, &GetentResolver::default()).unwrap())
1711 .unwrap_or_default(),
1712 no_setid: false,
1713 fail_early: false,
1714 defer_dir_changes: false,
1715 filter: None,
1716 time_filter: None,
1717 dry_run: None,
1718 }
1719 }
1720 fn settings_with_no_setid(mode: &str, owner: Option<&str>, group: Option<&str>) -> Settings {
1721 let mut settings = settings_with(mode, owner, group);
1722 settings.no_setid = true;
1723 settings
1724 }
1725 #[test]
1726 fn plan_noop_when_already_correct() {
1727 let s = settings_with("g+r", None, None);
1728 let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1730 assert!(plan.is_noop());
1731 }
1732 #[test]
1733 fn plan_chmod_when_mode_differs() {
1734 let s = settings_with("g+w", None, None);
1735 let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1736 assert_eq!(plan.chmod, Some(0o664));
1737 assert!(plan.chown.is_none());
1738 }
1739 #[test]
1740 fn plan_chown_only_changed_ids() {
1741 let s = settings_with("", None, Some("2000"));
1742 let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1743 assert_eq!(plan.chown, Some((None, Some(2000))));
1745 assert!(plan.chmod.is_none());
1746 }
1747 #[test]
1748 fn plan_preserves_setgid_across_chgrp() {
1749 let s = settings_with("", None, Some("2000"));
1752 let plan = compute_plan(0o2755, 1000, 1000, EntryKind::File, &s);
1753 assert_eq!(plan.chown, Some((None, Some(2000))));
1754 assert_eq!(plan.chmod, Some(0o2755));
1755 }
1756 #[test]
1757 fn plan_symlink_never_chmods_but_chowns() {
1758 let s = settings_with("g+w", None, Some("2000"));
1759 let plan = compute_plan(0o777, 1000, 1000, EntryKind::Symlink, &s);
1760 assert!(plan.chmod.is_none());
1761 assert_eq!(plan.chown, Some((None, Some(2000))));
1762 }
1763 #[test]
1764 fn plan_preserves_setuid_when_mode_rule_noop_but_chown_runs() {
1765 let s = settings_with("g+r", Some("2000"), None);
1768 let plan = compute_plan(0o4755, 1000, 1000, EntryKind::File, &s);
1769 assert_eq!(plan.chown, Some((Some(2000), None)));
1770 assert_eq!(plan.chmod, Some(0o4755));
1771 }
1772 #[test]
1773 fn plan_preserves_setgid_dir_across_chgrp() {
1774 let s = settings_with("", None, Some("2000"));
1776 let plan = compute_plan(0o2770, 1000, 1000, EntryKind::Dir, &s);
1777 assert_eq!(plan.chown, Some((None, Some(2000))));
1778 assert_eq!(plan.chmod, Some(0o2770));
1779 }
1780 #[test]
1781 fn no_setid_drops_symbolic_setuid_request() {
1782 let s = settings_with_no_setid("u+s", None, None);
1783 let plan = compute_plan(0o755, 1000, 1000, EntryKind::File, &s);
1784 assert!(plan.is_noop());
1785 }
1786 #[test]
1787 fn no_setid_masks_octal_setid_bits() {
1788 let s = settings_with_no_setid("6755", None, None);
1789 let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1790 assert_eq!(plan.chmod, Some(0o755));
1791 }
1792 #[test]
1793 fn no_setid_clears_inherited_bits_on_unrelated_mode_change() {
1794 let s = settings_with_no_setid("g+w", None, None);
1795 let plan = compute_plan(0o6755, 1000, 1000, EntryKind::File, &s);
1796 assert_eq!(plan.chmod, Some(0o775));
1797 }
1798 #[test]
1799 fn no_setid_clears_bits_before_and_after_ownership_change() {
1800 let s = settings_with_no_setid("", Some("2000"), None);
1801 let plan = compute_plan(0o4755, 1000, 1000, EntryKind::File, &s);
1802 assert_eq!(plan.chmod_before_chown, Some(0o755));
1803 assert_eq!(plan.chown, Some((Some(2000), None)));
1804 assert_eq!(plan.chmod, Some(0o755));
1805 }
1806 #[test]
1807 fn no_setid_reapplies_mask_after_chown_without_snapshot_bits() {
1808 let s = settings_with_no_setid("", Some("2000"), None);
1809 let plan = compute_plan(0o755, 1000, 1000, EntryKind::File, &s);
1810 assert!(plan.chmod_before_chown.is_none());
1811 assert_eq!(plan.chown, Some((Some(2000), None)));
1812 assert_eq!(plan.chmod, Some(0o755));
1813 }
1814 #[test]
1815 fn no_setid_clears_bits_when_selected_owner_is_unchanged() {
1816 let s = settings_with_no_setid("", Some("1000"), None);
1817 let plan = compute_plan(0o4755, 1000, 1000, EntryKind::File, &s);
1818 assert!(plan.chmod_before_chown.is_none());
1819 assert!(plan.chown.is_none());
1820 assert_eq!(plan.chmod, Some(0o755));
1821 }
1822 #[test]
1823 fn no_setid_retains_sticky_bit_on_setgid_directory() {
1824 let s = settings_with_no_setid("", None, Some("2000"));
1825 let plan = compute_plan(0o3775, 1000, 1000, EntryKind::Dir, &s);
1826 assert_eq!(plan.chmod_before_chown, Some(0o1775));
1827 assert_eq!(plan.chown, Some((None, Some(2000))));
1828 assert_eq!(plan.chmod, Some(0o1775));
1829 }
1830 #[test]
1831 fn no_setid_never_chmods_symlink() {
1832 let s = settings_with_no_setid("", None, Some("2000"));
1833 let plan = compute_plan(0o6777, 1000, 1000, EntryKind::Symlink, &s);
1834 assert!(plan.chmod_before_chown.is_none());
1835 assert_eq!(plan.chown, Some((None, Some(2000))));
1836 assert!(plan.chmod.is_none());
1837 }
1838
1839 static RACE_PROGRESS: std::sync::LazyLock<Progress> = std::sync::LazyLock::new(Progress::new);
1840
1841 fn spawn_file_symlink_swapper(
1847 dir: std::path::PathBuf,
1848 entry_name: &'static str,
1849 sentinel: std::path::PathBuf,
1850 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
1851 ) -> std::thread::JoinHandle<()> {
1852 use std::os::unix::fs::PermissionsExt;
1853 std::thread::spawn(move || {
1854 let entry = dir.join(entry_name);
1855 let staged_real = dir.join("__staged_real");
1856 let staged_link = dir.join("__staged_link");
1857 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
1858 let _ = std::fs::remove_file(&staged_real);
1860 if std::fs::write(&staged_real, b"REAL").is_err() {
1861 continue;
1862 }
1863 let _ =
1864 std::fs::set_permissions(&staged_real, std::fs::Permissions::from_mode(0o644));
1865 let _ = std::fs::rename(&staged_real, &entry);
1866 let _ = std::fs::remove_file(&staged_link);
1868 let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
1869 let _ = std::fs::rename(&staged_link, &entry);
1870 }
1871 })
1872 }
1873
1874 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1882 async fn entry_symlink_swap_never_changes_sentinel_mode() -> anyhow::Result<()> {
1883 use std::os::unix::fs::PermissionsExt;
1884 let tmp = crate::testutils::create_temp_dir().await?;
1885 let root = tmp.as_path();
1886 let sentinel = root.join("sentinel_secret");
1888 tokio::fs::write(&sentinel, b"SENTINEL").await?;
1889 std::fs::set_permissions(&sentinel, std::fs::Permissions::from_mode(0o600))?;
1890 let sentinel_uid_before =
1891 std::os::unix::fs::MetadataExt::uid(&std::fs::symlink_metadata(&sentinel)?);
1892 let target_dir = root.join("tree");
1894 tokio::fs::create_dir(&target_dir).await?;
1895 let entry_path = target_dir.join("entry");
1896 tokio::fs::write(&entry_path, b"REAL").await?;
1897 std::fs::set_permissions(&entry_path, std::fs::Permissions::from_mode(0o644))?;
1898
1899 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1900 let swapper =
1901 spawn_file_symlink_swapper(target_dir.clone(), "entry", sentinel.clone(), stop.clone());
1902
1903 let settings = settings_with("g+w", None, None);
1905 let mut caught = 0usize;
1906 let mut changed_real = 0usize;
1907 for i in 0..300 {
1908 let result = tokio::time::timeout(
1909 std::time::Duration::from_secs(30),
1910 chmod(&RACE_PROGRESS, &target_dir, &settings),
1911 )
1912 .await
1913 .expect("rchm must not hang under concurrent swapping");
1914 match result {
1915 Ok(summary) => changed_real += summary.files_changed,
1916 Err(_) => caught += 1, }
1918 let sentinel_md = std::fs::symlink_metadata(&sentinel)?;
1921 assert_eq!(
1922 sentinel_md.permissions().mode() & 0o7777,
1923 0o600,
1924 "iteration {i}: sentinel mode changed — rchm followed the symlink to chmod it"
1925 );
1926 assert_eq!(
1927 std::os::unix::fs::MetadataExt::uid(&sentinel_md),
1928 sentinel_uid_before,
1929 "iteration {i}: sentinel owner changed — rchm followed the symlink to chown it"
1930 );
1931 }
1932
1933 stop.store(true, std::sync::atomic::Ordering::Relaxed);
1934 swapper.join().expect("swapper thread panicked");
1935 tracing::info!("entry swap: caught={caught}, changed_real={changed_real}");
1937 assert!(
1938 caught + changed_real > 0,
1939 "expected at least one observable outcome across the iterations"
1940 );
1941 Ok(())
1942 }
1943
1944 mod max_open_files_tests {
1948 use super::*;
1949 use crate::walk_driver::process_entry;
1950
1951 static PROGRESS: std::sync::LazyLock<Progress> = std::sync::LazyLock::new(Progress::new);
1952
1953 #[tokio::test]
1967 async fn hinted_leaf_that_is_dir_drops_permit_before_recursion() -> anyhow::Result<()> {
1968 let root = crate::testutils::create_temp_dir().await?;
1969 let dir_path = root.join("d");
1971 tokio::fs::create_dir(&dir_path).await?;
1972 let child_path = dir_path.join("c");
1973 tokio::fs::write(&child_path, b"x").await?;
1974 std::fs::set_permissions(&child_path, std::fs::Permissions::from_mode(0o644))?;
1975 throttle::set_max_open_files(1);
1978 let parent = Dir::open_parent_dir(&root, congestion::Side::Destination)
1980 .await
1981 .context("open parent dir")?;
1982 let parent = Arc::new(parent.into_tree());
1983 let name = std::ffi::OsStr::new("d");
1984 let handle = parent.child(name).await.context("classify d")?;
1985 assert_eq!(
1986 handle.kind(),
1987 EntryKind::Dir,
1988 "fixture `d` must be a directory"
1989 );
1990 drop(handle);
1991 let visitor = Arc::new(ChmodVisitor {
1995 prog_track: &PROGRESS,
1996 settings: settings_with("g+w", None, None),
1997 });
1998 let cx = EntryCx {
1999 parent: Arc::clone(&parent),
2000 name: name.to_owned(),
2001 rel_path: PathBuf::new(),
2002 filter_path: PathBuf::new(),
2003 real_path: dir_path.clone(),
2004 dry_run: false,
2005 prog_track: &PROGRESS,
2006 };
2007 let permit = crate::walk::preacquire_leaf_permit(
2008 PermitKind::PendingMeta,
2009 Some(EntryKind::File),
2010 |_| true,
2011 )
2012 .await;
2013 assert!(permit.is_some(), "the pre-acquire must take the one permit");
2014 let result = tokio::time::timeout(
2015 std::time::Duration::from_secs(20),
2016 process_entry(visitor, cx, (), permit),
2017 )
2018 .await;
2019 throttle::set_max_open_files(0);
2023 let summary = result
2024 .context(
2025 "process_entry hung — leaf permit held across directory recursion (deadlock)",
2026 )?
2027 .map_err(|e| e.source)
2028 .context("process_entry failed")?;
2029 assert_eq!(
2031 summary.files_changed, 1,
2032 "child file should have its mode changed"
2033 );
2034 assert_eq!(
2035 summary.directories_changed, 1,
2036 "directory should have its mode changed"
2037 );
2038 assert_eq!(
2039 std::fs::symlink_metadata(&child_path)?.permissions().mode() & 0o777,
2040 0o664,
2041 "child file mode should be g+w applied"
2042 );
2043 Ok(())
2044 }
2045 }
2046}