1use crate::filter::TimeFilter;
6use crate::progress::Progress;
7use crate::walk::{self, EntryKind};
8use anyhow::{Context, anyhow};
9use async_recursion::async_recursion;
10use std::os::unix::fs::{MetadataExt, PermissionsExt};
11use tracing::instrument;
12
13pub type Error = crate::error::OperationError<Summary>;
15
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub struct OwnerProgram {
20 pub file: Option<u32>,
21 pub dir: Option<u32>,
22 pub symlink: Option<u32>,
23}
24
25impl OwnerProgram {
26 #[must_use]
27 pub fn for_kind(&self, kind: EntryKind) -> Option<u32> {
28 match kind {
29 EntryKind::Dir => self.dir,
30 EntryKind::Symlink => self.symlink,
31 EntryKind::File | EntryKind::Special => self.file,
32 }
33 }
34 #[must_use]
35 pub fn is_empty(&self) -> bool {
36 self.file.is_none() && self.dir.is_none() && self.symlink.is_none()
37 }
38}
39
40#[derive(Clone, Debug, PartialEq, Eq)]
43pub enum ModeSpec {
44 Symbolic(Vec<SymbolicClause>),
45 Octal(u32),
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub struct SymbolicClause {
52 pub who: u8,
53 pub op: ModeOp,
54 pub perms: u8,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum ModeOp {
60 Add,
61 Remove,
62 Set,
63}
64
65pub(crate) const WHO_U: u8 = 0b001;
66pub(crate) const WHO_G: u8 = 0b010;
67pub(crate) const WHO_O: u8 = 0b100;
68pub(crate) const WHO_A: u8 = WHO_U | WHO_G | WHO_O;
69pub(crate) const PERM_R: u8 = 0b00_0001;
70pub(crate) const PERM_W: u8 = 0b00_0010;
71pub(crate) const PERM_X: u8 = 0b00_0100;
72pub(crate) const PERM_BIGX: u8 = 0b00_1000;
73pub(crate) const PERM_S: u8 = 0b01_0000;
74pub(crate) const PERM_T: u8 = 0b10_0000;
75
76#[derive(Clone, Debug, Default, PartialEq, Eq)]
79pub struct ModeProgram {
80 pub file: Option<ModeSpec>,
81 pub dir: Option<ModeSpec>,
82}
83
84impl ModeProgram {
85 #[must_use]
86 pub fn for_kind(&self, kind: EntryKind) -> Option<&ModeSpec> {
87 match kind {
88 EntryKind::Dir => self.dir.as_ref(),
89 EntryKind::Symlink => None,
90 EntryKind::File | EntryKind::Special => self.file.as_ref(),
91 }
92 }
93 #[must_use]
94 pub fn is_empty(&self) -> bool {
95 self.file.is_none() && self.dir.is_none()
96 }
97}
98
99#[derive(Clone, Debug)]
101pub struct Settings {
102 pub mode: ModeProgram,
103 pub owner: OwnerProgram,
104 pub group: OwnerProgram,
105 pub fail_early: bool,
106 pub defer_dir_changes: bool,
110 pub filter: Option<crate::filter::FilterSettings>,
111 pub time_filter: Option<TimeFilter>,
112 pub dry_run: Option<crate::config::DryRunMode>,
113}
114
115#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
116pub struct Summary {
117 pub files_changed: usize,
118 pub symlinks_changed: usize,
119 pub directories_changed: usize,
120 pub files_unchanged: usize,
121 pub symlinks_unchanged: usize,
122 pub directories_unchanged: usize,
123 pub files_skipped: usize,
124 pub symlinks_skipped: usize,
125 pub directories_skipped: usize,
126}
127
128impl std::ops::Add for Summary {
129 type Output = Self;
130 fn add(self, other: Self) -> Self {
131 Self {
132 files_changed: self.files_changed + other.files_changed,
133 symlinks_changed: self.symlinks_changed + other.symlinks_changed,
134 directories_changed: self.directories_changed + other.directories_changed,
135 files_unchanged: self.files_unchanged + other.files_unchanged,
136 symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
137 directories_unchanged: self.directories_unchanged + other.directories_unchanged,
138 files_skipped: self.files_skipped + other.files_skipped,
139 symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
140 directories_skipped: self.directories_skipped + other.directories_skipped,
141 }
142 }
143}
144
145impl std::fmt::Display for Summary {
146 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
147 write!(
148 f,
149 "files changed: {}\n\
150 symlinks changed: {}\n\
151 directories changed: {}\n\
152 files unchanged: {}\n\
153 symlinks unchanged: {}\n\
154 directories unchanged: {}\n\
155 files skipped: {}\n\
156 symlinks skipped: {}\n\
157 directories skipped: {}\n",
158 self.files_changed,
159 self.symlinks_changed,
160 self.directories_changed,
161 self.files_unchanged,
162 self.symlinks_unchanged,
163 self.directories_unchanged,
164 self.files_skipped,
165 self.symlinks_skipped,
166 self.directories_skipped
167 )
168 }
169}
170
171#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub enum IdKind {
174 User,
175 Group,
176}
177
178fn resolve_id(token: &str, kind: IdKind) -> anyhow::Result<u32> {
181 if let Ok(n) = token.parse::<u32>() {
182 return Ok(n);
183 }
184 match kind {
185 IdKind::User => nix::unistd::User::from_name(token)
186 .with_context(|| format!("looking up user {token:?}"))?
187 .map(|u| u.uid.as_raw())
188 .ok_or_else(|| anyhow!("unknown user: {token}")),
189 IdKind::Group => nix::unistd::Group::from_name(token)
190 .with_context(|| format!("looking up group {token:?}"))?
191 .map(|g| g.gid.as_raw())
192 .ok_or_else(|| anyhow!("unknown group: {token}")),
193 }
194}
195
196pub fn parse_owner_dsl(s: &str, kind: IdKind) -> anyhow::Result<OwnerProgram> {
199 let mut prog = OwnerProgram::default();
200 let mut bare: Option<u32> = None;
201 for clause in s.split_whitespace() {
202 if let Some((ty, rest)) = clause.split_once(':') {
203 let id = resolve_id(rest, kind)?;
204 match ty {
205 "f" | "file" => prog.file = Some(id),
206 "d" | "dir" | "directory" => prog.dir = Some(id),
207 "l" | "link" | "symlink" => prog.symlink = Some(id),
208 _ => return Err(anyhow!("unknown type prefix {ty:?} (expected f:/d:/l:)")),
209 }
210 } else if bare.is_some() {
211 return Err(anyhow!(
212 "multiple bare values in {s:?}; use f:/d:/l: prefixes to set different types"
213 ));
214 } else {
215 bare = Some(resolve_id(clause, kind)?);
216 }
217 }
218 if let Some(b) = bare {
219 prog.file.get_or_insert(b);
220 prog.dir.get_or_insert(b);
221 prog.symlink.get_or_insert(b);
222 }
223 Ok(prog)
224}
225
226#[must_use]
229pub fn apply_mode(current: u32, spec: &ModeSpec, is_dir: bool) -> u32 {
230 match spec {
231 ModeSpec::Octal(m) => m & 0o7777,
232 ModeSpec::Symbolic(clauses) => {
233 let mut mode = current & 0o7777;
234 for clause in clauses {
235 mode = apply_clause(mode, *clause, is_dir);
236 }
237 mode
238 }
239 }
240}
241
242fn apply_clause(current: u32, clause: SymbolicClause, is_dir: bool) -> u32 {
243 let any_exec = current & 0o111 != 0;
244 let exec =
245 (clause.perms & PERM_X != 0) || (clause.perms & PERM_BIGX != 0 && (is_dir || any_exec));
246 let r = clause.perms & PERM_R != 0;
247 let w = clause.perms & PERM_W != 0;
248 let s = clause.perms & PERM_S != 0;
249 let t = clause.perms & PERM_T != 0;
250 let mut value: u32 = 0;
251 if clause.who & WHO_U != 0 {
252 if r {
253 value |= 0o400;
254 }
255 if w {
256 value |= 0o200;
257 }
258 if exec {
259 value |= 0o100;
260 }
261 if s {
262 value |= 0o4000;
263 }
264 }
265 if clause.who & WHO_G != 0 {
266 if r {
267 value |= 0o040;
268 }
269 if w {
270 value |= 0o020;
271 }
272 if exec {
273 value |= 0o010;
274 }
275 if s {
276 value |= 0o2000;
277 }
278 }
279 if clause.who & WHO_O != 0 {
280 if r {
281 value |= 0o004;
282 }
283 if w {
284 value |= 0o002;
285 }
286 if exec {
287 value |= 0o001;
288 }
289 }
290 if t && clause.who & WHO_O != 0 {
291 value |= 0o1000;
293 }
294 match clause.op {
295 ModeOp::Add => current | value,
296 ModeOp::Remove => current & !value,
297 ModeOp::Set => {
298 let mut clear: u32 = 0;
299 if clause.who & WHO_U != 0 {
300 clear |= 0o4700;
301 }
302 if clause.who & WHO_G != 0 {
303 clear |= 0o2070;
304 }
305 if clause.who & WHO_O != 0 {
306 clear |= 0o1007;
307 }
308 (current & !clear) | value
309 }
310 }
311}
312
313fn parse_mode_token(token: &str) -> anyhow::Result<ModeSpec> {
316 if token.is_empty() {
317 return Err(anyhow!("empty mode"));
318 }
319 if token.bytes().all(|b| b.is_ascii_digit()) {
320 if token.bytes().any(|b| b > b'7') {
321 return Err(anyhow!("invalid octal mode {token:?} (digits must be 0-7)"));
322 }
323 let value = u32::from_str_radix(token, 8)
324 .with_context(|| format!("parsing octal mode {token:?}"))?;
325 if value > 0o7777 {
326 return Err(anyhow!("octal mode {token:?} out of range (max 0o7777)"));
327 }
328 return Ok(ModeSpec::Octal(value));
329 }
330 let clauses = token
331 .split(',')
332 .map(parse_symbolic_clause)
333 .collect::<anyhow::Result<Vec<_>>>()?;
334 Ok(ModeSpec::Symbolic(clauses))
335}
336
337fn parse_symbolic_clause(clause: &str) -> anyhow::Result<SymbolicClause> {
338 let op_pos = clause
339 .find(['+', '-', '='])
340 .ok_or_else(|| anyhow!("mode clause {clause:?} missing +, - or ="))?;
341 let (who_str, rest) = clause.split_at(op_pos);
342 let op = match &rest[..1] {
343 "+" => ModeOp::Add,
344 "-" => ModeOp::Remove,
345 "=" => ModeOp::Set,
346 _ => unreachable!("find guaranteed one of +-="),
347 };
348 let perms_str = &rest[1..];
349 let mut who = 0u8;
350 for ch in who_str.chars() {
351 who |= match ch {
352 'u' => WHO_U,
353 'g' => WHO_G,
354 'o' => WHO_O,
355 'a' => WHO_A,
356 other => {
357 return Err(anyhow!(
358 "invalid 'who' {other:?} in {clause:?} (expected u/g/o/a)"
359 ));
360 }
361 };
362 }
363 if who == 0 {
364 who = WHO_A;
365 }
366 let mut perms = 0u8;
367 for ch in perms_str.chars() {
368 perms |= match ch {
369 'r' => PERM_R,
370 'w' => PERM_W,
371 'x' => PERM_X,
372 'X' => PERM_BIGX,
373 's' => PERM_S,
374 't' => PERM_T,
375 other => return Err(anyhow!("invalid permission {other:?} in {clause:?}")),
376 };
377 }
378 Ok(SymbolicClause { who, op, perms })
379}
380
381pub fn parse_mode_dsl(s: &str) -> anyhow::Result<ModeProgram> {
385 let mut prog = ModeProgram::default();
386 let mut bare: Option<ModeSpec> = None;
387 for clause in s.split_whitespace() {
388 if let Some((ty, rest)) = clause.split_once(':') {
389 let spec = parse_mode_token(rest)?;
390 match ty {
391 "f" | "file" => prog.file = Some(spec),
392 "d" | "dir" | "directory" => prog.dir = Some(spec),
393 "l" | "link" | "symlink" => {
394 return Err(anyhow!(
395 "symlink mode (l:) is not settable on Linux; remove the l: section"
396 ));
397 }
398 _ => return Err(anyhow!("unknown type prefix {ty:?} (expected f:/d:)")),
399 }
400 } else if bare.is_some() {
401 return Err(anyhow!(
402 "multiple bare mode expressions in {s:?}; chain sub-ops with commas (e.g. g+r,o+w)"
403 ));
404 } else {
405 bare = Some(parse_mode_token(clause)?);
406 }
407 }
408 if let Some(b) = bare {
409 prog.file.get_or_insert(b.clone());
410 prog.dir.get_or_insert(b);
411 }
412 Ok(prog)
413}
414
415#[derive(Clone, Copy, Debug, PartialEq, Eq)]
419pub(crate) struct EntryPlan {
420 pub chown: Option<(Option<u32>, Option<u32>)>,
421 pub chmod: Option<u32>,
422}
423
424impl EntryPlan {
425 pub(crate) fn is_noop(&self) -> bool {
426 self.chown.is_none() && self.chmod.is_none()
427 }
428}
429
430pub(crate) fn compute_plan(
433 cur_mode: u32,
434 cur_uid: u32,
435 cur_gid: u32,
436 kind: EntryKind,
437 settings: &Settings,
438) -> EntryPlan {
439 let cur_mode = cur_mode & 0o7777;
440 let uid_change = settings.owner.for_kind(kind).filter(|&u| u != cur_uid);
441 let gid_change = settings.group.for_kind(kind).filter(|&g| g != cur_gid);
442 let need_chown = uid_change.is_some() || gid_change.is_some();
443 let chown = need_chown.then_some((uid_change, gid_change));
444 let chmod = if kind == EntryKind::Symlink {
445 None
447 } else if let Some(spec) = settings.mode.for_kind(kind) {
448 let desired = apply_mode(cur_mode, spec, kind == EntryKind::Dir);
449 if desired != cur_mode || (need_chown && desired & 0o6000 != 0) {
452 Some(desired)
453 } else {
454 None
455 }
456 } else if need_chown && cur_mode & 0o6000 != 0 {
457 Some(cur_mode)
460 } else {
461 None
462 };
463 EntryPlan { chown, chmod }
464}
465
466fn inc_changed(prog: &Progress, kind: EntryKind) -> Summary {
467 match kind {
468 EntryKind::Dir => {
469 prog.directories_changed.inc();
470 Summary {
471 directories_changed: 1,
472 ..Default::default()
473 }
474 }
475 EntryKind::Symlink => {
476 prog.symlinks_changed.inc();
477 Summary {
478 symlinks_changed: 1,
479 ..Default::default()
480 }
481 }
482 EntryKind::File | EntryKind::Special => {
483 prog.files_changed.inc();
484 Summary {
485 files_changed: 1,
486 ..Default::default()
487 }
488 }
489 }
490}
491
492fn inc_unchanged(prog: &Progress, kind: EntryKind) -> Summary {
493 match kind {
494 EntryKind::Dir => {
495 prog.directories_unchanged.inc();
496 Summary {
497 directories_unchanged: 1,
498 ..Default::default()
499 }
500 }
501 EntryKind::Symlink => {
502 prog.symlinks_unchanged.inc();
503 Summary {
504 symlinks_unchanged: 1,
505 ..Default::default()
506 }
507 }
508 EntryKind::File | EntryKind::Special => {
509 prog.files_unchanged.inc();
510 Summary {
511 files_unchanged: 1,
512 ..Default::default()
513 }
514 }
515 }
516}
517
518fn skipped_summary_for(kind: EntryKind) -> Summary {
519 match kind {
520 EntryKind::Dir => Summary {
521 directories_skipped: 1,
522 ..Default::default()
523 },
524 EntryKind::Symlink => Summary {
525 symlinks_skipped: 1,
526 ..Default::default()
527 },
528 EntryKind::File | EntryKind::Special => Summary {
529 files_skipped: 1,
530 ..Default::default()
531 },
532 }
533}
534
535fn describe_change(cur_mode: u32, cur_uid: u32, cur_gid: u32, plan: &EntryPlan) -> String {
537 let mut parts = Vec::new();
538 if let Some(mode) = plan.chmod {
539 if mode == cur_mode & 0o7777 {
540 parts.push(format!("mode {mode:04o} (re-applied after chown)"));
542 } else {
543 parts.push(format!("mode {:04o}->{:04o}", cur_mode & 0o7777, mode));
544 }
545 }
546 if let Some((uid, gid)) = plan.chown {
547 if let Some(uid) = uid {
548 parts.push(format!("owner {cur_uid}->{uid}"));
549 }
550 if let Some(gid) = gid {
551 parts.push(format!("group {cur_gid}->{gid}"));
552 }
553 }
554 parts.join(", ")
555}
556
557async fn apply_plan(path: &std::path::Path, plan: &EntryPlan) -> anyhow::Result<()> {
558 if let Some((uid, gid)) = plan.chown {
559 let dst = path.to_owned();
560 walk::run_metadata_probed(
561 congestion::Side::Destination,
562 congestion::MetadataOp::Chmod,
563 async {
564 tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
565 nix::unistd::fchownat(
566 nix::fcntl::AT_FDCWD,
567 &dst,
568 uid.map(Into::into),
569 gid.map(Into::into),
570 nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW,
571 )
572 .with_context(|| format!("failed to chown {dst:?}"))?;
573 Ok(())
574 })
575 .await?
576 },
577 )
578 .await?;
579 }
580 if let Some(mode) = plan.chmod {
581 walk::run_metadata_probed(
584 congestion::Side::Destination,
585 congestion::MetadataOp::Chmod,
586 tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)),
587 )
588 .await
589 .with_context(|| format!("failed to chmod {path:?} to {mode:04o}"))?;
590 }
591 Ok(())
592}
593
594async fn process_entry(
597 prog: &'static Progress,
598 path: &std::path::Path,
599 metadata: &std::fs::Metadata,
600 kind: EntryKind,
601 settings: &Settings,
602) -> Result<Summary, Error> {
603 if let Some(ref time_filter) = settings.time_filter {
604 match time_filter.matches(metadata) {
605 Ok(result) => {
606 if let Some(reason) = result.as_skip_reason() {
607 if let Some(mode) = settings.dry_run {
608 crate::dry_run::report_time_skip(path, reason, mode, kind.label());
609 }
610 kind.inc_skipped(prog);
611 return Ok(skipped_summary_for(kind));
612 }
613 }
614 Err(err) => {
615 let err = err.context(format!("failed evaluating time filter on {path:?}"));
616 if settings.fail_early {
617 return Err(Error::new(err, Default::default()));
618 }
619 tracing::warn!("time filter failed for {:?}, skipping: {:#}", path, &err);
620 kind.inc_skipped(prog);
621 return Ok(skipped_summary_for(kind));
622 }
623 }
624 }
625 let plan = compute_plan(
626 metadata.mode(),
627 metadata.uid(),
628 metadata.gid(),
629 kind,
630 settings,
631 );
632 if plan.is_noop() {
633 if let Some(crate::config::DryRunMode::All) = settings.dry_run {
634 println!("unchanged {} {:?}", kind.label(), path);
635 }
636 return Ok(inc_unchanged(prog, kind));
637 }
638 if settings.dry_run.is_some() {
639 let desc = describe_change(metadata.mode(), metadata.uid(), metadata.gid(), &plan);
640 println!("would modify {} {:?}: {}", kind.label(), path, desc);
641 return Ok(inc_changed(prog, kind));
642 }
643 apply_plan(path, &plan)
644 .await
645 .map_err(|err| Error::new(err, Default::default()))?;
646 Ok(inc_changed(prog, kind))
647}
648
649fn without_trailing_separators(path: &std::path::Path) -> std::path::PathBuf {
656 use std::os::unix::ffi::OsStrExt;
657 let bytes = path.as_os_str().as_bytes();
658 let mut end = bytes.len();
659 while end > 1 && bytes[end - 1] == b'/' {
660 end -= 1;
661 }
662 std::path::PathBuf::from(std::ffi::OsStr::from_bytes(&bytes[..end]))
663}
664
665#[instrument(skip(prog_track, settings))]
668pub async fn chmod(
669 prog_track: &'static Progress,
670 path: &std::path::Path,
671 settings: &Settings,
672) -> Result<Summary, Error> {
673 let stripped = without_trailing_separators(path);
674 let path = stripped.as_path();
675 if let Some(ref filter) = settings.filter
676 && let Some(name) = path.file_name().map(std::path::Path::new)
677 {
678 let metadata = walk::run_metadata_probed(
679 congestion::Side::Source,
680 congestion::MetadataOp::Stat,
681 tokio::fs::symlink_metadata(path),
682 )
683 .await
684 .with_context(|| format!("failed reading metadata from {path:?}"))
685 .map_err(|err| Error::new(err, Default::default()))?;
686 match filter.should_include_root_item(name, metadata.is_dir()) {
687 crate::filter::FilterResult::Included => {}
688 result => {
689 let kind = EntryKind::from_metadata(&metadata);
690 if let Some(mode) = settings.dry_run {
691 crate::dry_run::report_skip(path, &result, mode, kind.label_long());
692 }
693 kind.inc_skipped(prog_track);
694 return Ok(skipped_summary_for(kind));
695 }
696 }
697 }
698 chmod_internal(prog_track, path, path, settings).await
699}
700
701async fn apply_dir_self(
705 prog_track: &'static Progress,
706 path: &std::path::Path,
707 metadata: &std::fs::Metadata,
708 traversed_only: bool,
709 settings: &Settings,
710) -> Result<Summary, Error> {
711 if traversed_only {
712 if let Some(crate::config::DryRunMode::All) = settings.dry_run {
713 println!("skip dir {path:?} (only traversed for include matches)");
714 }
715 prog_track.directories_skipped.inc();
716 return Ok(skipped_summary_for(EntryKind::Dir));
717 }
718 process_entry(prog_track, path, metadata, EntryKind::Dir, settings).await
719}
720
721#[instrument(skip(prog_track, settings))]
722#[async_recursion]
723async fn chmod_internal(
724 prog_track: &'static Progress,
725 path: &std::path::Path,
726 source_root: &std::path::Path,
727 settings: &Settings,
728) -> Result<Summary, Error> {
729 let _ops_guard = prog_track.ops.guard();
730 let metadata = walk::run_metadata_probed(
731 congestion::Side::Source,
732 congestion::MetadataOp::Stat,
733 tokio::fs::symlink_metadata(path),
734 )
735 .await
736 .with_context(|| format!("failed reading metadata from {path:?}"))
737 .map_err(|err| Error::new(err, Default::default()))?;
738 let kind = EntryKind::from_metadata(&metadata);
739 if kind != EntryKind::Dir {
740 return process_entry(prog_track, path, &metadata, kind, settings).await;
741 }
742 let relative_path = walk::relative_to_root(path, source_root);
746 let traversed_only = settings
747 .filter
748 .as_ref()
749 .is_some_and(|f| f.has_includes() && !f.directly_matches_include(relative_path, true));
750 let errors = crate::error_collector::ErrorCollector::default();
751 let mut summary = Summary::default();
752 if !settings.defer_dir_changes {
756 match apply_dir_self(prog_track, path, &metadata, traversed_only, settings).await {
757 Ok(dir_summary) => summary = summary + dir_summary,
758 Err(error) => {
759 if settings.fail_early {
760 return Err(Error::new(error.source, summary + error.summary));
761 }
762 tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
763 summary = summary + error.summary;
764 errors.push(error.source);
765 }
766 }
767 }
768 match tokio::fs::read_dir(path).await {
770 Ok(mut entries) => {
771 let mut join_set = tokio::task::JoinSet::new();
772 loop {
773 let (entry, entry_file_type) =
774 match walk::next_entry_probed(&mut entries, congestion::Side::Source, || {
775 format!("failed traversing directory {path:?}")
776 })
777 .await
778 {
779 Ok(Some(entry)) => entry,
780 Ok(None) => break,
781 Err(error) => {
782 if settings.fail_early {
783 return Err(Error::new(error, summary));
784 }
785 tracing::error!("chmod: {:#}", &error);
786 errors.push(error);
787 break;
788 }
789 };
790 let entry_path = entry.path();
791 let entry_kind = EntryKind::from_file_type(entry_file_type.as_ref());
792 let relative_path = walk::relative_to_root(&entry_path, source_root);
793 if let Some(skip_result) = walk::should_skip_entry(
794 &settings.filter,
795 relative_path,
796 entry_kind == EntryKind::Dir,
797 ) {
798 if let Some(mode) = settings.dry_run {
799 crate::dry_run::report_skip(
800 &entry_path,
801 &skip_result,
802 mode,
803 entry_kind.label(),
804 );
805 }
806 entry_kind.inc_skipped(prog_track);
807 summary = summary + skipped_summary_for(entry_kind);
808 continue;
809 }
810 let settings = settings.clone();
811 let source_root = source_root.to_owned();
812 let known_leaf = entry_file_type.as_ref().is_some_and(|ft| !ft.is_dir());
813 let pending_guard = if known_leaf {
814 Some(throttle::pending_meta_permit().await)
815 } else {
816 None
817 };
818 join_set.spawn(async move {
819 let _pending_guard = pending_guard;
820 chmod_internal(prog_track, &entry_path, &source_root, &settings).await
821 });
822 }
823 drop(entries);
824 while let Some(res) = join_set.join_next().await {
825 match res {
826 Ok(Ok(child)) => summary = summary + child,
827 Ok(Err(error)) => {
828 tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
829 summary = summary + error.summary;
830 errors.push(error.source);
831 if settings.fail_early {
832 break;
833 }
834 }
835 Err(error) => {
836 errors.push(error.into());
837 if settings.fail_early {
838 break;
839 }
840 }
841 }
842 }
843 }
844 Err(read_error) => {
845 let error = anyhow::Error::new(read_error)
849 .context(format!("failed reading directory {path:?}"));
850 if settings.fail_early {
851 return Err(Error::new(error, summary));
852 }
853 tracing::error!("chmod: {:#}", &error);
854 errors.push(error);
855 }
856 }
857 if settings.fail_early && errors.has_errors() {
861 return Err(Error::new(errors.into_error().unwrap(), summary));
862 }
863 if settings.defer_dir_changes {
867 match apply_dir_self(prog_track, path, &metadata, traversed_only, settings).await {
868 Ok(dir_summary) => summary = summary + dir_summary,
869 Err(error) => {
870 if settings.fail_early {
871 return Err(Error::new(error.source, summary + error.summary));
872 }
873 tracing::error!("chmod: {:?} failed with: {:#}", path, &error);
874 summary = summary + error.summary;
875 errors.push(error.source);
876 }
877 }
878 }
879 if errors.has_errors() {
880 return Err(Error::new(errors.into_error().unwrap(), summary));
881 }
882 Ok(summary)
883}
884
885#[cfg(test)]
886mod tests {
887 use super::*;
888 #[test]
889 fn mode_token_octal() {
890 assert_eq!(parse_mode_token("2775").unwrap(), ModeSpec::Octal(0o2775));
891 assert_eq!(parse_mode_token("0644").unwrap(), ModeSpec::Octal(0o644));
892 }
893 #[test]
894 fn mode_token_octal_out_of_range_errors() {
895 assert!(parse_mode_token("9999").is_err()); assert!(parse_mode_token("77777").is_err()); }
898 #[test]
899 fn mode_token_symbolic_simple() {
900 let spec = parse_mode_token("g+w").unwrap();
901 assert_eq!(
902 spec,
903 ModeSpec::Symbolic(vec![SymbolicClause {
904 who: WHO_G,
905 op: ModeOp::Add,
906 perms: PERM_W
907 }])
908 );
909 }
910 #[test]
911 fn mode_token_symbolic_omitted_who_means_all() {
912 let spec = parse_mode_token("+x").unwrap();
913 assert_eq!(
914 spec,
915 ModeSpec::Symbolic(vec![SymbolicClause {
916 who: WHO_A,
917 op: ModeOp::Add,
918 perms: PERM_X
919 }])
920 );
921 }
922 #[test]
923 fn mode_token_symbolic_comma_chained() {
924 let spec = parse_mode_token("u+rw,g-w").unwrap();
925 let ModeSpec::Symbolic(clauses) = spec else {
926 panic!("expected symbolic")
927 };
928 assert_eq!(clauses.len(), 2);
929 assert_eq!(
930 clauses[0],
931 SymbolicClause {
932 who: WHO_U,
933 op: ModeOp::Add,
934 perms: PERM_R | PERM_W
935 }
936 );
937 assert_eq!(
938 clauses[1],
939 SymbolicClause {
940 who: WHO_G,
941 op: ModeOp::Remove,
942 perms: PERM_W
943 }
944 );
945 }
946 #[test]
947 fn mode_token_symbolic_bigx_and_specials() {
948 let spec = parse_mode_token("g+rwXs").unwrap();
949 assert_eq!(
950 spec,
951 ModeSpec::Symbolic(vec![SymbolicClause {
952 who: WHO_G,
953 op: ModeOp::Add,
954 perms: PERM_R | PERM_W | PERM_BIGX | PERM_S,
955 }])
956 );
957 }
958 #[test]
959 fn mode_token_rejects_garbage() {
960 assert!(parse_mode_token("q+z").is_err());
961 assert!(parse_mode_token("g!w").is_err());
962 assert!(parse_mode_token("").is_err());
963 }
964 #[test]
965 fn summary_add_combines_fields() {
966 let a = Summary {
967 files_changed: 1,
968 directories_changed: 2,
969 files_unchanged: 3,
970 ..Default::default()
971 };
972 let b = Summary {
973 files_changed: 10,
974 symlinks_skipped: 4,
975 ..Default::default()
976 };
977 let sum = a + b;
978 assert_eq!(sum.files_changed, 11);
979 assert_eq!(sum.directories_changed, 2);
980 assert_eq!(sum.files_unchanged, 3);
981 assert_eq!(sum.symlinks_skipped, 4);
982 }
983 #[test]
984 fn owner_dsl_bare_applies_to_all_types() {
985 let prog = parse_owner_dsl("0", IdKind::User).unwrap();
986 assert_eq!(prog.file, Some(0));
987 assert_eq!(prog.dir, Some(0));
988 assert_eq!(prog.symlink, Some(0));
989 }
990 #[test]
991 fn owner_dsl_per_type_overrides() {
992 let prog = parse_owner_dsl("f:1 d:2", IdKind::User).unwrap();
993 assert_eq!(prog.file, Some(1));
994 assert_eq!(prog.dir, Some(2));
995 assert_eq!(prog.symlink, None);
996 }
997 #[test]
998 fn owner_dsl_bare_plus_override() {
999 let prog = parse_owner_dsl("5 d:2", IdKind::Group).unwrap();
1000 assert_eq!(prog.file, Some(5));
1001 assert_eq!(prog.dir, Some(2));
1002 assert_eq!(prog.symlink, Some(5));
1003 }
1004 #[test]
1005 fn owner_dsl_explicit_before_bare_is_order_independent() {
1006 let prog = parse_owner_dsl("f:1 5", IdKind::User).unwrap();
1007 assert_eq!(prog.file, Some(1));
1008 assert_eq!(prog.dir, Some(5));
1009 assert_eq!(prog.symlink, Some(5));
1010 }
1011 #[test]
1012 fn owner_dsl_rejects_multiple_bare() {
1013 assert!(parse_owner_dsl("1 2", IdKind::User).is_err());
1014 }
1015 #[test]
1016 fn owner_dsl_rejects_unknown_id() {
1017 assert!(parse_owner_dsl("definitely-no-such-group-xyz", IdKind::Group).is_err());
1018 }
1019 fn sym(s: &str) -> ModeSpec {
1020 parse_mode_token(s).unwrap()
1021 }
1022 #[test]
1023 fn apply_mode_group_add_remove() {
1024 assert_eq!(apply_mode(0o644, &sym("g+w"), false), 0o664);
1025 assert_eq!(apply_mode(0o664, &sym("g-w"), false), 0o644);
1026 }
1027 #[test]
1028 fn apply_mode_set_clears_other_bits() {
1029 assert_eq!(apply_mode(0o755, &sym("o="), false), 0o750);
1031 assert_eq!(apply_mode(0o000, &sym("u=rwx,go=rx"), false), 0o755);
1033 assert_eq!(apply_mode(0o1755, &sym("o="), false), 0o0750);
1035 }
1036 #[test]
1037 fn apply_mode_conditional_bigx() {
1038 assert_eq!(apply_mode(0o644, &sym("a+X"), false), 0o644);
1040 assert_eq!(apply_mode(0o744, &sym("a+X"), false), 0o755);
1042 assert_eq!(apply_mode(0o644, &sym("a+X"), true), 0o755);
1044 }
1045 #[test]
1046 fn apply_mode_setgid_and_sticky() {
1047 assert_eq!(apply_mode(0o750, &sym("g+rwxs"), true), 0o2770);
1048 assert_eq!(apply_mode(0o755, &sym("+t"), true), 0o1755);
1049 assert_eq!(apply_mode(0o755, &sym("u+s"), false), 0o4755);
1050 }
1051 #[test]
1052 fn apply_mode_sticky_only_responds_to_other() {
1053 assert_eq!(apply_mode(0o755, &sym("u+t"), false), 0o755);
1055 assert_eq!(apply_mode(0o755, &sym("g+t"), false), 0o755);
1056 assert_eq!(apply_mode(0o755, &sym("ug+t"), false), 0o755);
1057 assert_eq!(apply_mode(0o755, &sym("o+t"), false), 0o1755);
1058 assert_eq!(apply_mode(0o755, &sym("+t"), false), 0o1755);
1059 assert_eq!(apply_mode(0o1755, &sym("u-t"), false), 0o1755);
1060 assert_eq!(apply_mode(0o1755, &sym("o-t"), false), 0o755);
1061 }
1062 #[test]
1063 fn apply_mode_octal_is_absolute() {
1064 assert_eq!(apply_mode(0o4755, &sym("644"), false), 0o644);
1065 assert_eq!(apply_mode(0o000, &sym("2775"), true), 0o2775);
1066 }
1067 #[test]
1068 fn mode_dsl_bare_applies_to_file_and_dir_not_symlink() {
1069 let prog = parse_mode_dsl("g+rwX").unwrap();
1070 assert!(prog.file.is_some());
1071 assert!(prog.dir.is_some());
1072 assert!(prog.for_kind(EntryKind::Symlink).is_none());
1074 }
1075 #[test]
1076 fn mode_dsl_per_type() {
1077 let prog = parse_mode_dsl("f:g+rw d:g+rwxs").unwrap();
1078 assert_eq!(prog.file, Some(sym("g+rw")));
1079 assert_eq!(prog.dir, Some(sym("g+rwxs")));
1080 }
1081 #[test]
1082 fn mode_dsl_bare_plus_override() {
1083 let prog = parse_mode_dsl("g+r d:g+rwx").unwrap();
1084 assert_eq!(prog.file, Some(sym("g+r")));
1085 assert_eq!(prog.dir, Some(sym("g+rwx")));
1086 }
1087 #[test]
1088 fn mode_dsl_rejects_symlink_section() {
1089 assert!(parse_mode_dsl("l:g+w").is_err());
1090 }
1091 #[test]
1092 fn mode_dsl_rejects_multiple_bare() {
1093 assert!(parse_mode_dsl("g+r o+w").is_err());
1094 }
1095 #[test]
1096 fn mode_dsl_rejects_unknown_prefix() {
1097 assert!(parse_mode_dsl("z:644").is_err());
1098 }
1099 #[test]
1100 fn mode_dsl_single_type_leaves_other_none() {
1101 let prog_f = parse_mode_dsl("f:644").unwrap();
1102 assert!(prog_f.file.is_some());
1103 assert!(prog_f.dir.is_none());
1104 let prog_d = parse_mode_dsl("d:755").unwrap();
1105 assert!(prog_d.dir.is_some());
1106 assert!(prog_d.file.is_none());
1107 }
1108 fn settings_with(mode: &str, owner: Option<&str>, group: Option<&str>) -> Settings {
1109 Settings {
1110 mode: if mode.is_empty() {
1111 ModeProgram::default()
1112 } else {
1113 parse_mode_dsl(mode).unwrap()
1114 },
1115 owner: owner
1116 .map(|s| parse_owner_dsl(s, IdKind::User).unwrap())
1117 .unwrap_or_default(),
1118 group: group
1119 .map(|s| parse_owner_dsl(s, IdKind::Group).unwrap())
1120 .unwrap_or_default(),
1121 fail_early: false,
1122 defer_dir_changes: false,
1123 filter: None,
1124 time_filter: None,
1125 dry_run: None,
1126 }
1127 }
1128 #[test]
1129 fn plan_noop_when_already_correct() {
1130 let s = settings_with("g+r", None, None);
1131 let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1133 assert!(plan.is_noop());
1134 }
1135 #[test]
1136 fn plan_chmod_when_mode_differs() {
1137 let s = settings_with("g+w", None, None);
1138 let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1139 assert_eq!(plan.chmod, Some(0o664));
1140 assert!(plan.chown.is_none());
1141 }
1142 #[test]
1143 fn plan_chown_only_changed_ids() {
1144 let s = settings_with("", None, Some("2000"));
1145 let plan = compute_plan(0o644, 1000, 1000, EntryKind::File, &s);
1146 assert_eq!(plan.chown, Some((None, Some(2000))));
1148 assert!(plan.chmod.is_none());
1149 }
1150 #[test]
1151 fn plan_preserves_setgid_across_chgrp() {
1152 let s = settings_with("", None, Some("2000"));
1155 let plan = compute_plan(0o2755, 1000, 1000, EntryKind::File, &s);
1156 assert_eq!(plan.chown, Some((None, Some(2000))));
1157 assert_eq!(plan.chmod, Some(0o2755));
1158 }
1159 #[test]
1160 fn plan_symlink_never_chmods_but_chowns() {
1161 let s = settings_with("g+w", None, Some("2000"));
1162 let plan = compute_plan(0o777, 1000, 1000, EntryKind::Symlink, &s);
1163 assert!(plan.chmod.is_none());
1164 assert_eq!(plan.chown, Some((None, Some(2000))));
1165 }
1166 #[test]
1167 fn plan_preserves_setuid_when_mode_rule_noop_but_chown_runs() {
1168 let s = settings_with("g+r", Some("2000"), None);
1171 let plan = compute_plan(0o4755, 1000, 1000, EntryKind::File, &s);
1172 assert_eq!(plan.chown, Some((Some(2000), None)));
1173 assert_eq!(plan.chmod, Some(0o4755));
1174 }
1175 #[test]
1176 fn plan_preserves_setgid_dir_across_chgrp() {
1177 let s = settings_with("", None, Some("2000"));
1179 let plan = compute_plan(0o2770, 1000, 1000, EntryKind::Dir, &s);
1180 assert_eq!(plan.chown, Some((None, Some(2000))));
1181 assert_eq!(plan.chmod, Some(0o2770));
1182 }
1183}