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
20fn mode_of(meta: &FileMeta) -> u32 {
25 meta.permissions().mode() & 0o7777
26}
27
28pub type Error = crate::error::OperationError<Summary>;
30
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub struct OwnerProgram {
35 pub file: Option<u32>,
36 pub dir: Option<u32>,
37 pub symlink: Option<u32>,
38}
39
40impl OwnerProgram {
41 #[must_use]
42 pub fn for_kind(&self, kind: EntryKind) -> Option<u32> {
43 match kind {
44 EntryKind::Dir => self.dir,
45 EntryKind::Symlink => self.symlink,
46 EntryKind::File | EntryKind::Special => self.file,
47 }
48 }
49 #[must_use]
50 pub fn is_empty(&self) -> bool {
51 self.file.is_none() && self.dir.is_none() && self.symlink.is_none()
52 }
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
58pub enum ModeSpec {
59 Symbolic(Vec<SymbolicClause>),
60 Octal(u32),
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub struct SymbolicClause {
67 pub who: u8,
68 pub op: ModeOp,
69 pub perms: u8,
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
74pub enum ModeOp {
75 Add,
76 Remove,
77 Set,
78}
79
80pub(crate) const WHO_U: u8 = 0b001;
81pub(crate) const WHO_G: u8 = 0b010;
82pub(crate) const WHO_O: u8 = 0b100;
83pub(crate) const WHO_A: u8 = WHO_U | WHO_G | WHO_O;
84pub(crate) const PERM_R: u8 = 0b00_0001;
85pub(crate) const PERM_W: u8 = 0b00_0010;
86pub(crate) const PERM_X: u8 = 0b00_0100;
87pub(crate) const PERM_BIGX: u8 = 0b00_1000;
88pub(crate) const PERM_S: u8 = 0b01_0000;
89pub(crate) const PERM_T: u8 = 0b10_0000;
90
91#[derive(Clone, Debug, Default, PartialEq, Eq)]
94pub struct ModeProgram {
95 pub file: Option<ModeSpec>,
96 pub dir: Option<ModeSpec>,
97}
98
99impl ModeProgram {
100 #[must_use]
101 pub fn for_kind(&self, kind: EntryKind) -> Option<&ModeSpec> {
102 match kind {
103 EntryKind::Dir => self.dir.as_ref(),
104 EntryKind::Symlink => None,
105 EntryKind::File | EntryKind::Special => self.file.as_ref(),
106 }
107 }
108 #[must_use]
109 pub fn is_empty(&self) -> bool {
110 self.file.is_none() && self.dir.is_none()
111 }
112}
113
114#[derive(Clone, Debug)]
116pub struct Settings {
117 pub mode: ModeProgram,
118 pub owner: OwnerProgram,
119 pub group: OwnerProgram,
120 pub fail_early: bool,
121 pub defer_dir_changes: bool,
125 pub filter: Option<crate::filter::FilterSettings>,
126 pub time_filter: Option<TimeFilter>,
127 pub dry_run: Option<crate::config::DryRunMode>,
128}
129
130#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
131pub struct Summary {
132 pub files_changed: usize,
133 pub symlinks_changed: usize,
134 pub directories_changed: usize,
135 pub files_unchanged: usize,
136 pub symlinks_unchanged: usize,
137 pub directories_unchanged: usize,
138 pub files_skipped: usize,
139 pub symlinks_skipped: usize,
140 pub directories_skipped: usize,
141}
142
143impl std::ops::Add for Summary {
144 type Output = Self;
145 fn add(self, other: Self) -> Self {
146 Self {
147 files_changed: self.files_changed + other.files_changed,
148 symlinks_changed: self.symlinks_changed + other.symlinks_changed,
149 directories_changed: self.directories_changed + other.directories_changed,
150 files_unchanged: self.files_unchanged + other.files_unchanged,
151 symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
152 directories_unchanged: self.directories_unchanged + other.directories_unchanged,
153 files_skipped: self.files_skipped + other.files_skipped,
154 symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
155 directories_skipped: self.directories_skipped + other.directories_skipped,
156 }
157 }
158}
159
160impl std::fmt::Display for Summary {
161 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
162 write!(
163 f,
164 "files changed: {}\n\
165 symlinks changed: {}\n\
166 directories changed: {}\n\
167 files unchanged: {}\n\
168 symlinks unchanged: {}\n\
169 directories unchanged: {}\n\
170 files skipped: {}\n\
171 symlinks skipped: {}\n\
172 directories skipped: {}\n",
173 self.files_changed,
174 self.symlinks_changed,
175 self.directories_changed,
176 self.files_unchanged,
177 self.symlinks_unchanged,
178 self.directories_unchanged,
179 self.files_skipped,
180 self.symlinks_skipped,
181 self.directories_skipped
182 )
183 }
184}
185
186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
188pub enum IdKind {
189 User,
190 Group,
191}
192
193impl IdKind {
194 fn getent_database(self) -> &'static str {
196 match self {
197 IdKind::User => "passwd",
198 IdKind::Group => "group",
199 }
200 }
201 fn label(self) -> &'static str {
203 match self {
204 IdKind::User => "user",
205 IdKind::Group => "group",
206 }
207 }
208}
209
210fn resolve_id(token: &str, kind: IdKind, getent: &GetentResolver) -> anyhow::Result<u32> {
216 if let Ok(n) = token.parse::<u32>() {
217 return Ok(n);
218 }
219 let in_process = match kind {
220 IdKind::User => {
221 nix::unistd::User::from_name(token).map(|user| user.map(|u| u.uid.as_raw()))
222 }
223 IdKind::Group => {
224 nix::unistd::Group::from_name(token).map(|group| group.map(|g| g.gid.as_raw()))
225 }
226 };
227 match in_process {
228 Ok(Some(id)) => Ok(id),
229 Ok(None) | Err(_) => resolve_via_getent(token, kind, getent),
233 }
234}
235
236const GETENT_NOT_FOUND: i32 = 2;
240
241fn resolve_via_getent(token: &str, kind: IdKind, getent: &GetentResolver) -> anyhow::Result<u32> {
250 match getent.program()? {
251 Some(path) => resolve_via_getent_cmd(path.as_os_str(), token, kind),
252 None => resolve_via_getent_cmd(OsStr::new("getent"), token, kind),
253 }
254}
255
256fn resolve_via_getent_cmd(
262 getent_program: &OsStr,
263 token: &str,
264 kind: IdKind,
265) -> anyhow::Result<u32> {
266 let database = kind.getent_database();
267 let label = kind.label();
268 let prog = getent_program.to_string_lossy();
270 let output = std::process::Command::new(getent_program)
276 .args(["--", database, token])
277 .output()
278 .with_context(|| {
279 format!(
280 "cannot run `{prog} {database} {token}` to look up the {label} name; \
281 use a numeric id instead"
282 )
283 })?;
284 if output.status.code() == Some(GETENT_NOT_FOUND) {
285 return Err(anyhow!("unknown {label}: {token}"));
286 }
287 if !output.status.success() {
288 let status = output.status;
289 let stderr = String::from_utf8_lossy(&output.stderr);
290 let stderr = stderr.trim();
291 let detail = if stderr.is_empty() {
292 String::new()
293 } else {
294 format!(": {stderr}")
295 };
296 return Err(anyhow!(
297 "`{prog} {database} {token}` failed with {status}{detail}; \
298 use a numeric id instead"
299 ));
300 }
301 let stdout = String::from_utf8_lossy(&output.stdout);
302 let line = stdout
303 .lines()
304 .next()
305 .ok_or_else(|| anyhow!("`{prog} {database} {token}` produced no output"))?;
306 parse_getent_id(line).with_context(|| format!("unexpected getent output {line:?}"))
307}
308
309const TRUSTED_GETENT_DIRS: &[&str] = &["/usr/bin", "/bin", "/run/current-system/sw/bin"];
315
316#[must_use]
322pub fn is_privileged() -> bool {
323 let euid = nix::unistd::geteuid();
324 euid.as_raw() == 0 || nix::unistd::getuid() != euid
325}
326
327#[derive(Clone, Debug)]
335pub struct GetentResolver {
336 explicit: Option<PathBuf>,
338 privileged: bool,
340}
341
342impl Default for GetentResolver {
345 fn default() -> Self {
346 Self {
347 explicit: None,
348 privileged: false,
349 }
350 }
351}
352
353impl GetentResolver {
354 pub fn from_cli(getent_path: Option<PathBuf>, privileged: bool) -> anyhow::Result<Self> {
359 if let Some(path) = &getent_path
360 && !path.is_absolute()
361 {
362 return Err(anyhow!(
363 "--getent-path must be an absolute path, got {path:?}"
364 ));
365 }
366 Ok(Self {
367 explicit: getent_path,
368 privileged,
369 })
370 }
371
372 fn program(&self) -> anyhow::Result<Option<PathBuf>> {
375 self.program_in(TRUSTED_GETENT_DIRS)
376 }
377
378 fn program_in(&self, trusted_dirs: &[&str]) -> anyhow::Result<Option<PathBuf>> {
380 if let Some(path) = &self.explicit {
381 return Ok(Some(path.clone()));
382 }
383 if !self.privileged {
384 return Ok(None);
387 }
388 for dir in trusted_dirs {
390 let candidate = Path::new(dir).join("getent");
391 if candidate.is_file() {
392 return Ok(Some(candidate));
393 }
394 }
395 Err(anyhow!(
396 "running with elevated privilege and could not find `getent` in any trusted \
397 directory ({}); PATH is intentionally ignored when privileged so a name lookup \
398 cannot exec an attacker-controlled binary as root — pass an absolute \
399 --getent-path, or use numeric ids",
400 trusted_dirs.join(", ")
401 ))
402 }
403}
404
405fn parse_getent_id(line: &str) -> anyhow::Result<u32> {
408 let field = line
409 .split(':')
410 .nth(2)
411 .ok_or_else(|| anyhow!("expected at least 3 ':'-separated fields"))?;
412 field
413 .parse::<u32>()
414 .with_context(|| format!("parsing id field {field:?}"))
415}
416
417pub fn parse_owner_dsl(
421 s: &str,
422 kind: IdKind,
423 getent: &GetentResolver,
424) -> anyhow::Result<OwnerProgram> {
425 let mut prog = OwnerProgram::default();
426 let mut bare: Option<u32> = None;
427 for clause in s.split_whitespace() {
428 if let Some((ty, rest)) = clause.split_once(':') {
429 let id = resolve_id(rest, kind, getent)?;
430 match ty {
431 "f" | "file" => prog.file = Some(id),
432 "d" | "dir" | "directory" => prog.dir = Some(id),
433 "l" | "link" | "symlink" => prog.symlink = Some(id),
434 _ => return Err(anyhow!("unknown type prefix {ty:?} (expected f:/d:/l:)")),
435 }
436 } else if bare.is_some() {
437 return Err(anyhow!(
438 "multiple bare values in {s:?}; use f:/d:/l: prefixes to set different types"
439 ));
440 } else {
441 bare = Some(resolve_id(clause, kind, getent)?);
442 }
443 }
444 if let Some(b) = bare {
445 prog.file.get_or_insert(b);
446 prog.dir.get_or_insert(b);
447 prog.symlink.get_or_insert(b);
448 }
449 Ok(prog)
450}
451
452#[must_use]
455pub fn apply_mode(current: u32, spec: &ModeSpec, is_dir: bool) -> u32 {
456 match spec {
457 ModeSpec::Octal(m) => m & 0o7777,
458 ModeSpec::Symbolic(clauses) => {
459 let mut mode = current & 0o7777;
460 for clause in clauses {
461 mode = apply_clause(mode, *clause, is_dir);
462 }
463 mode
464 }
465 }
466}
467
468fn apply_clause(current: u32, clause: SymbolicClause, is_dir: bool) -> u32 {
469 let any_exec = current & 0o111 != 0;
470 let exec =
471 (clause.perms & PERM_X != 0) || (clause.perms & PERM_BIGX != 0 && (is_dir || any_exec));
472 let r = clause.perms & PERM_R != 0;
473 let w = clause.perms & PERM_W != 0;
474 let s = clause.perms & PERM_S != 0;
475 let t = clause.perms & PERM_T != 0;
476 let mut value: u32 = 0;
477 if clause.who & WHO_U != 0 {
478 if r {
479 value |= 0o400;
480 }
481 if w {
482 value |= 0o200;
483 }
484 if exec {
485 value |= 0o100;
486 }
487 if s {
488 value |= 0o4000;
489 }
490 }
491 if clause.who & WHO_G != 0 {
492 if r {
493 value |= 0o040;
494 }
495 if w {
496 value |= 0o020;
497 }
498 if exec {
499 value |= 0o010;
500 }
501 if s {
502 value |= 0o2000;
503 }
504 }
505 if clause.who & WHO_O != 0 {
506 if r {
507 value |= 0o004;
508 }
509 if w {
510 value |= 0o002;
511 }
512 if exec {
513 value |= 0o001;
514 }
515 }
516 if t && clause.who & WHO_O != 0 {
517 value |= 0o1000;
519 }
520 match clause.op {
521 ModeOp::Add => current | value,
522 ModeOp::Remove => current & !value,
523 ModeOp::Set => {
524 let mut clear: u32 = 0;
525 if clause.who & WHO_U != 0 {
526 clear |= 0o4700;
527 }
528 if clause.who & WHO_G != 0 {
529 clear |= 0o2070;
530 }
531 if clause.who & WHO_O != 0 {
532 clear |= 0o1007;
533 }
534 (current & !clear) | value
535 }
536 }
537}
538
539fn parse_mode_token(token: &str) -> anyhow::Result<ModeSpec> {
542 if token.is_empty() {
543 return Err(anyhow!("empty mode"));
544 }
545 if token.bytes().all(|b| b.is_ascii_digit()) {
546 if token.bytes().any(|b| b > b'7') {
547 return Err(anyhow!("invalid octal mode {token:?} (digits must be 0-7)"));
548 }
549 let value = u32::from_str_radix(token, 8)
550 .with_context(|| format!("parsing octal mode {token:?}"))?;
551 if value > 0o7777 {
552 return Err(anyhow!("octal mode {token:?} out of range (max 0o7777)"));
553 }
554 return Ok(ModeSpec::Octal(value));
555 }
556 let clauses = token
557 .split(',')
558 .map(parse_symbolic_clause)
559 .collect::<anyhow::Result<Vec<_>>>()?;
560 Ok(ModeSpec::Symbolic(clauses))
561}
562
563fn parse_symbolic_clause(clause: &str) -> anyhow::Result<SymbolicClause> {
564 let op_pos = clause
565 .find(['+', '-', '='])
566 .ok_or_else(|| anyhow!("mode clause {clause:?} missing +, - or ="))?;
567 let (who_str, rest) = clause.split_at(op_pos);
568 let op = match &rest[..1] {
569 "+" => ModeOp::Add,
570 "-" => ModeOp::Remove,
571 "=" => ModeOp::Set,
572 _ => unreachable!("find guaranteed one of +-="),
573 };
574 let perms_str = &rest[1..];
575 let mut who = 0u8;
576 for ch in who_str.chars() {
577 who |= match ch {
578 'u' => WHO_U,
579 'g' => WHO_G,
580 'o' => WHO_O,
581 'a' => WHO_A,
582 other => {
583 return Err(anyhow!(
584 "invalid 'who' {other:?} in {clause:?} (expected u/g/o/a)"
585 ));
586 }
587 };
588 }
589 if who == 0 {
590 who = WHO_A;
591 }
592 let mut perms = 0u8;
593 for ch in perms_str.chars() {
594 perms |= match ch {
595 'r' => PERM_R,
596 'w' => PERM_W,
597 'x' => PERM_X,
598 'X' => PERM_BIGX,
599 's' => PERM_S,
600 't' => PERM_T,
601 other => return Err(anyhow!("invalid permission {other:?} in {clause:?}")),
602 };
603 }
604 Ok(SymbolicClause { who, op, perms })
605}
606
607pub fn parse_mode_dsl(s: &str) -> anyhow::Result<ModeProgram> {
611 let mut prog = ModeProgram::default();
612 let mut bare: Option<ModeSpec> = None;
613 for clause in s.split_whitespace() {
614 if let Some((ty, rest)) = clause.split_once(':') {
615 let spec = parse_mode_token(rest)?;
616 match ty {
617 "f" | "file" => prog.file = Some(spec),
618 "d" | "dir" | "directory" => prog.dir = Some(spec),
619 "l" | "link" | "symlink" => {
620 return Err(anyhow!(
621 "symlink mode (l:) is not settable on Linux; remove the l: section"
622 ));
623 }
624 _ => return Err(anyhow!("unknown type prefix {ty:?} (expected f:/d:)")),
625 }
626 } else if bare.is_some() {
627 return Err(anyhow!(
628 "multiple bare mode expressions in {s:?}; chain sub-ops with commas (e.g. g+r,o+w)"
629 ));
630 } else {
631 bare = Some(parse_mode_token(clause)?);
632 }
633 }
634 if let Some(b) = bare {
635 prog.file.get_or_insert(b.clone());
636 prog.dir.get_or_insert(b);
637 }
638 Ok(prog)
639}
640
641#[derive(Clone, Copy, Debug, PartialEq, Eq)]
645pub(crate) struct EntryPlan {
646 pub chown: Option<(Option<u32>, Option<u32>)>,
647 pub chmod: Option<u32>,
648}
649
650impl EntryPlan {
651 pub(crate) fn is_noop(&self) -> bool {
652 self.chown.is_none() && self.chmod.is_none()
653 }
654}
655
656pub(crate) fn compute_plan(
659 cur_mode: u32,
660 cur_uid: u32,
661 cur_gid: u32,
662 kind: EntryKind,
663 settings: &Settings,
664) -> EntryPlan {
665 let cur_mode = cur_mode & 0o7777;
666 let uid_change = settings.owner.for_kind(kind).filter(|&u| u != cur_uid);
667 let gid_change = settings.group.for_kind(kind).filter(|&g| g != cur_gid);
668 let need_chown = uid_change.is_some() || gid_change.is_some();
669 let chown = need_chown.then_some((uid_change, gid_change));
670 let chmod = if kind == EntryKind::Symlink {
671 None
673 } else if let Some(spec) = settings.mode.for_kind(kind) {
674 let desired = apply_mode(cur_mode, spec, kind == EntryKind::Dir);
675 if desired != cur_mode || (need_chown && desired & 0o6000 != 0) {
678 Some(desired)
679 } else {
680 None
681 }
682 } else if need_chown && cur_mode & 0o6000 != 0 {
683 Some(cur_mode)
686 } else {
687 None
688 };
689 EntryPlan { chown, chmod }
690}
691
692fn inc_changed(prog: &Progress, kind: EntryKind) -> Summary {
693 match kind {
694 EntryKind::Dir => {
695 prog.directories_changed.inc();
696 Summary {
697 directories_changed: 1,
698 ..Default::default()
699 }
700 }
701 EntryKind::Symlink => {
702 prog.symlinks_changed.inc();
703 Summary {
704 symlinks_changed: 1,
705 ..Default::default()
706 }
707 }
708 EntryKind::File | EntryKind::Special => {
709 prog.files_changed.inc();
710 Summary {
711 files_changed: 1,
712 ..Default::default()
713 }
714 }
715 }
716}
717
718fn inc_unchanged(prog: &Progress, kind: EntryKind) -> Summary {
719 match kind {
720 EntryKind::Dir => {
721 prog.directories_unchanged.inc();
722 Summary {
723 directories_unchanged: 1,
724 ..Default::default()
725 }
726 }
727 EntryKind::Symlink => {
728 prog.symlinks_unchanged.inc();
729 Summary {
730 symlinks_unchanged: 1,
731 ..Default::default()
732 }
733 }
734 EntryKind::File | EntryKind::Special => {
735 prog.files_unchanged.inc();
736 Summary {
737 files_unchanged: 1,
738 ..Default::default()
739 }
740 }
741 }
742}
743
744fn skipped_summary_for(kind: EntryKind) -> Summary {
745 match kind {
746 EntryKind::Dir => Summary {
747 directories_skipped: 1,
748 ..Default::default()
749 },
750 EntryKind::Symlink => Summary {
751 symlinks_skipped: 1,
752 ..Default::default()
753 },
754 EntryKind::File | EntryKind::Special => Summary {
755 files_skipped: 1,
756 ..Default::default()
757 },
758 }
759}
760
761fn describe_change(cur_mode: u32, cur_uid: u32, cur_gid: u32, plan: &EntryPlan) -> String {
763 let mut parts = Vec::new();
764 if let Some(mode) = plan.chmod {
765 if mode == cur_mode & 0o7777 {
766 parts.push(format!("mode {mode:04o} (re-applied after chown)"));
768 } else {
769 parts.push(format!("mode {:04o}->{:04o}", cur_mode & 0o7777, mode));
770 }
771 }
772 if let Some((uid, gid)) = plan.chown {
773 if let Some(uid) = uid {
774 parts.push(format!("owner {cur_uid}->{uid}"));
775 }
776 if let Some(gid) = gid {
777 parts.push(format!("group {cur_gid}->{gid}"));
778 }
779 }
780 parts.join(", ")
781}
782
783async fn apply_plan(handle: &Handle, plan: &EntryPlan) -> anyhow::Result<()> {
803 if let Some((uid, gid)) = plan.chown {
804 safedir::fchown_handle(handle, congestion::Side::Destination, uid, gid)
805 .await
806 .with_context(|| format!("failed to chown via fd (uid={uid:?}, gid={gid:?})"))?;
807 }
808 if let Some(mode) = plan.chmod {
809 safedir::chmod_via_proc_fd(handle, congestion::Side::Destination, mode)
810 .await
811 .with_context(|| format!("failed to chmod via fd to {mode:04o}"))?;
812 }
813 Ok(())
814}
815
816async fn apply_entry_change(
824 prog: &'static Progress,
825 path: &std::path::Path,
826 handle: &Handle,
827 kind: EntryKind,
828 settings: &Settings,
829) -> Result<Summary, Error> {
830 if let Some(ref time_filter) = settings.time_filter {
831 let metadata =
834 match safedir::stat_meta_via_proc_fd(handle, congestion::Side::Destination).await {
835 Ok(md) => md,
836 Err(err) => {
837 let err = anyhow::Error::new(err).context(format!(
838 "failed reading metadata for time filter on {path:?}"
839 ));
840 if settings.fail_early {
841 return Err(Error::new(err, Default::default()));
842 }
843 tracing::warn!("time filter failed for {:?}, skipping: {:#}", path, &err);
844 kind.inc_skipped(prog);
845 return Ok(skipped_summary_for(kind));
846 }
847 };
848 match time_filter.matches(&metadata) {
849 Ok(result) => {
850 if let Some(reason) = result.as_skip_reason() {
851 if let Some(mode) = settings.dry_run {
852 crate::dry_run::report_time_skip(path, reason, mode, kind.label());
853 }
854 kind.inc_skipped(prog);
855 return Ok(skipped_summary_for(kind));
856 }
857 }
858 Err(err) => {
859 let err = err.context(format!("failed evaluating time filter on {path:?}"));
860 if settings.fail_early {
861 return Err(Error::new(err, Default::default()));
862 }
863 tracing::warn!("time filter failed for {:?}, skipping: {:#}", path, &err);
864 kind.inc_skipped(prog);
865 return Ok(skipped_summary_for(kind));
866 }
867 }
868 }
869 let meta = handle.meta();
870 let cur_mode = mode_of(meta);
871 let plan = compute_plan(cur_mode, meta.uid(), meta.gid(), kind, settings);
872 if plan.is_noop() {
873 if let Some(crate::config::DryRunMode::All) = settings.dry_run {
874 println!("unchanged {} {:?}", kind.label(), path);
875 }
876 return Ok(inc_unchanged(prog, kind));
877 }
878 if settings.dry_run.is_some() {
879 let desc = describe_change(cur_mode, meta.uid(), meta.gid(), &plan);
880 println!("would modify {} {:?}: {}", kind.label(), path, desc);
881 return Ok(inc_changed(prog, kind));
882 }
883 apply_plan(handle, &plan)
884 .await
885 .map_err(|err| Error::new(err, Default::default()))?;
886 Ok(inc_changed(prog, kind))
887}
888
889#[instrument(skip(prog_track, settings))]
899pub async fn chmod(
900 prog_track: &'static Progress,
901 path: &std::path::Path,
902 settings: &Settings,
903) -> Result<Summary, Error> {
904 let operand = crate::walk::split_root_operand(path)
910 .await
911 .map_err(|err| Error::new(err, Default::default()))?;
912 let parent_path = operand.parent.as_path();
913 let name = operand.name.as_os_str();
914 let path = operand.display.as_path();
915 let parent = Dir::open_parent_dir(parent_path, congestion::Side::Destination)
921 .await
922 .with_context(|| format!("cannot open parent directory {parent_path:?}"))
923 .map_err(|err| Error::new(err, Default::default()))?;
924 let parent = Arc::new(parent.into_tree());
926 if let Some(ref filter) = settings.filter {
927 let root_handle = parent
930 .child(name)
931 .await
932 .with_context(|| format!("failed reading metadata from {path:?}"))
933 .map_err(|err| Error::new(err, Default::default()))?;
934 let name_path = std::path::Path::new(name);
935 match filter.should_include_root_item(name_path, root_handle.kind() == EntryKind::Dir) {
936 crate::filter::FilterResult::Included => {}
937 result => {
938 let kind = root_handle.kind();
939 if let Some(mode) = settings.dry_run {
940 crate::dry_run::report_skip(path, &result, mode, kind.label_long());
941 }
942 kind.inc_skipped(prog_track);
943 return Ok(skipped_summary_for(kind));
944 }
945 }
946 }
947 run_chmod_root(prog_track, &parent, name, path, settings).await
948}
949
950async fn run_chmod_root(
954 prog_track: &'static Progress,
955 parent: &Arc<Dir>,
956 name: &OsStr,
957 root: &std::path::Path,
958 settings: &Settings,
959) -> Result<Summary, Error> {
960 let visitor = Arc::new(ChmodVisitor {
961 prog_track,
962 settings: settings.clone(),
963 });
964 let root_cx = EntryCx {
967 parent: Arc::clone(parent),
968 name: name.to_owned(),
969 rel_path: PathBuf::new(),
970 filter_path: PathBuf::new(),
971 real_path: root.to_path_buf(),
972 dry_run: settings.dry_run.is_some(),
973 prog_track,
974 };
975 process_entry(visitor, root_cx, (), None).await }
977
978struct ChmodVisitor {
984 prog_track: &'static Progress,
985 settings: Settings,
986}
987
988struct ChmodDirState {
991 handle: Handle,
994 traversed_only: bool,
997 base: Summary,
999 pre_order_error: Option<anyhow::Error>,
1003}
1004
1005impl WalkVisitor for ChmodVisitor {
1006 type Summary = Summary;
1007 type DirContext = ();
1008 type DirState = ChmodDirState;
1009
1010 fn root_dir_context(&self) {}
1011
1012 fn permit_kind(&self) -> PermitKind {
1013 PermitKind::PendingMeta
1015 }
1016
1017 fn want_permit(&self, hint: Option<EntryKind>) -> bool {
1018 hint.is_some_and(|k| k != EntryKind::Dir)
1021 }
1022
1023 fn fail_early(&self) -> bool {
1024 self.settings.fail_early
1025 }
1026
1027 fn filter(&self) -> Option<&crate::filter::FilterSettings> {
1028 self.settings.filter.as_ref()
1029 }
1030
1031 fn on_skip(
1032 &self,
1033 cx: &EntryCx,
1034 kind: EntryKind,
1035 skip_result: &crate::filter::FilterResult,
1036 ) -> Summary {
1037 if let Some(mode) = self.settings.dry_run {
1040 crate::dry_run::report_skip(&cx.real_path, skip_result, mode, kind.label());
1041 }
1042 skipped_summary_for(kind)
1043 }
1044
1045 async fn visit_leaf(
1046 &self,
1047 cx: &EntryCx,
1048 _parent_ctx: &(),
1049 handle: Handle,
1050 kind: EntryKind,
1051 permit: Option<LeafPermit>,
1052 ) -> Result<Summary, Error> {
1053 let _permit = permit;
1057 apply_entry_change(
1058 self.prog_track,
1059 &cx.real_path,
1060 &handle,
1061 kind,
1062 &self.settings,
1063 )
1064 .await
1065 }
1066
1067 async fn dir_pre(&self, cx: &EntryCx, _parent_ctx: &(), handle: &Handle) -> DirPreResult<Self> {
1068 let path = &cx.real_path;
1069 let traversed_only = self.settings.filter.as_ref().is_some_and(|f| {
1073 f.has_includes() && !f.directly_matches_include(&cx.filter_path, true)
1074 });
1075 let mut base = Summary::default();
1076 let mut pre_order_error: Option<anyhow::Error> = None;
1077 if !self.settings.defer_dir_changes {
1082 match apply_dir_self(
1083 self.prog_track,
1084 path,
1085 handle,
1086 traversed_only,
1087 &self.settings,
1088 )
1089 .await
1090 {
1091 Ok(dir_summary) => base = base + dir_summary,
1092 Err(error) if self.settings.fail_early => {
1095 return Err(Error::new(error.source, base + error.summary));
1096 }
1097 Err(error) => {
1098 tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
1099 base = base + error.summary;
1100 pre_order_error = Some(error.source);
1101 }
1102 }
1103 }
1104 let dir_handle = handle
1108 .try_clone()
1109 .with_context(|| format!("cannot duplicate directory handle for {path:?}"))
1110 .map_err(|err| Error::new(err, base))?;
1111 match cx.parent.open_dir(&cx.name).await {
1115 Ok(dir) => Ok(DirAction::Descend {
1116 dir: Arc::new(dir),
1117 child_ctx: (),
1118 state: ChmodDirState {
1119 handle: dir_handle,
1120 traversed_only,
1121 base,
1122 pre_order_error,
1123 },
1124 }),
1125 Err(error) => {
1126 let error = anyhow::Error::new(error)
1127 .context(format!("cannot open directory {path:?} for reading"));
1128 if self.settings.fail_early {
1129 return Err(Error::new(error, base));
1130 }
1131 let errors = crate::error_collector::ErrorCollector::default();
1135 if let Some(pre_order_error) = pre_order_error {
1136 errors.push(pre_order_error);
1137 }
1138 tracing::error!("chmod: {:#}", &error);
1139 errors.push(error);
1140 if self.settings.defer_dir_changes {
1141 match apply_dir_self(
1142 self.prog_track,
1143 path,
1144 handle,
1145 traversed_only,
1146 &self.settings,
1147 )
1148 .await
1149 {
1150 Ok(dir_summary) => base = base + dir_summary,
1151 Err(error) => {
1152 tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
1153 base = base + error.summary;
1154 errors.push(error.source);
1155 }
1156 }
1157 }
1158 Err(Error::new(errors.into_error().unwrap(), base))
1160 }
1161 }
1162 }
1163
1164 async fn dir_post(
1165 &self,
1166 cx: &EntryCx,
1167 state: ChmodDirState,
1168 _processed: &ProcessedChildren,
1169 child_result: Result<Summary, Error>,
1170 ) -> Result<Summary, Error> {
1171 let ChmodDirState {
1172 handle,
1173 traversed_only,
1174 base,
1175 pre_order_error,
1176 } = state;
1177 let path = &cx.real_path;
1178 let (child_summary, child_error) = match child_result {
1181 Ok(summary) => (summary, None),
1182 Err(err) => (err.summary, Some(err.source)),
1183 };
1184 let mut summary = base + child_summary;
1185 let errors = crate::error_collector::ErrorCollector::default();
1187 if let Some(pre_order_error) = pre_order_error {
1188 errors.push(pre_order_error);
1189 }
1190 if let Some(child_error) = child_error {
1191 errors.push(child_error);
1192 }
1193 if self.settings.defer_dir_changes {
1199 match apply_dir_self(
1200 self.prog_track,
1201 path,
1202 &handle,
1203 traversed_only,
1204 &self.settings,
1205 )
1206 .await
1207 {
1208 Ok(dir_summary) => summary = summary + dir_summary,
1209 Err(error) => {
1210 if self.settings.fail_early {
1211 return Err(Error::new(error.source, summary + error.summary));
1212 }
1213 tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
1214 summary = summary + error.summary;
1215 errors.push(error.source);
1216 }
1217 }
1218 }
1219 if let Some(error) = errors.into_error() {
1220 return Err(Error::new(error, summary));
1221 }
1222 Ok(summary)
1223 }
1224}
1225
1226async fn apply_dir_self(
1234 prog_track: &'static Progress,
1235 path: &std::path::Path,
1236 handle: &Handle,
1237 traversed_only: bool,
1238 settings: &Settings,
1239) -> Result<Summary, Error> {
1240 if traversed_only {
1241 if let Some(crate::config::DryRunMode::All) = settings.dry_run {
1242 println!("skip dir {path:?} (only traversed for include matches)");
1243 }
1244 prog_track.directories_skipped.inc();
1245 return Ok(skipped_summary_for(EntryKind::Dir));
1246 }
1247 apply_entry_change(prog_track, path, handle, EntryKind::Dir, settings).await
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252 use super::*;
1253 #[test]
1254 fn mode_token_octal() {
1255 assert_eq!(parse_mode_token("2775").unwrap(), ModeSpec::Octal(0o2775));
1256 assert_eq!(parse_mode_token("0644").unwrap(), ModeSpec::Octal(0o644));
1257 }
1258 #[test]
1259 fn mode_token_octal_out_of_range_errors() {
1260 assert!(parse_mode_token("9999").is_err()); assert!(parse_mode_token("77777").is_err()); }
1263 #[test]
1264 fn mode_token_symbolic_simple() {
1265 let spec = parse_mode_token("g+w").unwrap();
1266 assert_eq!(
1267 spec,
1268 ModeSpec::Symbolic(vec![SymbolicClause {
1269 who: WHO_G,
1270 op: ModeOp::Add,
1271 perms: PERM_W
1272 }])
1273 );
1274 }
1275 #[test]
1276 fn mode_token_symbolic_omitted_who_means_all() {
1277 let spec = parse_mode_token("+x").unwrap();
1278 assert_eq!(
1279 spec,
1280 ModeSpec::Symbolic(vec![SymbolicClause {
1281 who: WHO_A,
1282 op: ModeOp::Add,
1283 perms: PERM_X
1284 }])
1285 );
1286 }
1287 #[test]
1288 fn mode_token_symbolic_comma_chained() {
1289 let spec = parse_mode_token("u+rw,g-w").unwrap();
1290 let ModeSpec::Symbolic(clauses) = spec else {
1291 panic!("expected symbolic")
1292 };
1293 assert_eq!(clauses.len(), 2);
1294 assert_eq!(
1295 clauses[0],
1296 SymbolicClause {
1297 who: WHO_U,
1298 op: ModeOp::Add,
1299 perms: PERM_R | PERM_W
1300 }
1301 );
1302 assert_eq!(
1303 clauses[1],
1304 SymbolicClause {
1305 who: WHO_G,
1306 op: ModeOp::Remove,
1307 perms: PERM_W
1308 }
1309 );
1310 }
1311 #[test]
1312 fn mode_token_symbolic_bigx_and_specials() {
1313 let spec = parse_mode_token("g+rwXs").unwrap();
1314 assert_eq!(
1315 spec,
1316 ModeSpec::Symbolic(vec![SymbolicClause {
1317 who: WHO_G,
1318 op: ModeOp::Add,
1319 perms: PERM_R | PERM_W | PERM_BIGX | PERM_S,
1320 }])
1321 );
1322 }
1323 #[test]
1324 fn mode_token_rejects_garbage() {
1325 assert!(parse_mode_token("q+z").is_err());
1326 assert!(parse_mode_token("g!w").is_err());
1327 assert!(parse_mode_token("").is_err());
1328 }
1329 #[test]
1330 fn summary_add_combines_fields() {
1331 let a = Summary {
1332 files_changed: 1,
1333 directories_changed: 2,
1334 files_unchanged: 3,
1335 ..Default::default()
1336 };
1337 let b = Summary {
1338 files_changed: 10,
1339 symlinks_skipped: 4,
1340 ..Default::default()
1341 };
1342 let sum = a + b;
1343 assert_eq!(sum.files_changed, 11);
1344 assert_eq!(sum.directories_changed, 2);
1345 assert_eq!(sum.files_unchanged, 3);
1346 assert_eq!(sum.symlinks_skipped, 4);
1347 }
1348 #[test]
1349 fn owner_dsl_bare_applies_to_all_types() {
1350 let prog = parse_owner_dsl("0", IdKind::User, &GetentResolver::default()).unwrap();
1351 assert_eq!(prog.file, Some(0));
1352 assert_eq!(prog.dir, Some(0));
1353 assert_eq!(prog.symlink, Some(0));
1354 }
1355 #[test]
1356 fn owner_dsl_per_type_overrides() {
1357 let prog = parse_owner_dsl("f:1 d:2", IdKind::User, &GetentResolver::default()).unwrap();
1358 assert_eq!(prog.file, Some(1));
1359 assert_eq!(prog.dir, Some(2));
1360 assert_eq!(prog.symlink, None);
1361 }
1362 #[test]
1363 fn owner_dsl_bare_plus_override() {
1364 let prog = parse_owner_dsl("5 d:2", IdKind::Group, &GetentResolver::default()).unwrap();
1365 assert_eq!(prog.file, Some(5));
1366 assert_eq!(prog.dir, Some(2));
1367 assert_eq!(prog.symlink, Some(5));
1368 }
1369 #[test]
1370 fn owner_dsl_explicit_before_bare_is_order_independent() {
1371 let prog = parse_owner_dsl("f:1 5", IdKind::User, &GetentResolver::default()).unwrap();
1372 assert_eq!(prog.file, Some(1));
1373 assert_eq!(prog.dir, Some(5));
1374 assert_eq!(prog.symlink, Some(5));
1375 }
1376 #[test]
1377 fn owner_dsl_rejects_multiple_bare() {
1378 assert!(parse_owner_dsl("1 2", IdKind::User, &GetentResolver::default()).is_err());
1379 }
1380 #[test]
1381 fn owner_dsl_rejects_unknown_id() {
1382 assert!(
1383 parse_owner_dsl(
1384 "definitely-no-such-group-xyz",
1385 IdKind::Group,
1386 &GetentResolver::default()
1387 )
1388 .is_err()
1389 );
1390 }
1391 #[test]
1392 fn owner_dsl_resolves_root_name() {
1393 let prog = parse_owner_dsl("root", IdKind::User, &GetentResolver::default()).unwrap();
1395 assert_eq!(prog.file, Some(0));
1396 assert_eq!(prog.dir, Some(0));
1397 assert_eq!(prog.symlink, Some(0));
1398 }
1399 #[test]
1400 fn getent_id_parses_passwd_and_group_lines() {
1401 assert_eq!(
1403 parse_getent_id("alice:x:1234:100:Alice:/home/alice:/bin/sh").unwrap(),
1404 1234
1405 );
1406 assert_eq!(parse_getent_id("data:*:5678:alice,bob").unwrap(), 5678);
1408 }
1409 #[test]
1410 fn getent_id_rejects_malformed_lines() {
1411 assert!(parse_getent_id("").is_err());
1412 assert!(parse_getent_id("alice").is_err());
1413 assert!(parse_getent_id("alice:x").is_err());
1414 assert!(parse_getent_id("alice:x:notanumber:100").is_err());
1415 }
1416 fn write_stub_getent(dir: &std::path::Path, name: &str, body: &str) -> std::path::PathBuf {
1418 use std::os::unix::fs::PermissionsExt;
1419 let path = dir.join(name);
1420 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
1421 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1422 path
1423 }
1424 #[test]
1425 fn getent_stub_resolves_directory_service_names() {
1426 let tmp = tempfile::tempdir().unwrap();
1427 let user_stub = write_stub_getent(
1431 tmp.path(),
1432 "getent-user",
1433 "[ \"$1\" = -- ] && [ \"$2\" = passwd ] || exit 99\necho 'ldapuser:*:4242:4242:LDAP User:/home/ldapuser:/bin/sh'",
1434 );
1435 let group_stub = write_stub_getent(
1436 tmp.path(),
1437 "getent-group",
1438 "[ \"$1\" = -- ] && [ \"$2\" = group ] || exit 99\necho 'ldapgroup:*:4343:ldapuser'",
1439 );
1440 assert_eq!(
1441 resolve_via_getent_cmd(user_stub.as_os_str(), "ldapuser", IdKind::User).unwrap(),
1442 4242
1443 );
1444 assert_eq!(
1445 resolve_via_getent_cmd(group_stub.as_os_str(), "ldapgroup", IdKind::Group).unwrap(),
1446 4343
1447 );
1448 }
1449 #[test]
1450 fn getent_stub_not_found_is_unknown() {
1451 let tmp = tempfile::tempdir().unwrap();
1452 let stub = write_stub_getent(tmp.path(), "getent-miss", "exit 2");
1453 let err = resolve_via_getent_cmd(stub.as_os_str(), "nosuch", IdKind::Group).unwrap_err();
1454 assert!(
1455 format!("{err:#}").contains("unknown group: nosuch"),
1456 "got: {err:#}"
1457 );
1458 }
1459 #[test]
1460 fn getent_missing_program_suggests_numeric_id() {
1461 let tmp = tempfile::tempdir().unwrap();
1462 let missing = tmp.path().join("no-such-getent");
1463 let err = resolve_via_getent_cmd(missing.as_os_str(), "alice", IdKind::User).unwrap_err();
1464 assert!(
1465 format!("{err:#}").contains("use a numeric id instead"),
1466 "got: {err:#}"
1467 );
1468 }
1469 #[test]
1470 fn getent_stub_garbled_output_errors() {
1471 let tmp = tempfile::tempdir().unwrap();
1472 let stub = write_stub_getent(tmp.path(), "getent-garbled", "echo 'not a passwd line'");
1473 let err = resolve_via_getent_cmd(stub.as_os_str(), "alice", IdKind::User).unwrap_err();
1474 assert!(
1475 format!("{err:#}").contains("unexpected getent output"),
1476 "got: {err:#}"
1477 );
1478 }
1479 #[test]
1480 fn getent_stub_exit0_no_output_errors() {
1481 let tmp = tempfile::tempdir().unwrap();
1482 let stub = write_stub_getent(tmp.path(), "getent-empty", "exit 0");
1483 let err = resolve_via_getent_cmd(stub.as_os_str(), "alice", IdKind::User).unwrap_err();
1484 assert!(
1485 format!("{err:#}").contains("produced no output"),
1486 "got: {err:#}"
1487 );
1488 }
1489 #[test]
1490 fn getent_real_resolves_root() {
1491 assert_eq!(
1494 resolve_via_getent("root", IdKind::User, &GetentResolver::default()).unwrap(),
1495 0
1496 );
1497 assert_eq!(
1498 resolve_via_getent("root", IdKind::Group, &GetentResolver::default()).unwrap(),
1499 0
1500 );
1501 }
1502 #[test]
1503 fn getent_real_option_like_name_fails_closed_no_injection() {
1504 let err = resolve_via_getent("--service=files", IdKind::Group, &GetentResolver::default())
1510 .unwrap_err();
1511 let msg = format!("{err:#}");
1512 assert!(
1513 msg.contains("unknown group") || msg.contains("produced no output"),
1514 "option-like name must fail closed (not resolve to an id), got: {msg}"
1515 );
1516 }
1517 #[test]
1518 fn getent_source_rejects_relative_explicit_path() {
1519 let err = GetentResolver::from_cli(Some(PathBuf::from("getent")), false).unwrap_err();
1521 assert!(
1522 format!("{err:#}").contains("must be an absolute path"),
1523 "got: {err:#}"
1524 );
1525 }
1526 #[test]
1527 fn getent_source_explicit_absolute_used_even_when_privileged() {
1528 let resolver =
1530 GetentResolver::from_cli(Some(PathBuf::from("/opt/nss/getent")), true).unwrap();
1531 assert_eq!(
1532 resolver.program_in(&["/usr/bin"]).unwrap(),
1533 Some(PathBuf::from("/opt/nss/getent"))
1534 );
1535 }
1536 #[test]
1537 fn getent_source_unprivileged_searches_path() {
1538 let resolver = GetentResolver::from_cli(None, false).unwrap();
1540 assert_eq!(resolver.program_in(&["/nonexistent"]).unwrap(), None);
1541 }
1542 #[test]
1543 fn getent_source_privileged_probes_trusted_dirs_not_path() {
1544 let tmp = tempfile::tempdir().unwrap();
1546 let bin = tmp.path().join("getent");
1547 std::fs::write(&bin, "#!/bin/sh\n").unwrap();
1548 let resolver = GetentResolver::from_cli(None, true).unwrap();
1549 let dirs = [tmp.path().to_str().unwrap()];
1550 assert_eq!(resolver.program_in(&dirs).unwrap(), Some(bin));
1551 }
1552 #[test]
1553 fn getent_source_privileged_missing_getent_errors_not_path_fallback() {
1554 let tmp = tempfile::tempdir().unwrap(); let resolver = GetentResolver::from_cli(None, true).unwrap();
1558 let dir = tmp.path().to_str().unwrap();
1559 let err = resolver.program_in(&[dir]).unwrap_err();
1560 let msg = format!("{err:#}");
1561 assert!(
1562 msg.contains("PATH is intentionally ignored"),
1563 "should explain PATH is not consulted, got: {msg}"
1564 );
1565 assert!(
1566 msg.contains(dir),
1567 "should name the searched dir, got: {msg}"
1568 );
1569 }
1570 fn sym(s: &str) -> ModeSpec {
1571 parse_mode_token(s).unwrap()
1572 }
1573 #[test]
1574 fn apply_mode_group_add_remove() {
1575 assert_eq!(apply_mode(0o644, &sym("g+w"), false), 0o664);
1576 assert_eq!(apply_mode(0o664, &sym("g-w"), false), 0o644);
1577 }
1578 #[test]
1579 fn apply_mode_set_clears_other_bits() {
1580 assert_eq!(apply_mode(0o755, &sym("o="), false), 0o750);
1582 assert_eq!(apply_mode(0o000, &sym("u=rwx,go=rx"), false), 0o755);
1584 assert_eq!(apply_mode(0o1755, &sym("o="), false), 0o0750);
1586 }
1587 #[test]
1588 fn apply_mode_conditional_bigx() {
1589 assert_eq!(apply_mode(0o644, &sym("a+X"), false), 0o644);
1591 assert_eq!(apply_mode(0o744, &sym("a+X"), false), 0o755);
1593 assert_eq!(apply_mode(0o644, &sym("a+X"), true), 0o755);
1595 }
1596 #[test]
1597 fn apply_mode_setgid_and_sticky() {
1598 assert_eq!(apply_mode(0o750, &sym("g+rwxs"), true), 0o2770);
1599 assert_eq!(apply_mode(0o755, &sym("+t"), true), 0o1755);
1600 assert_eq!(apply_mode(0o755, &sym("u+s"), false), 0o4755);
1601 }
1602 #[test]
1603 fn apply_mode_sticky_only_responds_to_other() {
1604 assert_eq!(apply_mode(0o755, &sym("u+t"), false), 0o755);
1606 assert_eq!(apply_mode(0o755, &sym("g+t"), false), 0o755);
1607 assert_eq!(apply_mode(0o755, &sym("ug+t"), false), 0o755);
1608 assert_eq!(apply_mode(0o755, &sym("o+t"), false), 0o1755);
1609 assert_eq!(apply_mode(0o755, &sym("+t"), false), 0o1755);
1610 assert_eq!(apply_mode(0o1755, &sym("u-t"), false), 0o1755);
1611 assert_eq!(apply_mode(0o1755, &sym("o-t"), false), 0o755);
1612 }
1613 #[test]
1614 fn apply_mode_octal_is_absolute() {
1615 assert_eq!(apply_mode(0o4755, &sym("644"), false), 0o644);
1616 assert_eq!(apply_mode(0o000, &sym("2775"), true), 0o2775);
1617 }
1618 #[test]
1619 fn mode_dsl_bare_applies_to_file_and_dir_not_symlink() {
1620 let prog = parse_mode_dsl("g+rwX").unwrap();
1621 assert!(prog.file.is_some());
1622 assert!(prog.dir.is_some());
1623 assert!(prog.for_kind(EntryKind::Symlink).is_none());
1625 }
1626 #[test]
1627 fn mode_dsl_per_type() {
1628 let prog = parse_mode_dsl("f:g+rw d:g+rwxs").unwrap();
1629 assert_eq!(prog.file, Some(sym("g+rw")));
1630 assert_eq!(prog.dir, Some(sym("g+rwxs")));
1631 }
1632 #[test]
1633 fn mode_dsl_bare_plus_override() {
1634 let prog = parse_mode_dsl("g+r d:g+rwx").unwrap();
1635 assert_eq!(prog.file, Some(sym("g+r")));
1636 assert_eq!(prog.dir, Some(sym("g+rwx")));
1637 }
1638 #[test]
1639 fn mode_dsl_rejects_symlink_section() {
1640 assert!(parse_mode_dsl("l:g+w").is_err());
1641 }
1642 #[test]
1643 fn mode_dsl_rejects_multiple_bare() {
1644 assert!(parse_mode_dsl("g+r o+w").is_err());
1645 }
1646 #[test]
1647 fn mode_dsl_rejects_unknown_prefix() {
1648 assert!(parse_mode_dsl("z:644").is_err());
1649 }
1650 #[test]
1651 fn mode_dsl_single_type_leaves_other_none() {
1652 let prog_f = parse_mode_dsl("f:644").unwrap();
1653 assert!(prog_f.file.is_some());
1654 assert!(prog_f.dir.is_none());
1655 let prog_d = parse_mode_dsl("d:755").unwrap();
1656 assert!(prog_d.dir.is_some());
1657 assert!(prog_d.file.is_none());
1658 }
1659 fn settings_with(mode: &str, owner: Option<&str>, group: Option<&str>) -> Settings {
1660 Settings {
1661 mode: if mode.is_empty() {
1662 ModeProgram::default()
1663 } else {
1664 parse_mode_dsl(mode).unwrap()
1665 },
1666 owner: owner
1667 .map(|s| parse_owner_dsl(s, IdKind::User, &GetentResolver::default()).unwrap())
1668 .unwrap_or_default(),
1669 group: group
1670 .map(|s| parse_owner_dsl(s, IdKind::Group, &GetentResolver::default()).unwrap())
1671 .unwrap_or_default(),
1672 fail_early: false,
1673 defer_dir_changes: false,
1674 filter: None,
1675 time_filter: None,
1676 dry_run: None,
1677 }
1678 }
1679 #[test]
1680 fn plan_noop_when_already_correct() {
1681 let s = settings_with("g+r", None, None);
1682 let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1684 assert!(plan.is_noop());
1685 }
1686 #[test]
1687 fn plan_chmod_when_mode_differs() {
1688 let s = settings_with("g+w", None, None);
1689 let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1690 assert_eq!(plan.chmod, Some(0o664));
1691 assert!(plan.chown.is_none());
1692 }
1693 #[test]
1694 fn plan_chown_only_changed_ids() {
1695 let s = settings_with("", None, Some("2000"));
1696 let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1697 assert_eq!(plan.chown, Some((None, Some(2000))));
1699 assert!(plan.chmod.is_none());
1700 }
1701 #[test]
1702 fn plan_preserves_setgid_across_chgrp() {
1703 let s = settings_with("", None, Some("2000"));
1706 let plan = compute_plan(0o2755, 1000, 1000, EntryKind::File, &s);
1707 assert_eq!(plan.chown, Some((None, Some(2000))));
1708 assert_eq!(plan.chmod, Some(0o2755));
1709 }
1710 #[test]
1711 fn plan_symlink_never_chmods_but_chowns() {
1712 let s = settings_with("g+w", None, Some("2000"));
1713 let plan = compute_plan(0o777, 1000, 1000, EntryKind::Symlink, &s);
1714 assert!(plan.chmod.is_none());
1715 assert_eq!(plan.chown, Some((None, Some(2000))));
1716 }
1717 #[test]
1718 fn plan_preserves_setuid_when_mode_rule_noop_but_chown_runs() {
1719 let s = settings_with("g+r", Some("2000"), None);
1722 let plan = compute_plan(0o4755, 1000, 1000, EntryKind::File, &s);
1723 assert_eq!(plan.chown, Some((Some(2000), None)));
1724 assert_eq!(plan.chmod, Some(0o4755));
1725 }
1726 #[test]
1727 fn plan_preserves_setgid_dir_across_chgrp() {
1728 let s = settings_with("", None, Some("2000"));
1730 let plan = compute_plan(0o2770, 1000, 1000, EntryKind::Dir, &s);
1731 assert_eq!(plan.chown, Some((None, Some(2000))));
1732 assert_eq!(plan.chmod, Some(0o2770));
1733 }
1734
1735 static RACE_PROGRESS: std::sync::LazyLock<Progress> = std::sync::LazyLock::new(Progress::new);
1736
1737 fn spawn_file_symlink_swapper(
1743 dir: std::path::PathBuf,
1744 entry_name: &'static str,
1745 sentinel: std::path::PathBuf,
1746 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
1747 ) -> std::thread::JoinHandle<()> {
1748 use std::os::unix::fs::PermissionsExt;
1749 std::thread::spawn(move || {
1750 let entry = dir.join(entry_name);
1751 let staged_real = dir.join("__staged_real");
1752 let staged_link = dir.join("__staged_link");
1753 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
1754 let _ = std::fs::remove_file(&staged_real);
1756 if std::fs::write(&staged_real, b"REAL").is_err() {
1757 continue;
1758 }
1759 let _ =
1760 std::fs::set_permissions(&staged_real, std::fs::Permissions::from_mode(0o644));
1761 let _ = std::fs::rename(&staged_real, &entry);
1762 let _ = std::fs::remove_file(&staged_link);
1764 let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
1765 let _ = std::fs::rename(&staged_link, &entry);
1766 }
1767 })
1768 }
1769
1770 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1778 async fn entry_symlink_swap_never_changes_sentinel_mode() -> anyhow::Result<()> {
1779 use std::os::unix::fs::PermissionsExt;
1780 let tmp = crate::testutils::create_temp_dir().await?;
1781 let root = tmp.as_path();
1782 let sentinel = root.join("sentinel_secret");
1784 tokio::fs::write(&sentinel, b"SENTINEL").await?;
1785 std::fs::set_permissions(&sentinel, std::fs::Permissions::from_mode(0o600))?;
1786 let sentinel_uid_before =
1787 std::os::unix::fs::MetadataExt::uid(&std::fs::symlink_metadata(&sentinel)?);
1788 let target_dir = root.join("tree");
1790 tokio::fs::create_dir(&target_dir).await?;
1791 let entry_path = target_dir.join("entry");
1792 tokio::fs::write(&entry_path, b"REAL").await?;
1793 std::fs::set_permissions(&entry_path, std::fs::Permissions::from_mode(0o644))?;
1794
1795 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1796 let swapper =
1797 spawn_file_symlink_swapper(target_dir.clone(), "entry", sentinel.clone(), stop.clone());
1798
1799 let settings = settings_with("g+w", None, None);
1801 let mut caught = 0usize;
1802 let mut changed_real = 0usize;
1803 for i in 0..300 {
1804 let result = tokio::time::timeout(
1805 std::time::Duration::from_secs(30),
1806 chmod(&RACE_PROGRESS, &target_dir, &settings),
1807 )
1808 .await
1809 .expect("rchm must not hang under concurrent swapping");
1810 match result {
1811 Ok(summary) => changed_real += summary.files_changed,
1812 Err(_) => caught += 1, }
1814 let sentinel_md = std::fs::symlink_metadata(&sentinel)?;
1817 assert_eq!(
1818 sentinel_md.permissions().mode() & 0o7777,
1819 0o600,
1820 "iteration {i}: sentinel mode changed — rchm followed the symlink to chmod it"
1821 );
1822 assert_eq!(
1823 std::os::unix::fs::MetadataExt::uid(&sentinel_md),
1824 sentinel_uid_before,
1825 "iteration {i}: sentinel owner changed — rchm followed the symlink to chown it"
1826 );
1827 }
1828
1829 stop.store(true, std::sync::atomic::Ordering::Relaxed);
1830 swapper.join().expect("swapper thread panicked");
1831 tracing::info!("entry swap: caught={caught}, changed_real={changed_real}");
1833 assert!(
1834 caught + changed_real > 0,
1835 "expected at least one observable outcome across the iterations"
1836 );
1837 Ok(())
1838 }
1839
1840 mod max_open_files_tests {
1844 use super::*;
1845 use crate::walk_driver::process_entry;
1846
1847 static PROGRESS: std::sync::LazyLock<Progress> = std::sync::LazyLock::new(Progress::new);
1848
1849 #[tokio::test]
1863 async fn hinted_leaf_that_is_dir_drops_permit_before_recursion() -> anyhow::Result<()> {
1864 let root = crate::testutils::create_temp_dir().await?;
1865 let dir_path = root.join("d");
1867 tokio::fs::create_dir(&dir_path).await?;
1868 let child_path = dir_path.join("c");
1869 tokio::fs::write(&child_path, b"x").await?;
1870 std::fs::set_permissions(&child_path, std::fs::Permissions::from_mode(0o644))?;
1871 throttle::set_max_open_files(1);
1874 let parent = Dir::open_parent_dir(&root, congestion::Side::Destination)
1876 .await
1877 .context("open parent dir")?;
1878 let parent = Arc::new(parent.into_tree());
1879 let name = std::ffi::OsStr::new("d");
1880 let handle = parent.child(name).await.context("classify d")?;
1881 assert_eq!(
1882 handle.kind(),
1883 EntryKind::Dir,
1884 "fixture `d` must be a directory"
1885 );
1886 drop(handle);
1887 let visitor = Arc::new(ChmodVisitor {
1891 prog_track: &PROGRESS,
1892 settings: settings_with("g+w", None, None),
1893 });
1894 let cx = EntryCx {
1895 parent: Arc::clone(&parent),
1896 name: name.to_owned(),
1897 rel_path: PathBuf::new(),
1898 filter_path: PathBuf::new(),
1899 real_path: dir_path.clone(),
1900 dry_run: false,
1901 prog_track: &PROGRESS,
1902 };
1903 let permit = crate::walk::preacquire_leaf_permit(
1904 PermitKind::PendingMeta,
1905 Some(EntryKind::File),
1906 |_| true,
1907 )
1908 .await;
1909 assert!(permit.is_some(), "the pre-acquire must take the one permit");
1910 let result = tokio::time::timeout(
1911 std::time::Duration::from_secs(20),
1912 process_entry(visitor, cx, (), permit),
1913 )
1914 .await;
1915 throttle::set_max_open_files(0);
1919 let summary = result
1920 .context(
1921 "process_entry hung — leaf permit held across directory recursion (deadlock)",
1922 )?
1923 .map_err(|e| e.source)
1924 .context("process_entry failed")?;
1925 assert_eq!(
1927 summary.files_changed, 1,
1928 "child file should have its mode changed"
1929 );
1930 assert_eq!(
1931 summary.directories_changed, 1,
1932 "directory should have its mode changed"
1933 );
1934 assert_eq!(
1935 std::fs::symlink_metadata(&child_path)?.permissions().mode() & 0o777,
1936 0o664,
1937 "child file mode should be g+w applied"
1938 );
1939 Ok(())
1940 }
1941 }
1942}