1use anyhow::{Context, anyhow};
2use std::ffi::OsStr;
3use std::os::unix::fs::PermissionsExt;
4use std::path::PathBuf;
5use std::sync::Arc;
6use tracing::instrument;
7
8use crate::filter::TimeFilter;
9use crate::progress;
10use crate::safedir::{self, Dir, Handle};
11use crate::walk::{AdmittedEntry, AdmittedLeaf, EntryAdmission, EntryKind, LeafPermit, PermitKind};
12use crate::walk_driver::{
13 DirAction, DirPreResult, EntryCx, ProcessedChildren, WalkVisitor, process_admitted_entry,
14 process_entry,
15};
16
17pub type Error = crate::error::OperationError<Summary>;
20
21#[derive(Debug, Clone)]
22pub struct Settings {
23 pub fail_early: bool,
24 pub filter: Option<crate::filter::FilterSettings>,
26 pub time_filter: Option<TimeFilter>,
34 pub dry_run: Option<crate::config::DryRunMode>,
36}
37
38fn is_unsupported_io_error(err: &anyhow::Error) -> bool {
43 err.chain().any(|cause| {
44 cause
45 .downcast_ref::<std::io::Error>()
46 .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::Unsupported)
47 })
48}
49
50fn skipped_summary_for(kind: EntryKind) -> Summary {
54 match kind {
55 EntryKind::Dir => Summary {
56 directories_skipped: 1,
57 ..Default::default()
58 },
59 EntryKind::Symlink => Summary {
60 symlinks_skipped: 1,
61 ..Default::default()
62 },
63 EntryKind::File | EntryKind::Special => Summary {
64 files_skipped: 1,
65 ..Default::default()
66 },
67 }
68}
69
70#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
71pub struct Summary {
72 pub bytes_removed: u64,
73 pub files_removed: usize,
74 pub symlinks_removed: usize,
75 pub directories_removed: usize,
76 pub files_skipped: usize,
77 pub symlinks_skipped: usize,
78 pub directories_skipped: usize,
79}
80
81impl std::ops::Add for Summary {
82 type Output = Self;
83 fn add(self, other: Self) -> Self {
84 Self {
85 bytes_removed: self.bytes_removed + other.bytes_removed,
86 files_removed: self.files_removed + other.files_removed,
87 symlinks_removed: self.symlinks_removed + other.symlinks_removed,
88 directories_removed: self.directories_removed + other.directories_removed,
89 files_skipped: self.files_skipped + other.files_skipped,
90 symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
91 directories_skipped: self.directories_skipped + other.directories_skipped,
92 }
93 }
94}
95
96impl std::fmt::Display for Summary {
97 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
98 write!(
99 f,
100 "bytes removed: {}\n\
101 files removed: {}\n\
102 symlinks removed: {}\n\
103 directories removed: {}\n\
104 files skipped: {}\n\
105 symlinks skipped: {}\n\
106 directories skipped: {}\n",
107 bytesize::ByteSize(self.bytes_removed),
108 self.files_removed,
109 self.symlinks_removed,
110 self.directories_removed,
111 self.files_skipped,
112 self.symlinks_skipped,
113 self.directories_skipped
114 )
115 }
116}
117
118struct RelaxedDir {
125 handle: Handle,
126 original_mode: u32,
127}
128
129impl RelaxedDir {
130 fn prepare(handle: &Handle, original_mode: u32) -> std::io::Result<Self> {
132 Ok(Self {
133 handle: handle.try_clone()?,
134 original_mode,
135 })
136 }
137
138 async fn restore(self) -> std::io::Result<()> {
140 safedir::chmod_via_proc_fd(
141 &self.handle,
142 congestion::Side::Destination,
143 self.original_mode,
144 )
145 .await
146 }
147}
148
149async fn restore_retained_dir(
150 relaxed: Option<RelaxedDir>,
151 path: &std::path::Path,
152) -> anyhow::Result<()> {
153 if let Some(relaxed) = relaxed {
154 relaxed
155 .restore()
156 .await
157 .with_context(|| format!("failed to restore filter-retained directory {path:?}"))?;
158 }
159 Ok(())
160}
161
162#[instrument(skip(prog_track, settings))]
174pub async fn rm(
175 prog_track: &'static progress::Progress,
176 path: &std::path::Path,
177 settings: &Settings,
178) -> Result<Summary, Error> {
179 let permit = crate::walk::ensure_leaf_permit(PermitKind::PendingMeta, None).await;
183 enum RmRootSetup {
184 Complete(Summary),
185 Ready {
186 operand: crate::walk::RootOperand,
187 parent: Arc<Dir>,
188 entry: AdmittedEntry,
189 },
190 }
191 let setup = safedir::with_optional_fd_admission(
192 permit.as_ref().map(LeafPermit::admission),
193 async move {
194 let operand = crate::walk::split_root_operand(path)
201 .await
202 .map_err(|err| Error::new(err, Default::default()))?;
203 let parent_path = operand.parent.as_path();
204 let name = operand.name.as_os_str();
205 let path = operand.display.as_path();
206 let parent = Dir::open_parent_dir(parent_path, congestion::Side::Source)
212 .await
213 .with_context(|| format!("cannot open parent directory {parent_path:?}"))
214 .map_err(|err| Error::new(err, Default::default()))?;
215 let parent = Arc::new(parent.into_tree());
217 let entry = crate::walk::classify_admitted_entry(&parent, name, permit)
220 .await
221 .with_context(|| format!("failed reading metadata from {path:?}"))
222 .map_err(|err| Error::new(err, Default::default()))?;
223 if let Some(ref filter) = settings.filter {
224 let name_path = std::path::Path::new(name);
225 let result =
226 filter.should_include_root_item(name_path, entry.kind() == EntryKind::Dir);
227 match result {
228 crate::filter::FilterResult::Included => {}
229 result => {
230 let kind = entry.kind();
231 if let Some(mode) = settings.dry_run {
232 crate::dry_run::report_skip(path, &result, mode, kind.label_long());
233 }
234 kind.inc_skipped(prog_track);
235 return Ok(RmRootSetup::Complete(skipped_summary_for(kind)));
236 }
237 }
238 }
239 Ok(RmRootSetup::Ready {
240 operand,
241 parent,
242 entry,
243 })
244 },
245 )
246 .await?;
247 let (operand, parent, entry) = match setup {
248 RmRootSetup::Complete(summary) => return Ok(summary),
249 RmRootSetup::Ready {
250 operand,
251 parent,
252 entry,
253 } => (operand, parent, entry),
254 };
255 let name = operand.name.as_os_str();
256 let path = operand.display.as_path();
257 let visitor = Arc::new(RmVisitor {
262 prog_track,
263 settings: settings.clone(),
264 });
265 let root_cx = EntryCx {
266 parent: Arc::clone(&parent),
267 name: name.to_owned(),
268 rel_path: PathBuf::new(),
269 filter_path: PathBuf::new(),
270 real_path: path.to_path_buf(),
271 dry_run: settings.dry_run.is_some(),
272 prog_track,
273 };
274 process_admitted_entry(visitor, root_cx, (), entry).await }
276
277pub async fn rm_child(
301 prog_track: &'static progress::Progress,
302 parent: &Arc<Dir>,
303 name: &OsStr,
304 rel_path: &std::path::Path,
305 settings: &Settings,
306) -> Result<Summary, Error> {
307 rm_child_admitted(
308 prog_track,
309 parent,
310 name,
311 rel_path,
312 settings,
313 EntryAdmission::RootOrDelegated,
314 )
315 .await
316}
317
318pub(crate) async fn rm_child_admitted(
324 prog_track: &'static progress::Progress,
325 parent: &Arc<Dir>,
326 name: &OsStr,
327 rel_path: &std::path::Path,
328 settings: &Settings,
329 admission: EntryAdmission,
330) -> Result<Summary, Error> {
331 rm_child_with_source(
332 prog_track,
333 parent,
334 name,
335 rel_path,
336 settings,
337 RmChildSource::Unclassified(admission),
338 )
339 .await
340}
341
342pub(crate) async fn rm_child_admitted_entry(
344 prog_track: &'static progress::Progress,
345 parent: &Arc<Dir>,
346 name: &OsStr,
347 rel_path: &std::path::Path,
348 settings: &Settings,
349 entry: AdmittedEntry,
350) -> Result<Summary, Error> {
351 rm_child_with_source(
352 prog_track,
353 parent,
354 name,
355 rel_path,
356 settings,
357 RmChildSource::Admitted(entry),
358 )
359 .await
360}
361
362enum RmChildSource {
363 Unclassified(EntryAdmission),
364 Admitted(AdmittedEntry),
365}
366
367#[allow(clippy::too_many_arguments)]
368async fn rm_child_with_source(
369 prog_track: &'static progress::Progress,
370 parent: &Arc<Dir>,
371 name: &OsStr,
372 rel_path: &std::path::Path,
373 settings: &Settings,
374 source: RmChildSource,
375) -> Result<Summary, Error> {
376 let visitor = Arc::new(RmVisitor {
383 prog_track,
384 settings: settings.clone(),
385 });
386 let root_cx = EntryCx {
387 parent: Arc::clone(parent),
388 name: name.to_owned(),
389 rel_path: rel_path.to_path_buf(),
390 filter_path: rel_path.to_path_buf(),
391 real_path: rel_path.to_path_buf(),
392 dry_run: settings.dry_run.is_some(),
393 prog_track,
394 };
395 match source {
396 RmChildSource::Unclassified(admission) => {
397 process_entry(visitor, root_cx, (), admission).await
398 }
399 RmChildSource::Admitted(entry) => process_admitted_entry(visitor, root_cx, (), entry).await,
400 }
401}
402struct RmVisitor {
408 prog_track: &'static progress::Progress,
409 settings: Settings,
410}
411
412struct RmDirState {
415 relaxed: Option<RelaxedDir>,
418 time_metadata: Option<anyhow::Result<std::fs::Metadata>>,
422 matches_no_include: bool,
427}
428
429impl WalkVisitor for RmVisitor {
430 type Summary = Summary;
431 type DirContext = ();
432 type DirState = RmDirState;
433
434 fn root_dir_context(&self) {}
435
436 fn permit_kind(&self) -> PermitKind {
437 PermitKind::PendingMeta
441 }
442
443 fn fail_early(&self) -> bool {
444 self.settings.fail_early
445 }
446
447 fn filter(&self) -> Option<&crate::filter::FilterSettings> {
448 self.settings.filter.as_ref()
449 }
450
451 fn filter_requires_admitted_entry(&self) -> bool {
452 true
453 }
454
455 fn filter_allows_hint_only_skip(&self, _dir_ctx: &Self::DirContext) -> bool {
456 self.settings.dry_run.is_none()
459 }
460
461 fn on_skip(
462 &self,
463 cx: &EntryCx,
464 kind: EntryKind,
465 skip_result: &crate::filter::FilterResult,
466 ) -> Summary {
467 if let Some(mode) = self.settings.dry_run {
470 crate::dry_run::report_skip(&cx.real_path, skip_result, mode, kind.label());
471 }
472 skipped_summary_for(kind)
473 }
474
475 async fn visit_leaf(
476 &self,
477 cx: &EntryCx,
478 _parent_ctx: &(),
479 leaf: AdmittedLeaf,
480 ) -> Result<Summary, Error> {
481 let kind = leaf.kind();
482 let prog_track = self.prog_track;
483 let settings = &self.settings;
484 let parent = &cx.parent;
485 let name = cx.name.as_os_str();
486 let path = cx.real_path.as_path();
487 tracing::debug!("not a directory, just remove");
488 let is_symlink = kind == EntryKind::Symlink;
489 let file_size = if is_symlink {
490 0
491 } else {
492 crate::preserve::Metadata::size(leaf.handle().meta())
493 };
494 if let Some(ref time_filter) = settings.time_filter {
496 let entry_type = if is_symlink { "symlink" } else { "file" };
497 let make_skipped_summary = || {
498 tracing::debug!("skipping {:?} due to time filter", &path);
499 if is_symlink {
500 prog_track.symlinks_skipped.inc();
501 Summary {
502 symlinks_skipped: 1,
503 ..Default::default()
504 }
505 } else {
506 prog_track.files_skipped.inc();
507 Summary {
508 files_skipped: 1,
509 ..Default::default()
510 }
511 }
512 };
513 let metadata =
517 match safedir::stat_meta_via_proc_fd(leaf.handle(), congestion::Side::Source).await
518 {
519 Ok(md) => md,
520 Err(err) => {
521 let err = anyhow::Error::new(err).context(format!(
522 "failed reading metadata for time filter on {path:?}"
523 ));
524 if settings.fail_early {
525 return Err(Error::new(err, Default::default()));
526 }
527 if is_unsupported_io_error(&err) {
529 tracing::warn!(
530 "time filter evaluation unsupported for {} {:?}, skipping: {:#}",
531 entry_type,
532 &path,
533 &err
534 );
535 } else {
536 tracing::error!(
537 "time filter evaluation failed for {} {:?}, skipping: {:#}",
538 entry_type,
539 &path,
540 &err
541 );
542 }
543 return Ok(make_skipped_summary());
544 }
545 };
546 match time_filter.matches(&metadata) {
547 Ok(result) => {
548 if let Some(skip_reason) = result.as_skip_reason() {
549 if let Some(mode) = settings.dry_run {
550 crate::dry_run::report_time_skip(path, skip_reason, mode, entry_type);
551 }
552 return Ok(make_skipped_summary());
553 }
554 }
555 Err(err) => {
556 let err = err.context(format!("failed evaluating time filter on {:?}", &path));
557 if settings.fail_early {
558 return Err(Error::new(err, Default::default()));
559 }
560 if is_unsupported_io_error(&err) {
564 tracing::warn!(
565 "time filter evaluation unsupported for {} {:?}, skipping: {:#}",
566 entry_type,
567 &path,
568 &err
569 );
570 } else {
571 tracing::error!(
572 "time filter evaluation failed for {} {:?}, skipping: {:#}",
573 entry_type,
574 &path,
575 &err
576 );
577 }
578 return Ok(make_skipped_summary());
579 }
580 }
581 }
582 if settings.dry_run.is_some() {
584 let entry_type = if is_symlink { "symlink" } else { "file" };
585 crate::dry_run::report_action("remove", path, None, entry_type);
586 return Ok(Summary {
587 bytes_removed: file_size,
588 files_removed: if is_symlink { 0 } else { 1 },
589 symlinks_removed: if is_symlink { 1 } else { 0 },
590 ..Default::default()
591 });
592 }
593 if let Err(err) = parent
604 .unlink_at_on(name, congestion::Side::Destination)
605 .await
606 .with_context(|| format!("failed removing {:?}", &path))
607 {
608 return Err(Error::new(err, Default::default()));
609 }
610 if is_symlink {
611 prog_track.symlinks_removed.inc();
612 return Ok(Summary {
613 symlinks_removed: 1,
614 ..Default::default()
615 });
616 }
617 prog_track.files_removed.inc();
618 prog_track.bytes_removed.add(file_size);
619 Ok(Summary {
620 bytes_removed: file_size,
621 files_removed: 1,
622 ..Default::default()
623 })
624 }
625
626 async fn dir_pre(&self, cx: &EntryCx, _parent_ctx: &(), handle: &Handle) -> DirPreResult<Self> {
627 let settings = &self.settings;
628 let path = cx.real_path.as_path();
629 tracing::debug!("remove contents of the directory first");
630 let time_metadata: Option<anyhow::Result<std::fs::Metadata>> =
639 if settings.time_filter.is_some() {
640 Some(
641 safedir::stat_meta_via_proc_fd(handle, congestion::Side::Source)
642 .await
643 .map_err(|err| {
644 anyhow::Error::new(err).context(format!(
645 "failed reading metadata for time filter on {path:?}"
646 ))
647 }),
648 )
649 } else {
650 None
651 };
652 let matches_no_include = settings.filter.as_ref().is_some_and(|f| {
658 f.has_includes() && !f.directly_matches_include(&cx.filter_path, true)
659 });
660 let mut relaxed: Option<RelaxedDir> = None;
667 if settings.dry_run.is_none() {
668 let original_mode =
669 crate::preserve::Metadata::permissions(handle.meta()).mode() & 0o7777;
670 if original_mode & 0o700 != 0o700 {
675 tracing::debug!("directory is not writable/traversable - relax the permissions");
676 let prepared = RelaxedDir::prepare(handle, original_mode)
679 .with_context(|| format!("failed to pin relaxed directory {path:?}"))
680 .map_err(|err| Error::new(err, Default::default()))?;
681 safedir::chmod_via_proc_fd(handle, congestion::Side::Destination, 0o700)
684 .await
685 .with_context(|| {
686 format!("failed to make {path:?} directory readable and writable")
687 })
688 .map_err(|err| Error::new(err, Default::default()))?;
689 relaxed = Some(prepared);
690 }
691 }
692 let dir = cx
696 .parent
697 .open_dir(&cx.name)
698 .await
699 .with_context(|| format!("failed reading directory {path:?}"))
700 .map_err(|err| Error::new(err, Default::default()))?;
701 dir.verify_same_inode(handle)
702 .await
703 .with_context(|| {
704 format!("directory {path:?} changed identity before descent (possible TOCTOU swap)")
705 })
706 .map_err(|err| Error::new(err, Default::default()))?;
707 let dir = Arc::new(dir);
708 Ok(DirAction::Descend {
709 dir,
710 child_ctx: (),
711 state: RmDirState {
712 relaxed,
713 time_metadata,
714 matches_no_include,
715 },
716 })
717 }
718
719 async fn dir_post(
720 &self,
721 cx: &EntryCx,
722 state: RmDirState,
723 _processed: &ProcessedChildren,
724 child_result: Result<Summary, Error>,
725 ) -> Result<Summary, Error> {
726 let prog_track = self.prog_track;
727 let settings = &self.settings;
728 let path = cx.real_path.as_path();
729 let RmDirState {
730 relaxed,
731 time_metadata,
732 matches_no_include,
733 } = state;
734 let mut rm_summary = match child_result {
737 Ok(summary) => summary,
738 Err(err) => return Err(err),
739 };
740 tracing::debug!("finally remove the empty directory");
741 let anything_removed = rm_summary.files_removed > 0
742 || rm_summary.symlinks_removed > 0
743 || rm_summary.directories_removed > 0;
744 let anything_skipped = rm_summary.files_skipped > 0
745 || rm_summary.symlinks_skipped > 0
746 || rm_summary.directories_skipped > 0;
747 let traversed_only = !anything_removed && matches_no_include;
750 let dir_passes_time_filter: bool = if let Some(ref time_filter) = settings.time_filter {
757 let matched = time_metadata.unwrap().and_then(|md| {
760 time_filter.matches(&md).map_err(|err| {
761 err.context(format!("failed evaluating time filter on {path:?}"))
762 })
763 });
764 match matched {
765 Ok(result) => match result.as_skip_reason() {
766 Some(reason) => {
767 if let Some(mode) = settings.dry_run {
768 crate::dry_run::report_time_skip(path, reason, mode, "dir");
769 }
770 false
771 }
772 None => true,
773 },
774 Err(err) => {
775 if settings.fail_early {
776 return Err(Error::new(err, rm_summary));
777 }
778 if is_unsupported_io_error(&err) {
782 tracing::warn!(
783 "time filter evaluation unsupported for dir {:?}, leaving it intact: {:#}",
784 &path,
785 &err
786 );
787 } else {
788 tracing::error!(
789 "time filter evaluation failed for dir {:?}, leaving it intact: {:#}",
790 &path,
791 &err
792 );
793 }
794 false
795 }
796 }
797 } else {
798 true
799 };
800 if settings.dry_run.is_some() {
807 if traversed_only || anything_skipped || !dir_passes_time_filter {
808 tracing::debug!(
809 "dry-run: directory {:?} would not be removed (removed={}, skipped={}, time_ok={})",
810 &path,
811 anything_removed,
812 anything_skipped,
813 dir_passes_time_filter
814 );
815 if !dir_passes_time_filter {
816 prog_track.directories_skipped.inc();
817 rm_summary.directories_skipped += 1;
818 }
819 } else {
820 crate::dry_run::report_action("remove", path, None, "dir");
821 rm_summary.directories_removed += 1;
822 }
823 return Ok(rm_summary);
824 }
825 if traversed_only {
829 tracing::debug!(
830 "directory {:?} had nothing removed, leaving it intact",
831 &path
832 );
833 restore_retained_dir(relaxed, path)
834 .await
835 .map_err(|err| Error::new(err, rm_summary))?;
836 return Ok(rm_summary);
837 }
838 if !dir_passes_time_filter {
841 tracing::debug!(
842 "directory {:?} skipped by time filter, leaving it intact",
843 &path
844 );
845 prog_track.directories_skipped.inc();
846 rm_summary.directories_skipped += 1;
847 restore_retained_dir(relaxed, path)
848 .await
849 .map_err(|err| Error::new(err, rm_summary))?;
850 return Ok(rm_summary);
851 }
852 #[cfg(test)]
859 let _final_rmdir_gate_visit = {
860 crate::testutils::wait_on_blocking_path_gate(path, libc::AT_FDCWD)
863 };
864 let any_filter_active = settings.filter.is_some() || settings.time_filter.is_some();
865 match cx
866 .parent
867 .rmdir_at_on(&cx.name, congestion::Side::Destination)
868 .await
869 {
870 Ok(()) => {
871 prog_track.directories_removed.inc();
872 rm_summary.directories_removed += 1;
873 }
874 Err(err) if any_filter_active => {
875 if err.kind() == std::io::ErrorKind::DirectoryNotEmpty
879 || err.raw_os_error() == Some(39)
880 {
881 tracing::info!(
882 "directory {:?} not empty after filtering, leaving it intact",
883 &path
884 );
885 restore_retained_dir(relaxed, path)
886 .await
887 .map_err(|restore_err| Error::new(restore_err, rm_summary))?;
888 } else {
889 return Err(Error::new(
890 anyhow!(err).context(format!("failed removing directory {:?}", &path)),
891 rm_summary,
892 ));
893 }
894 }
895 Err(err) => {
896 return Err(Error::new(
897 anyhow!(err).context(format!("failed removing directory {:?}", &path)),
898 rm_summary,
899 ));
900 }
901 }
902 Ok(rm_summary)
903 }
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909 use crate::config::DryRunMode;
910 use crate::testutils;
911 use tracing_test::traced_test;
912
913 static PROGRESS: std::sync::LazyLock<progress::Progress> =
914 std::sync::LazyLock::new(progress::Progress::new);
915
916 #[tokio::test]
917 #[traced_test]
918 async fn no_write_permission() -> Result<(), anyhow::Error> {
919 let tmp_dir = testutils::setup_test_dir().await?;
920 let test_path = tmp_dir.as_path();
921 let filepaths = vec![
922 test_path.join("foo").join("0.txt"),
923 test_path.join("foo").join("bar").join("2.txt"),
924 test_path.join("foo").join("baz").join("4.txt"),
925 test_path.join("foo").join("baz"),
926 ];
927 for fpath in &filepaths {
928 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o555)).await?;
930 }
931 let summary = rm(
932 &PROGRESS,
933 &test_path.join("foo"),
934 &Settings {
935 fail_early: false,
936 filter: None,
937 dry_run: None,
938 time_filter: None,
939 },
940 )
941 .await?;
942 assert!(!test_path.join("foo").exists());
943 assert_eq!(summary.files_removed, 5);
944 assert_eq!(summary.symlinks_removed, 2);
945 assert_eq!(summary.directories_removed, 3);
946 Ok(())
947 }
948
949 #[tokio::test]
950 #[traced_test]
951 async fn removal_error_leaves_temporary_directory_mode() -> Result<(), anyhow::Error> {
952 let tmp = tempfile::tempdir()?;
961 let parent = tmp.path().join("parent");
962 let dst = parent.join("dst");
963 tokio::fs::create_dir(&parent).await?;
964 tokio::fs::create_dir(&dst).await?;
965 tokio::fs::write(dst.join("inside.txt"), b"x").await?;
966 tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o555)).await?;
968 tokio::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).await?;
970
971 let result = rm(
972 &PROGRESS,
973 &dst,
974 &Settings {
975 fail_early: false,
976 filter: None,
977 dry_run: None,
978 time_filter: None,
979 },
980 )
981 .await;
982
983 tokio::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).await?;
985
986 assert!(
987 result.is_err(),
988 "rm must fail when its dst can be emptied but the parent dir blocks remove_dir"
989 );
990 let mode = tokio::fs::metadata(&dst).await?.permissions().mode() & 0o7777;
991 assert_eq!(
992 mode, 0o700,
993 "a removal error unexpectedly restored the temporary directory mode (got {mode:o}o)"
994 );
995
996 tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o755)).await?;
998 Ok(())
999 }
1000
1001 #[tokio::test]
1002 #[traced_test]
1003 async fn parent_dir_no_write_permission() -> Result<(), anyhow::Error> {
1004 let tmp_dir = testutils::setup_test_dir().await?;
1005 let test_path = tmp_dir.as_path();
1006 tokio::fs::set_permissions(
1008 &test_path.join("foo").join("bar"),
1009 std::fs::Permissions::from_mode(0o555),
1010 )
1011 .await?;
1012 let result = rm(
1013 &PROGRESS,
1014 &test_path.join("foo").join("bar").join("2.txt"),
1015 &Settings {
1016 fail_early: true,
1017 filter: None,
1018 dry_run: None,
1019 time_filter: None,
1020 },
1021 )
1022 .await;
1023 assert!(result.is_err());
1025 let err = result.unwrap_err();
1026 let err_string = format!("{:#}", err);
1027 assert!(
1029 err_string.contains("Permission denied") || err_string.contains("permission denied"),
1030 "Error should contain 'Permission denied' but got: {}",
1031 err_string
1032 );
1033 Ok(())
1034 }
1035
1036 mod filter_tests {
1037 use super::*;
1038 use crate::filter::FilterSettings;
1039 #[tokio::test]
1041 #[traced_test]
1042 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
1043 let tmp_dir = testutils::setup_test_dir().await?;
1044 let test_path = tmp_dir.as_path();
1045 let mut filter = FilterSettings::new();
1047 filter.add_include("bar/*.txt").unwrap();
1048 let summary = rm(
1049 &PROGRESS,
1050 &test_path.join("foo"),
1051 &Settings {
1052 fail_early: false,
1053 filter: Some(filter),
1054 dry_run: None,
1055 time_filter: None,
1056 },
1057 )
1058 .await?;
1059 assert_eq!(
1061 summary.files_removed, 3,
1062 "should remove 3 files matching bar/*.txt"
1063 );
1064 assert_eq!(summary.bytes_removed, 3, "should report 3 bytes removed");
1066 assert!(
1068 !test_path.join("foo/bar/1.txt").exists(),
1069 "bar/1.txt should be removed"
1070 );
1071 assert!(
1072 !test_path.join("foo/bar/2.txt").exists(),
1073 "bar/2.txt should be removed"
1074 );
1075 assert!(
1076 !test_path.join("foo/bar/3.txt").exists(),
1077 "bar/3.txt should be removed"
1078 );
1079 assert!(
1081 test_path.join("foo/0.txt").exists(),
1082 "0.txt should still exist"
1083 );
1084 Ok(())
1085 }
1086 #[tokio::test]
1088 #[traced_test]
1089 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
1090 let tmp_dir = testutils::setup_test_dir().await?;
1091 let test_path = tmp_dir.as_path();
1092 let mut filter = FilterSettings::new();
1094 filter.add_exclude("*.txt").unwrap();
1095 let summary = rm(
1096 &PROGRESS,
1097 &test_path.join("foo/0.txt"), &Settings {
1099 fail_early: false,
1100 filter: Some(filter),
1101 dry_run: None,
1102 time_filter: None,
1103 },
1104 )
1105 .await?;
1106 assert_eq!(
1108 summary.files_removed, 0,
1109 "file matching exclude pattern should not be removed"
1110 );
1111 assert!(
1112 test_path.join("foo/0.txt").exists(),
1113 "excluded file should still exist"
1114 );
1115 Ok(())
1116 }
1117 #[tokio::test]
1119 #[traced_test]
1120 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
1121 let test_path = testutils::create_temp_dir().await?;
1122 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
1124 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
1125 let mut filter = FilterSettings::new();
1127 filter.add_exclude("*_dir/").unwrap();
1128 let result = rm(
1129 &PROGRESS,
1130 &test_path.join("excluded_dir"),
1131 &Settings {
1132 fail_early: false,
1133 filter: Some(filter),
1134 dry_run: None,
1135 time_filter: None,
1136 },
1137 )
1138 .await?;
1139 assert_eq!(
1141 result.directories_removed, 0,
1142 "root directory matching exclude should not be removed"
1143 );
1144 assert!(
1145 test_path.join("excluded_dir").exists(),
1146 "excluded root directory should still exist"
1147 );
1148 Ok(())
1149 }
1150 #[tokio::test]
1152 #[traced_test]
1153 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
1154 let test_path = testutils::create_temp_dir().await?;
1155 tokio::fs::write(test_path.join("target.txt"), "content").await?;
1157 tokio::fs::symlink(
1158 test_path.join("target.txt"),
1159 test_path.join("excluded_link"),
1160 )
1161 .await?;
1162 let mut filter = FilterSettings::new();
1164 filter.add_exclude("*_link").unwrap();
1165 let result = rm(
1166 &PROGRESS,
1167 &test_path.join("excluded_link"),
1168 &Settings {
1169 fail_early: false,
1170 filter: Some(filter),
1171 dry_run: None,
1172 time_filter: None,
1173 },
1174 )
1175 .await?;
1176 assert_eq!(
1178 result.symlinks_removed, 0,
1179 "root symlink matching exclude should not be removed"
1180 );
1181 assert!(
1182 test_path.join("excluded_link").exists(),
1183 "excluded root symlink should still exist"
1184 );
1185 Ok(())
1186 }
1187 #[tokio::test]
1189 #[traced_test]
1190 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
1191 let tmp_dir = testutils::setup_test_dir().await?;
1192 let test_path = tmp_dir.as_path();
1193 let mut filter = FilterSettings::new();
1200 filter.add_include("bar/*.txt").unwrap();
1201 filter.add_exclude("bar/2.txt").unwrap();
1202 let summary = rm(
1203 &PROGRESS,
1204 &test_path.join("foo"),
1205 &Settings {
1206 fail_early: false,
1207 filter: Some(filter),
1208 dry_run: None,
1209 time_filter: None,
1210 },
1211 )
1212 .await?;
1213 assert_eq!(summary.files_removed, 2, "should remove 2 files");
1216 assert_eq!(
1217 summary.files_skipped, 2,
1218 "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
1219 );
1220 assert!(
1222 !test_path.join("foo/bar/1.txt").exists(),
1223 "bar/1.txt should be removed"
1224 );
1225 assert!(
1226 test_path.join("foo/bar/2.txt").exists(),
1227 "bar/2.txt should be excluded"
1228 );
1229 assert!(
1230 !test_path.join("foo/bar/3.txt").exists(),
1231 "bar/3.txt should be removed"
1232 );
1233 Ok(())
1234 }
1235 #[tokio::test]
1237 #[traced_test]
1238 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
1239 let tmp_dir = testutils::setup_test_dir().await?;
1240 let test_path = tmp_dir.as_path();
1241 let mut filter = FilterSettings::new();
1248 filter.add_exclude("bar/").unwrap();
1249 let summary = rm(
1250 &PROGRESS,
1251 &test_path.join("foo"),
1252 &Settings {
1253 fail_early: false,
1254 filter: Some(filter),
1255 dry_run: None,
1256 time_filter: None,
1257 },
1258 )
1259 .await?;
1260 assert_eq!(summary.files_removed, 2, "should remove 2 files");
1265 assert_eq!(summary.symlinks_removed, 2, "should remove 2 symlinks");
1266 assert_eq!(
1267 summary.directories_removed, 1,
1268 "should remove 1 directory (baz only, foo not empty)"
1269 );
1270 assert_eq!(
1271 summary.directories_skipped, 1,
1272 "should skip 1 directory (bar)"
1273 );
1274 assert!(
1276 test_path.join("foo/bar").exists(),
1277 "bar directory should still exist"
1278 );
1279 assert!(
1281 test_path.join("foo").exists(),
1282 "foo directory should still exist (contains bar)"
1283 );
1284 Ok(())
1285 }
1286 #[tokio::test]
1289 #[traced_test]
1290 async fn test_empty_dir_not_removed_when_only_traversed() -> Result<(), anyhow::Error> {
1291 let test_path = testutils::create_temp_dir().await?;
1292 tokio::fs::write(test_path.join("foo"), "content").await?;
1298 tokio::fs::write(test_path.join("bar"), "content").await?;
1299 tokio::fs::create_dir(test_path.join("baz")).await?;
1300 let mut filter = FilterSettings::new();
1302 filter.add_include("foo").unwrap();
1303 let summary = rm(
1304 &PROGRESS,
1305 &test_path,
1306 &Settings {
1307 fail_early: false,
1308 filter: Some(filter),
1309 dry_run: None,
1310 time_filter: None,
1311 },
1312 )
1313 .await?;
1314 assert_eq!(summary.files_removed, 1, "should remove only 'foo' file");
1316 assert_eq!(
1317 summary.directories_removed, 0,
1318 "should NOT remove empty 'baz' directory"
1319 );
1320 assert!(!test_path.join("foo").exists(), "foo should be removed");
1322 assert!(test_path.join("bar").exists(), "bar should still exist");
1324 assert!(
1326 test_path.join("baz").exists(),
1327 "empty baz directory should NOT be removed"
1328 );
1329 Ok(())
1330 }
1331 #[tokio::test]
1335 #[traced_test]
1336 async fn test_exclude_only_removes_empty_directory() -> Result<(), anyhow::Error> {
1337 let test_path = testutils::create_temp_dir().await?;
1338 tokio::fs::write(test_path.join("foo"), "content").await?;
1344 tokio::fs::write(test_path.join("bar.log"), "content").await?;
1345 tokio::fs::create_dir(test_path.join("baz")).await?;
1346 let mut filter = FilterSettings::new();
1348 filter.add_exclude("*.log").unwrap();
1349 let summary = rm(
1350 &PROGRESS,
1351 &test_path,
1352 &Settings {
1353 fail_early: false,
1354 filter: Some(filter),
1355 dry_run: None,
1356 time_filter: None,
1357 },
1358 )
1359 .await?;
1360 assert_eq!(summary.files_removed, 1, "should remove 'foo'");
1362 assert_eq!(summary.files_skipped, 1, "should skip 'bar.log'");
1363 assert_eq!(
1364 summary.directories_removed, 1,
1365 "should remove empty 'baz' directory"
1366 );
1367 assert!(!test_path.join("foo").exists(), "foo should be removed");
1368 assert!(
1369 test_path.join("bar.log").exists(),
1370 "bar.log should still exist"
1371 );
1372 assert!(
1373 !test_path.join("baz").exists(),
1374 "empty baz directory should be removed"
1375 );
1376 Ok(())
1377 }
1378 #[tokio::test]
1380 #[traced_test]
1381 async fn test_dry_run_empty_dir_not_reported_as_removed() -> Result<(), anyhow::Error> {
1382 let test_path = testutils::create_temp_dir().await?;
1383 tokio::fs::write(test_path.join("foo"), "content").await?;
1389 tokio::fs::write(test_path.join("bar"), "content").await?;
1390 tokio::fs::create_dir(test_path.join("baz")).await?;
1391 let mut filter = FilterSettings::new();
1393 filter.add_include("foo").unwrap();
1394 let summary = rm(
1395 &PROGRESS,
1396 &test_path,
1397 &Settings {
1398 fail_early: false,
1399 filter: Some(filter),
1400 dry_run: Some(DryRunMode::Explain),
1401 time_filter: None,
1402 },
1403 )
1404 .await?;
1405 assert_eq!(
1407 summary.files_removed, 1,
1408 "should report only 'foo' would be removed"
1409 );
1410 assert_eq!(
1411 summary.directories_removed, 0,
1412 "should NOT report empty 'baz' would be removed"
1413 );
1414 assert!(test_path.join("foo").exists(), "foo should still exist");
1416 assert!(test_path.join("bar").exists(), "bar should still exist");
1417 assert!(test_path.join("baz").exists(), "baz should still exist");
1418 Ok(())
1419 }
1420 #[tokio::test]
1423 #[traced_test]
1424 async fn test_include_directly_matched_empty_dir_is_removed() -> Result<(), anyhow::Error> {
1425 let test_path = testutils::create_temp_dir().await?;
1426 tokio::fs::write(test_path.join("foo"), "content").await?;
1431 tokio::fs::create_dir(test_path.join("baz")).await?;
1432 let mut filter = FilterSettings::new();
1434 filter.add_include("baz/").unwrap();
1435 let summary = rm(
1436 &PROGRESS,
1437 &test_path,
1438 &Settings {
1439 fail_early: false,
1440 filter: Some(filter),
1441 dry_run: None,
1442 time_filter: None,
1443 },
1444 )
1445 .await?;
1446 assert_eq!(
1447 summary.directories_removed, 1,
1448 "should remove directly matched empty 'baz' directory"
1449 );
1450 assert_eq!(summary.files_removed, 0, "should not remove 'foo'");
1451 assert!(test_path.join("foo").exists(), "foo should still exist");
1452 assert!(
1453 !test_path.join("baz").exists(),
1454 "directly matched empty baz directory should be removed"
1455 );
1456 Ok(())
1457 }
1458 }
1459 mod dry_run_tests {
1460 use super::*;
1461 use crate::filter::FilterSettings;
1462
1463 #[tokio::test(flavor = "current_thread")]
1464 async fn dry_run_rechecks_a_directory_hint_before_previewing_an_included_file()
1465 -> Result<(), anyhow::Error> {
1466 let root = testutils::create_temp_dir().await?;
1467 let target = root.join("target");
1468 tokio::fs::create_dir(&target).await?;
1469 tokio::fs::write(target.join("node"), "CURRENT").await?;
1470 let mut filter = FilterSettings::new();
1471 filter.add_exclude("node/")?;
1472 assert!(!matches!(
1473 filter.should_include(std::path::Path::new("node"), true),
1474 crate::filter::FilterResult::Included
1475 ));
1476 assert!(matches!(
1477 filter.should_include(std::path::Path::new("node"), false),
1478 crate::filter::FilterResult::Included
1479 ));
1480 let settings = Settings {
1481 fail_early: false,
1482 filter: Some(filter),
1483 dry_run: Some(DryRunMode::Brief),
1484 time_filter: None,
1485 };
1486 let admission = testutils::AdmissionLimit::new().await;
1487 admission.set_files_in_flight(1);
1488 let target_dir =
1489 Arc::new(Dir::open_root_dir(&target, false, congestion::Side::Source).await?);
1490 let visitor = Arc::new(RmVisitor {
1491 prog_track: &PROGRESS,
1492 settings,
1493 });
1494 let root_cx = EntryCx {
1495 parent: Arc::clone(&target_dir),
1496 name: std::ffi::OsString::from("target"),
1497 rel_path: std::path::PathBuf::new(),
1498 filter_path: std::path::PathBuf::new(),
1499 real_path: target.clone(),
1500 dry_run: true,
1501 prog_track: &PROGRESS,
1502 };
1503 let result = admission
1504 .run_with_timeout(
1505 std::time::Duration::from_secs(20),
1506 crate::walk_driver::scope_tasks(crate::walk_driver::walk_dir_entries(
1507 visitor,
1508 target_dir,
1509 &root_cx,
1510 &(),
1511 vec![(std::ffi::OsString::from("node"), Some(EntryKind::Dir))],
1512 )),
1513 )
1514 .await
1515 .context("rm dry-run hint recheck did not finish at a cap of one")??;
1516 let returned = admission
1517 .run_with_timeout(
1518 std::time::Duration::from_secs(20),
1519 throttle::pending_meta_permit(),
1520 )
1521 .await
1522 .context("rm dry-run hint recheck did not return pending-meta capacity")?;
1523 drop(returned);
1524 let (summary, processed) = result;
1525 assert_eq!(processed.names(), &[std::ffi::OsString::from("node")]);
1526 assert_eq!(summary.files_removed, 1);
1527 assert_eq!(summary.directories_skipped, 0);
1528 assert_eq!(
1529 tokio::fs::read_to_string(target.join("node")).await?,
1530 "CURRENT"
1531 );
1532 Ok(())
1533 }
1534
1535 #[tokio::test]
1537 #[traced_test]
1538 async fn test_dry_run_preserves_readonly_permissions() -> Result<(), anyhow::Error> {
1539 let tmp_dir = testutils::setup_test_dir().await?;
1540 let test_path = tmp_dir.as_path();
1541 let readonly_dir = test_path.join("foo/bar");
1542 tokio::fs::set_permissions(&readonly_dir, std::fs::Permissions::from_mode(0o555))
1544 .await?;
1545 let before_mode = tokio::fs::metadata(&readonly_dir)
1547 .await?
1548 .permissions()
1549 .mode()
1550 & 0o777;
1551 assert_eq!(
1552 before_mode, 0o555,
1553 "directory should be read-only before dry-run"
1554 );
1555 let summary = rm(
1556 &PROGRESS,
1557 &readonly_dir,
1558 &Settings {
1559 fail_early: false,
1560 filter: None,
1561 dry_run: Some(DryRunMode::Brief),
1562 time_filter: None,
1563 },
1564 )
1565 .await?;
1566 assert!(
1568 readonly_dir.exists(),
1569 "directory should still exist after dry-run"
1570 );
1571 let after_mode = tokio::fs::metadata(&readonly_dir)
1573 .await?
1574 .permissions()
1575 .mode()
1576 & 0o777;
1577 assert_eq!(
1578 after_mode, 0o555,
1579 "dry-run should not modify directory permissions"
1580 );
1581 assert!(
1583 summary.directories_removed > 0 || summary.files_removed > 0,
1584 "dry-run should report what would be removed"
1585 );
1586 Ok(())
1587 }
1588 #[tokio::test]
1591 #[traced_test]
1592 async fn test_dry_run_with_filter_non_empty_directory() -> Result<(), anyhow::Error> {
1593 let tmp_dir = testutils::setup_test_dir().await?;
1594 let test_path = tmp_dir.as_path();
1595 let mut filter = crate::filter::FilterSettings::new();
1602 filter.add_exclude("bar/").unwrap();
1603 let summary = rm(
1604 &PROGRESS,
1605 &test_path.join("foo"),
1606 &Settings {
1607 fail_early: false,
1608 filter: Some(filter),
1609 dry_run: Some(DryRunMode::Brief),
1610 time_filter: None,
1611 },
1612 )
1613 .await?;
1614 assert!(
1616 test_path.join("foo").exists(),
1617 "foo should still exist after dry-run"
1618 );
1619 assert_eq!(
1625 summary.files_removed, 2,
1626 "should report 2 files would be removed"
1627 );
1628 assert_eq!(
1629 summary.symlinks_removed, 2,
1630 "should report 2 symlinks would be removed"
1631 );
1632 assert_eq!(
1633 summary.directories_removed, 1,
1634 "should report only baz (not foo) would be removed"
1635 );
1636 assert_eq!(
1637 summary.directories_skipped, 1,
1638 "should report bar directory skipped"
1639 );
1640 Ok(())
1641 }
1642 #[tokio::test]
1645 #[traced_test]
1646 async fn test_dry_run_exclude_only_reports_empty_dir_removed() -> Result<(), anyhow::Error>
1647 {
1648 let test_path = testutils::create_temp_dir().await?;
1649 tokio::fs::write(test_path.join("foo"), "content").await?;
1655 tokio::fs::write(test_path.join("bar.log"), "content").await?;
1656 tokio::fs::create_dir(test_path.join("baz")).await?;
1657 let mut filter = FilterSettings::new();
1659 filter.add_exclude("*.log").unwrap();
1660 let summary = rm(
1661 &PROGRESS,
1662 &test_path,
1663 &Settings {
1664 fail_early: false,
1665 filter: Some(filter),
1666 dry_run: Some(DryRunMode::Explain),
1667 time_filter: None,
1668 },
1669 )
1670 .await?;
1671 assert_eq!(
1673 summary.files_removed, 1,
1674 "should report 'foo' would be removed"
1675 );
1676 assert_eq!(
1677 summary.files_skipped, 1,
1678 "should report 'bar.log' would be skipped"
1679 );
1680 assert_eq!(
1681 summary.directories_removed, 1,
1682 "should report empty 'baz' directory would be removed"
1683 );
1684 assert!(test_path.join("foo").exists(), "foo should still exist");
1686 assert!(
1687 test_path.join("bar.log").exists(),
1688 "bar.log should still exist"
1689 );
1690 assert!(test_path.join("baz").exists(), "baz should still exist");
1691 Ok(())
1692 }
1693 #[tokio::test]
1696 #[traced_test]
1697 async fn test_dry_run_include_directly_matched_empty_dir_reported()
1698 -> Result<(), anyhow::Error> {
1699 let test_path = testutils::create_temp_dir().await?;
1700 tokio::fs::write(test_path.join("foo"), "content").await?;
1705 tokio::fs::create_dir(test_path.join("baz")).await?;
1706 let mut filter = FilterSettings::new();
1708 filter.add_include("baz/").unwrap();
1709 let summary = rm(
1710 &PROGRESS,
1711 &test_path,
1712 &Settings {
1713 fail_early: false,
1714 filter: Some(filter),
1715 dry_run: Some(DryRunMode::Explain),
1716 time_filter: None,
1717 },
1718 )
1719 .await?;
1720 assert_eq!(
1721 summary.directories_removed, 1,
1722 "should report directly matched empty 'baz' would be removed"
1723 );
1724 assert_eq!(summary.files_removed, 0, "should not report 'foo'");
1725 assert!(test_path.join("foo").exists(), "foo should still exist");
1727 assert!(test_path.join("baz").exists(), "baz should still exist");
1728 Ok(())
1729 }
1730 }
1731 mod time_filter_tests {
1732 use super::*;
1733 use crate::filter::TimeFilter;
1734
1735 fn set_mtime_age(path: &std::path::Path, age: std::time::Duration) -> anyhow::Result<()> {
1736 let past = filetime::FileTime::from_system_time(std::time::SystemTime::now() - age);
1737 filetime::set_file_mtime(path, past)?;
1738 Ok(())
1739 }
1740
1741 #[tokio::test]
1743 #[traced_test]
1744 async fn removes_files_older_than_modified_before() -> Result<(), anyhow::Error> {
1745 let test_path = testutils::create_temp_dir().await?;
1746 let file = test_path.join("old.txt");
1747 tokio::fs::write(&file, "x").await?;
1748 set_mtime_age(&file, std::time::Duration::from_secs(7200))?;
1749 set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1751 let summary = rm(
1752 &PROGRESS,
1753 &test_path,
1754 &Settings {
1755 fail_early: false,
1756 filter: None,
1757 time_filter: Some(TimeFilter {
1758 modified_before: Some(std::time::Duration::from_secs(3600)),
1759 created_before: None,
1760 }),
1761 dry_run: None,
1762 },
1763 )
1764 .await?;
1765 assert_eq!(summary.files_removed, 1, "old file should be removed");
1766 assert_eq!(summary.files_skipped, 0);
1767 assert!(!file.exists(), "old.txt should be removed");
1768 Ok(())
1769 }
1770
1771 #[tokio::test]
1773 #[traced_test]
1774 async fn keeps_files_newer_than_modified_before() -> Result<(), anyhow::Error> {
1775 let test_path = testutils::create_temp_dir().await?;
1776 let file = test_path.join("new.txt");
1777 tokio::fs::write(&file, "x").await?;
1778 set_mtime_age(&file, std::time::Duration::from_secs(60))?;
1779 set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1780 let summary = rm(
1781 &PROGRESS,
1782 &test_path,
1783 &Settings {
1784 fail_early: false,
1785 filter: None,
1786 time_filter: Some(TimeFilter {
1787 modified_before: Some(std::time::Duration::from_secs(3600)),
1788 created_before: None,
1789 }),
1790 dry_run: None,
1791 },
1792 )
1793 .await?;
1794 assert_eq!(summary.files_removed, 0, "new file should not be removed");
1795 assert_eq!(summary.files_skipped, 1, "new file should be skipped");
1796 assert!(file.exists(), "new.txt should still exist");
1797 Ok(())
1798 }
1799
1800 #[tokio::test]
1803 #[traced_test]
1804 async fn fresh_subdirectory_is_descended_but_not_removed() -> Result<(), anyhow::Error> {
1805 let test_path = testutils::create_temp_dir().await?;
1806 let old_file = test_path.join("old.txt");
1807 let fresh_dir = test_path.join("fresh_dir");
1808 let fresh_child = fresh_dir.join("fresh_child.txt");
1809 let old_child = fresh_dir.join("old_child.txt");
1810 tokio::fs::write(&old_file, "x").await?;
1811 tokio::fs::create_dir(&fresh_dir).await?;
1812 tokio::fs::write(&fresh_child, "x").await?;
1813 tokio::fs::write(&old_child, "x").await?;
1814 set_mtime_age(&old_file, std::time::Duration::from_secs(7200))?;
1815 set_mtime_age(&old_child, std::time::Duration::from_secs(7200))?;
1816 set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1819 let summary = rm(
1820 &PROGRESS,
1821 &test_path,
1822 &Settings {
1823 fail_early: false,
1824 filter: None,
1825 time_filter: Some(TimeFilter {
1826 modified_before: Some(std::time::Duration::from_secs(3600)),
1827 created_before: None,
1828 }),
1829 dry_run: None,
1830 },
1831 )
1832 .await?;
1833 assert_eq!(summary.files_removed, 2, "old.txt and old_child removed");
1835 assert_eq!(
1836 summary.files_skipped, 1,
1837 "fresh_child skipped inside fresh_dir"
1838 );
1839 assert_eq!(
1840 summary.directories_skipped, 1,
1841 "fresh_dir itself is skipped at removal time"
1842 );
1843 assert_eq!(
1844 summary.directories_removed, 0,
1845 "root survives because fresh_dir is still inside it"
1846 );
1847 assert!(!old_file.exists());
1848 assert!(!old_child.exists(), "old_child inside fresh_dir removed");
1849 assert!(
1850 fresh_dir.exists(),
1851 "fresh_dir kept despite its old child being removed"
1852 );
1853 assert!(fresh_child.exists(), "fresh_child inside fresh_dir kept");
1854 Ok(())
1855 }
1856
1857 #[tokio::test]
1860 #[traced_test]
1861 async fn old_dir_with_new_file_leaves_non_empty_dir_without_error()
1862 -> Result<(), anyhow::Error> {
1863 let test_path = testutils::create_temp_dir().await?;
1864 let old_dir = test_path.join("old_dir");
1865 tokio::fs::create_dir(&old_dir).await?;
1866 let new_file = old_dir.join("new.txt");
1867 tokio::fs::write(&new_file, "x").await?;
1868 set_mtime_age(&new_file, std::time::Duration::from_secs(60))?;
1869 set_mtime_age(&old_dir, std::time::Duration::from_secs(7200))?;
1870 set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1871 tokio::fs::set_permissions(&old_dir, std::fs::Permissions::from_mode(0o555)).await?;
1872 let result = rm(
1873 &PROGRESS,
1874 &test_path,
1875 &Settings {
1876 fail_early: false,
1877 filter: None,
1878 time_filter: Some(TimeFilter {
1879 modified_before: Some(std::time::Duration::from_secs(3600)),
1880 created_before: None,
1881 }),
1882 dry_run: None,
1883 },
1884 )
1885 .await;
1886 let summary = result.expect("ENOTEMPTY should not surface as an error");
1887 assert_eq!(summary.files_skipped, 1, "new file should be skipped");
1888 assert_eq!(
1889 summary.directories_removed, 0,
1890 "old_dir cannot be removed while new.txt remains"
1891 );
1892 assert!(old_dir.exists(), "old_dir should still exist");
1893 assert!(new_file.exists(), "new.txt should still exist");
1894 assert_eq!(
1895 tokio::fs::metadata(&old_dir).await?.permissions().mode() & 0o7777,
1896 0o555,
1897 "ENOTEMPTY retention did not restore the original directory mode"
1898 );
1899 assert!(
1901 logs_contain("not empty after filtering, leaving it intact"),
1902 "should log ENOTEMPTY case at info"
1903 );
1904 Ok(())
1905 }
1906
1907 #[tokio::test]
1909 #[traced_test]
1910 async fn old_empty_directory_is_removed() -> Result<(), anyhow::Error> {
1911 let test_path = testutils::create_temp_dir().await?;
1912 let old_empty = test_path.join("old_empty");
1913 tokio::fs::create_dir(&old_empty).await?;
1914 set_mtime_age(&old_empty, std::time::Duration::from_secs(7200))?;
1915 set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1916 let summary = rm(
1917 &PROGRESS,
1918 &test_path,
1919 &Settings {
1920 fail_early: false,
1921 filter: None,
1922 time_filter: Some(TimeFilter {
1923 modified_before: Some(std::time::Duration::from_secs(3600)),
1924 created_before: None,
1925 }),
1926 dry_run: None,
1927 },
1928 )
1929 .await?;
1930 assert_eq!(summary.directories_removed, 2);
1932 assert!(!old_empty.exists());
1933 assert!(!test_path.exists());
1934 Ok(())
1935 }
1936
1937 #[tokio::test]
1939 #[traced_test]
1940 async fn time_filter_combines_with_glob_exclude() -> Result<(), anyhow::Error> {
1941 let test_path = testutils::create_temp_dir().await?;
1942 let old_keep = test_path.join("keep.log");
1943 let old_drop = test_path.join("drop.txt");
1944 let new_drop = test_path.join("recent.txt");
1945 tokio::fs::write(&old_keep, "x").await?;
1946 tokio::fs::write(&old_drop, "x").await?;
1947 tokio::fs::write(&new_drop, "x").await?;
1948 set_mtime_age(&old_keep, std::time::Duration::from_secs(7200))?;
1949 set_mtime_age(&old_drop, std::time::Duration::from_secs(7200))?;
1950 set_mtime_age(&new_drop, std::time::Duration::from_secs(60))?;
1951 set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1952 let mut filter = crate::filter::FilterSettings::new();
1953 filter.add_exclude("*.log").unwrap();
1954 let summary = rm(
1955 &PROGRESS,
1956 &test_path,
1957 &Settings {
1958 fail_early: false,
1959 filter: Some(filter),
1960 time_filter: Some(TimeFilter {
1961 modified_before: Some(std::time::Duration::from_secs(3600)),
1962 created_before: None,
1963 }),
1964 dry_run: None,
1965 },
1966 )
1967 .await?;
1968 assert_eq!(summary.files_removed, 1, "only old_drop should be removed");
1970 assert_eq!(
1971 summary.files_skipped, 2,
1972 "old_keep and recent_drop should be skipped"
1973 );
1974 assert!(
1975 old_keep.exists(),
1976 "keep.log excluded by glob, should remain"
1977 );
1978 assert!(!old_drop.exists(), "drop.txt should be removed");
1979 assert!(new_drop.exists(), "recent.txt should remain (too new)");
1980 Ok(())
1981 }
1982
1983 #[tokio::test]
1985 #[traced_test]
1986 async fn time_filter_with_dry_run() -> Result<(), anyhow::Error> {
1987 let test_path = testutils::create_temp_dir().await?;
1988 let old_file = test_path.join("old.txt");
1989 let new_file = test_path.join("new.txt");
1990 tokio::fs::write(&old_file, "x").await?;
1991 tokio::fs::write(&new_file, "x").await?;
1992 set_mtime_age(&old_file, std::time::Duration::from_secs(7200))?;
1993 set_mtime_age(&new_file, std::time::Duration::from_secs(60))?;
1994 set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1995 let summary = rm(
1996 &PROGRESS,
1997 &test_path,
1998 &Settings {
1999 fail_early: false,
2000 filter: None,
2001 time_filter: Some(TimeFilter {
2002 modified_before: Some(std::time::Duration::from_secs(3600)),
2003 created_before: None,
2004 }),
2005 dry_run: Some(DryRunMode::Explain),
2006 },
2007 )
2008 .await?;
2009 assert_eq!(
2010 summary.files_removed, 1,
2011 "should report old file would be removed"
2012 );
2013 assert_eq!(
2014 summary.files_skipped, 1,
2015 "should report new file would be skipped"
2016 );
2017 assert!(old_file.exists(), "old.txt should still exist (dry-run)");
2018 assert!(new_file.exists(), "new.txt should still exist (dry-run)");
2019 Ok(())
2020 }
2021
2022 #[tokio::test]
2025 #[traced_test]
2026 async fn fresh_top_level_directory_is_traversed_but_not_removed()
2027 -> Result<(), anyhow::Error> {
2028 let test_path = testutils::create_temp_dir().await?;
2029 let old_inside = test_path.join("old.txt");
2030 tokio::fs::write(&old_inside, "x").await?;
2031 set_mtime_age(&old_inside, std::time::Duration::from_secs(7200))?;
2032 tokio::fs::set_permissions(&test_path, std::fs::Permissions::from_mode(0o555)).await?;
2034 let summary = rm(
2035 &PROGRESS,
2036 &test_path,
2037 &Settings {
2038 fail_early: false,
2039 filter: None,
2040 time_filter: Some(TimeFilter {
2041 modified_before: Some(std::time::Duration::from_secs(3600)),
2042 created_before: None,
2043 }),
2044 dry_run: None,
2045 },
2046 )
2047 .await?;
2048 assert_eq!(
2049 summary.files_removed, 1,
2050 "old child should be removed despite fresh parent"
2051 );
2052 assert_eq!(
2053 summary.directories_skipped, 1,
2054 "fresh root itself is skipped at removal time"
2055 );
2056 assert_eq!(
2057 summary.directories_removed, 0,
2058 "fresh root must not be removed"
2059 );
2060 assert!(test_path.exists(), "fresh root should still exist");
2061 assert!(!old_inside.exists(), "old child should be gone");
2062 assert_eq!(
2063 tokio::fs::metadata(&test_path).await?.permissions().mode() & 0o7777,
2064 0o555,
2065 "time-filter retention did not restore the original directory mode"
2066 );
2067 Ok(())
2068 }
2069
2070 #[tokio::test]
2072 #[traced_test]
2073 async fn time_filter_on_root_file_argument() -> Result<(), anyhow::Error> {
2074 let test_path = testutils::create_temp_dir().await?;
2075 let new_file = test_path.join("new.txt");
2076 tokio::fs::write(&new_file, "x").await?;
2077 set_mtime_age(&new_file, std::time::Duration::from_secs(60))?;
2078 let summary = rm(
2079 &PROGRESS,
2080 &new_file,
2081 &Settings {
2082 fail_early: false,
2083 filter: None,
2084 time_filter: Some(TimeFilter {
2085 modified_before: Some(std::time::Duration::from_secs(3600)),
2086 created_before: None,
2087 }),
2088 dry_run: None,
2089 },
2090 )
2091 .await?;
2092 assert_eq!(summary.files_removed, 0);
2093 assert_eq!(
2094 summary.files_skipped, 1,
2095 "root file too new should be skipped"
2096 );
2097 assert!(new_file.exists(), "root file should still exist");
2098 Ok(())
2099 }
2100 }
2101
2102 mod max_files_in_flight_tests {
2104 use super::*;
2105
2106 #[tokio::test]
2110 async fn filtered_root_waits_for_admission_before_setup() -> Result<(), anyhow::Error> {
2111 let root = testutils::create_temp_dir().await?;
2112 let path = root.join("victim");
2113 tokio::fs::write(&path, b"x").await?;
2114 let mut filter = crate::filter::FilterSettings::new();
2115 filter.add_exclude("victim")?;
2116 let settings = Settings {
2117 fail_early: true,
2118 filter: Some(filter),
2119 dry_run: None,
2120 time_filter: None,
2121 };
2122 let admission = testutils::AdmissionLimit::new().await;
2123 admission.set_files_in_flight(1);
2124 let stat_resource =
2125 throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
2126 admission.set_max_ops_in_flight(stat_resource, 1);
2127 let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
2128 let operation = rm(&PROGRESS, &path, &settings);
2129 tokio::pin!(operation);
2130 let stopped_at_stat_gate = futures::poll!(operation.as_mut()).is_pending();
2131 let mut second_permit = Box::pin(throttle::pending_meta_permit());
2132 let setup_bypassed_admission = futures::poll!(second_permit.as_mut()).is_ready();
2133 drop(second_permit);
2134 drop(held_stat);
2135 let result = admission
2136 .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
2137 .await;
2138 assert!(
2139 stopped_at_stat_gate,
2140 "rm root did not reach the held stat gate"
2141 );
2142 let summary =
2143 result.context("rm root did not resume after stat capacity was released")??;
2144 assert!(
2145 !setup_bypassed_admission,
2146 "rm performed root setup before pending-metadata admission"
2147 );
2148 assert_eq!(summary.files_skipped, 1);
2149 Ok(())
2150 }
2151
2152 #[tokio::test]
2155 #[traced_test]
2156 async fn wide_rm_under_files_in_flight_saturation() -> Result<(), anyhow::Error> {
2157 let test_path = testutils::create_temp_dir().await?;
2158 let file_count = 200;
2159 for i in 0..file_count {
2160 tokio::fs::write(
2161 test_path.join(format!("{}.txt", i)),
2162 format!("content-{}", i),
2163 )
2164 .await?;
2165 }
2166 let admission = testutils::AdmissionLimit::new().await;
2168 admission.set_files_in_flight(4);
2169 let summary = admission
2170 .run_with_timeout(
2171 std::time::Duration::from_secs(30),
2172 rm(
2173 &PROGRESS,
2174 &test_path,
2175 &Settings {
2176 fail_early: true,
2177 filter: None,
2178 dry_run: None,
2179 time_filter: None,
2180 },
2181 ),
2182 )
2183 .await
2184 .context("rm timed out — possible deadlock")??;
2185 assert_eq!(summary.files_removed, file_count);
2186 assert_eq!(summary.directories_removed, 1);
2187 assert!(!test_path.exists());
2188 Ok(())
2189 }
2190
2191 #[tokio::test]
2194 #[traced_test]
2195 async fn deep_tree_no_deadlock_under_files_in_flight_saturation()
2196 -> Result<(), anyhow::Error> {
2197 let test_path = testutils::create_temp_dir().await?;
2198 let depth = 20;
2199 let files_per_level = 5;
2200 let limit = 1;
2201 let mut dir = test_path.clone();
2203 for level in 0..depth {
2204 tokio::fs::create_dir_all(&dir).await?;
2205 for f in 0..files_per_level {
2206 tokio::fs::write(
2207 dir.join(format!("f{}_{}.txt", level, f)),
2208 format!("L{}F{}", level, f),
2209 )
2210 .await?;
2211 }
2212 dir = dir.join(format!("d{}", level));
2213 }
2214 let admission = testutils::AdmissionLimit::new().await;
2215 admission.set_files_in_flight(limit);
2216 let summary = admission
2217 .run_with_timeout(
2218 std::time::Duration::from_secs(30),
2219 rm(
2220 &PROGRESS,
2221 &test_path,
2222 &Settings {
2223 fail_early: true,
2224 filter: None,
2225 dry_run: None,
2226 time_filter: None,
2227 },
2228 ),
2229 )
2230 .await
2231 .context("rm timed out — possible deadlock")?
2232 .context("rm failed")?;
2233 assert_eq!(summary.files_removed, depth * files_per_level);
2234 assert_eq!(summary.directories_removed, depth);
2235 assert!(!test_path.exists());
2236 Ok(())
2237 }
2238
2239 #[tokio::test]
2255 #[traced_test]
2256 async fn hinted_leaf_that_is_dir_drops_permit_before_recursion() -> anyhow::Result<()> {
2257 let root = testutils::create_temp_dir().await?;
2258 let dir_path = root.join("d");
2260 tokio::fs::create_dir(&dir_path).await?;
2261 tokio::fs::write(dir_path.join("c"), b"x").await?;
2262 let admission = testutils::AdmissionLimit::new().await;
2265 admission.set_files_in_flight(1);
2266 let parent = Dir::open_parent_dir(&root, congestion::Side::Source)
2268 .await
2269 .context("open parent dir")?;
2270 let parent = Arc::new(parent.into_tree());
2271 let name = std::ffi::OsStr::new("d");
2272 let handle = parent.child(name).await.context("classify d")?;
2273 assert_eq!(
2274 handle.kind(),
2275 EntryKind::Dir,
2276 "fixture `d` must be a directory"
2277 );
2278 drop(handle);
2279 let settings = Settings {
2280 fail_early: true,
2281 filter: None,
2282 dry_run: None,
2283 time_filter: None,
2284 };
2285 let visitor = Arc::new(RmVisitor {
2286 prog_track: &PROGRESS,
2287 settings: settings.clone(),
2288 });
2289 let cx = EntryCx {
2290 parent: Arc::clone(&parent),
2291 name: name.to_owned(),
2292 rel_path: std::path::PathBuf::new(),
2293 filter_path: std::path::PathBuf::new(),
2294 real_path: dir_path.clone(),
2295 dry_run: false,
2296 prog_track: &PROGRESS,
2297 };
2298 let permit =
2301 crate::walk::preacquire_leaf_permit(PermitKind::PendingMeta, Some(EntryKind::File))
2302 .await;
2303 assert!(permit.is_some(), "the pre-acquire must take the one permit");
2304 let result = admission
2305 .run_with_timeout(
2306 std::time::Duration::from_secs(20),
2307 process_entry(visitor, cx, (), permit),
2308 )
2309 .await;
2310 let summary = result
2311 .context(
2312 "process_entry hung — leaf permit held across directory recursion (deadlock)",
2313 )?
2314 .map_err(|e| e.source)?;
2315 assert_eq!(summary.files_removed, 1, "child file should be removed");
2316 assert_eq!(
2317 summary.directories_removed, 1,
2318 "directory should be removed"
2319 );
2320 assert!(!dir_path.exists(), "directory `d` should be gone");
2321 Ok(())
2322 }
2323 }
2324
2325 mod race_tests {
2327 use super::*;
2328
2329 static RACE_PROGRESS: std::sync::LazyLock<progress::Progress> =
2330 std::sync::LazyLock::new(progress::Progress::new);
2331
2332 #[tokio::test]
2333 async fn non_directory_action_cannot_remove_directory_replacement() -> anyhow::Result<()> {
2334 let tmp = tempfile::tempdir()?;
2335 let entry = tmp.path().join("entry");
2336 let original = tmp.path().join("original");
2337 tokio::fs::write(&entry, b"original").await?;
2338 let parent = Arc::new(
2339 Dir::open_parent_dir(tmp.path(), congestion::Side::Source)
2340 .await?
2341 .into_tree(),
2342 );
2343 let handle = parent.child(OsStr::new("entry")).await?;
2344 let leaf = AdmittedLeaf::try_new(handle, None)
2345 .map_err(|_| anyhow::anyhow!("file fixture classified as a directory"))?;
2346 tokio::fs::rename(&entry, &original).await?;
2347 tokio::fs::create_dir(&entry).await?;
2348 tokio::fs::write(entry.join("replacement-child"), b"replacement").await?;
2349 let visitor = RmVisitor {
2350 prog_track: &RACE_PROGRESS,
2351 settings: Settings {
2352 fail_early: true,
2353 filter: None,
2354 time_filter: None,
2355 dry_run: None,
2356 },
2357 };
2358 let cx = EntryCx {
2359 parent,
2360 name: OsStr::new("entry").to_owned(),
2361 rel_path: std::path::PathBuf::from("entry"),
2362 filter_path: std::path::PathBuf::from("entry"),
2363 real_path: entry.clone(),
2364 dry_run: false,
2365 prog_track: &RACE_PROGRESS,
2366 };
2367 let error = visitor
2368 .visit_leaf(&cx, &(), leaf)
2369 .await
2370 .expect_err("a nondirectory unlink must reject a replacement directory");
2371 assert!(format!("{:#}", error.source).contains("failed removing"));
2372 assert_eq!(tokio::fs::read(&original).await?, b"original");
2373 assert_eq!(
2374 tokio::fs::read(entry.join("replacement-child")).await?,
2375 b"replacement"
2376 );
2377 Ok(())
2378 }
2379
2380 #[tokio::test]
2381 async fn directory_replacement_is_not_descended_after_classification() -> anyhow::Result<()>
2382 {
2383 let tmp = tempfile::tempdir()?;
2384 let entry = tmp.path().join("entry");
2385 let original = tmp.path().join("original");
2386 tokio::fs::create_dir(&entry).await?;
2387 let parent = Arc::new(
2388 Dir::open_parent_dir(tmp.path(), congestion::Side::Source)
2389 .await?
2390 .into_tree(),
2391 );
2392 let handle = parent.child(OsStr::new("entry")).await?;
2393 tokio::fs::rename(&entry, &original).await?;
2394 tokio::fs::create_dir(&entry).await?;
2395 tokio::fs::write(entry.join("replacement-child"), b"replacement").await?;
2396 let visitor = RmVisitor {
2397 prog_track: &RACE_PROGRESS,
2398 settings: Settings {
2399 fail_early: true,
2400 filter: None,
2401 time_filter: None,
2402 dry_run: None,
2403 },
2404 };
2405 let cx = EntryCx {
2406 parent,
2407 name: OsStr::new("entry").to_owned(),
2408 rel_path: std::path::PathBuf::from("entry"),
2409 filter_path: std::path::PathBuf::from("entry"),
2410 real_path: entry.clone(),
2411 dry_run: false,
2412 prog_track: &RACE_PROGRESS,
2413 };
2414 let error = match visitor.dir_pre(&cx, &(), &handle).await {
2415 Ok(_) => anyhow::bail!("a replacement passed the directory identity check"),
2416 Err(error) => error,
2417 };
2418 assert!(format!("{:#}", error.source).contains("changed identity before descent"));
2419 assert_eq!(
2420 tokio::fs::read(entry.join("replacement-child")).await?,
2421 b"replacement"
2422 );
2423 Ok(())
2424 }
2425
2426 #[tokio::test]
2427 async fn final_rmdir_removes_an_empty_replacement_in_the_walked_slot() -> anyhow::Result<()>
2428 {
2429 let tmp = tempfile::tempdir()?;
2430 let entry = tmp.path().join("entry");
2431 let original = tmp.path().join("original");
2432 tokio::fs::create_dir(&entry).await?;
2433 let parent = Arc::new(
2434 Dir::open_parent_dir(tmp.path(), congestion::Side::Source)
2435 .await?
2436 .into_tree(),
2437 );
2438 let handle = parent.child(OsStr::new("entry")).await?;
2439 let visitor = RmVisitor {
2440 prog_track: &RACE_PROGRESS,
2441 settings: Settings {
2442 fail_early: true,
2443 filter: None,
2444 time_filter: None,
2445 dry_run: None,
2446 },
2447 };
2448 let cx = EntryCx {
2449 parent,
2450 name: OsStr::new("entry").to_owned(),
2451 rel_path: std::path::PathBuf::from("entry"),
2452 filter_path: std::path::PathBuf::from("entry"),
2453 real_path: entry.clone(),
2454 dry_run: false,
2455 prog_track: &RACE_PROGRESS,
2456 };
2457 let state = match visitor.dir_pre(&cx, &(), &handle).await? {
2458 DirAction::Descend { state, .. } => state,
2459 DirAction::Skip(_) => anyhow::bail!("unfiltered directory fixture was skipped"),
2460 };
2461 tokio::fs::rename(&entry, &original).await?;
2462 tokio::fs::create_dir(&entry).await?;
2463 let summary = visitor
2464 .dir_post(
2465 &cx,
2466 state,
2467 &ProcessedChildren::default(),
2468 Ok(Summary::default()),
2469 )
2470 .await?;
2471 assert_eq!(summary.directories_removed, 1);
2472 assert!(!entry.exists(), "the empty slot replacement survived rmdir");
2473 assert!(original.is_dir());
2474 Ok(())
2475 }
2476
2477 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2478 async fn intended_removal_keeps_temporary_mode_until_rmdir() -> anyhow::Result<()> {
2479 let tmp = tempfile::tempdir()?;
2480 let entry = tmp.path().join("entry");
2481 tokio::fs::create_dir(&entry).await?;
2482 tokio::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o555)).await?;
2483 let parent = Arc::new(
2484 Dir::open_parent_dir(tmp.path(), congestion::Side::Source)
2485 .await?
2486 .into_tree(),
2487 );
2488 let handle = parent.child(OsStr::new("entry")).await?;
2489 let visitor = RmVisitor {
2490 prog_track: &RACE_PROGRESS,
2491 settings: Settings {
2492 fail_early: true,
2493 filter: None,
2494 time_filter: None,
2495 dry_run: None,
2496 },
2497 };
2498 let cx = EntryCx {
2499 parent,
2500 name: OsStr::new("entry").to_owned(),
2501 rel_path: std::path::PathBuf::from("entry"),
2502 filter_path: std::path::PathBuf::from("entry"),
2503 real_path: entry.clone(),
2504 dry_run: false,
2505 prog_track: &RACE_PROGRESS,
2506 };
2507 let state = match visitor.dir_pre(&cx, &(), &handle).await? {
2508 DirAction::Descend { state, .. } => state,
2509 DirAction::Skip(_) => anyhow::bail!("unfiltered directory fixture was skipped"),
2510 };
2511 assert_eq!(
2512 tokio::fs::metadata(&entry).await?.permissions().mode() & 0o7777,
2513 0o700,
2514 "dir_pre must relax the directory before final removal"
2515 );
2516
2517 let mut gate = testutils::BlockingPathGate::install(entry.clone());
2518 let mut task = tokio::spawn(async move {
2519 visitor
2520 .dir_post(
2521 &cx,
2522 state,
2523 &ProcessedChildren::default(),
2524 Ok(Summary::default()),
2525 )
2526 .await
2527 });
2528 let timeout = std::time::Duration::from_secs(20);
2529 let gate_start = tokio::time::timeout(timeout, gate.wait_started()).await;
2530 if !matches!(&gate_start, Ok(Ok(_))) {
2531 gate.release_all();
2532 task.abort();
2533 let _ = task.await;
2534 match gate_start {
2535 Ok(Err(error)) => {
2536 return Err(error.context("final rmdir did not reach its pre-syscall gate"));
2537 }
2538 Err(error) => {
2539 return Err(anyhow::Error::new(error)
2540 .context("final rmdir did not reach its pre-syscall gate"));
2541 }
2542 Ok(Ok(_)) => unreachable!(),
2543 }
2544 }
2545 let checked_mode = tokio::fs::metadata(&entry)
2546 .await
2547 .map(|metadata| metadata.permissions().mode() & 0o7777);
2548 gate.release_all();
2549 let owner_join = match tokio::time::timeout(timeout, &mut task).await {
2550 Ok(result) => result,
2551 Err(error) => {
2552 task.abort();
2553 let _ = task.await;
2554 return Err(anyhow::Error::new(error)
2555 .context("final rmdir did not quiesce after its gate released"));
2556 }
2557 };
2558 let result = owner_join.context("final rmdir task panicked")?;
2559 let summary = result.map_err(|error| error.source)?;
2560 assert_eq!(
2561 checked_mode?, 0o700,
2562 "the intended-removal path restored metadata before the rmdir boundary"
2563 );
2564 assert!(!entry.exists(), "the by-name rmdir left the directory name");
2565 assert_eq!(summary.directories_removed, 1);
2566 Ok(())
2567 }
2568
2569 #[tokio::test]
2570 async fn filtered_retention_restores_mode_through_chmod_admission() -> anyhow::Result<()> {
2571 let tmp = tempfile::tempdir()?;
2572 let entry = tmp.path().join("entry");
2573 tokio::fs::create_dir(&entry).await?;
2574 tokio::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o555)).await?;
2575 let mut filter = crate::filter::FilterSettings::new();
2576 filter.add_include("other")?;
2577 let parent = Arc::new(
2578 Dir::open_parent_dir(tmp.path(), congestion::Side::Source)
2579 .await?
2580 .into_tree(),
2581 );
2582 let handle = parent.child(OsStr::new("entry")).await?;
2583 let visitor = RmVisitor {
2584 prog_track: &RACE_PROGRESS,
2585 settings: Settings {
2586 fail_early: true,
2587 filter: Some(filter),
2588 time_filter: None,
2589 dry_run: None,
2590 },
2591 };
2592 let cx = EntryCx {
2593 parent,
2594 name: OsStr::new("entry").to_owned(),
2595 rel_path: std::path::PathBuf::from("entry"),
2596 filter_path: std::path::PathBuf::from("entry"),
2597 real_path: entry.clone(),
2598 dry_run: false,
2599 prog_track: &RACE_PROGRESS,
2600 };
2601 let state = match visitor.dir_pre(&cx, &(), &handle).await? {
2602 DirAction::Descend { state, .. } => state,
2603 DirAction::Skip(_) => anyhow::bail!("include-filter ancestor was skipped"),
2604 };
2605 assert_eq!(
2606 tokio::fs::metadata(&entry).await?.permissions().mode() & 0o7777,
2607 0o700,
2608 "dir_pre did not relax the protected directory"
2609 );
2610
2611 let admission = testutils::AdmissionLimit::new().await;
2612 let chmod_resource =
2613 throttle::Resource::meta(throttle::Side::Destination, throttle::MetadataOp::Chmod);
2614 admission.set_max_ops_in_flight(chmod_resource, 1);
2615 let held_chmod = throttle::ops_in_flight_permit(chmod_resource).await;
2616 let processed = ProcessedChildren::default();
2617 let restore = visitor.dir_post(&cx, state, &processed, Ok(Summary::default()));
2618 tokio::pin!(restore);
2619 assert!(
2620 futures::poll!(restore.as_mut()).is_pending(),
2621 "filtered retention bypassed Destination/Chmod admission"
2622 );
2623 assert_eq!(
2624 tokio::fs::metadata(&entry).await?.permissions().mode() & 0o7777,
2625 0o700,
2626 "filtered retention restored the mode while chmod admission was held"
2627 );
2628 drop(held_chmod);
2629 let summary = admission
2630 .run_with_timeout(std::time::Duration::from_secs(20), restore.as_mut())
2631 .await
2632 .context("filtered mode restore did not resume after chmod admission")?
2633 .map_err(|error| error.source)?;
2634 assert!(entry.is_dir(), "filtered retention removed the directory");
2635 assert_eq!(summary.directories_removed, 0);
2636 assert_eq!(
2637 tokio::fs::metadata(&entry).await?.permissions().mode() & 0o7777,
2638 0o555,
2639 "filtered retention did not restore the original directory mode"
2640 );
2641 Ok(())
2642 }
2643
2644 fn spawn_dir_symlink_swapper(
2651 tree: std::path::PathBuf,
2652 sentinel: std::path::PathBuf,
2653 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
2654 ) -> std::thread::JoinHandle<()> {
2655 std::thread::spawn(move || {
2656 let sub = tree.join("sub");
2657 let staged_dir = tree.join("__staged_sub_dir");
2658 let staged_link = tree.join("__staged_sub_link");
2659 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
2660 let _ = std::fs::remove_dir_all(&staged_dir);
2662 if std::fs::create_dir(&staged_dir).is_ok() {
2663 let _ = std::fs::write(staged_dir.join("real.txt"), b"REAL");
2664 let _ = std::fs::remove_dir_all(&sub);
2668 let _ = std::fs::remove_file(&sub);
2669 let _ = std::fs::rename(&staged_dir, &sub);
2670 }
2671 let _ = std::fs::remove_file(&staged_link);
2673 if std::os::unix::fs::symlink(&sentinel, &staged_link).is_ok() {
2674 let _ = std::fs::remove_dir_all(&sub);
2675 let _ = std::fs::remove_file(&sub);
2676 let _ = std::fs::rename(&staged_link, &sub);
2677 }
2678 }
2679 })
2680 }
2681
2682 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2694 async fn intermediate_dir_swap_never_deletes_out_of_tree_sentinel() -> anyhow::Result<()> {
2695 let tmp = testutils::create_temp_dir().await?;
2696 let root = tmp.as_path();
2697 let sentinel = root.join("sentinel_tree");
2699 tokio::fs::create_dir(&sentinel).await?;
2700 tokio::fs::write(sentinel.join("secret1.txt"), b"SECRET-1").await?;
2701 tokio::fs::create_dir(sentinel.join("subdir")).await?;
2702 tokio::fs::write(sentinel.join("subdir").join("secret2.txt"), b"SECRET-2").await?;
2703
2704 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2705 let swapper =
2706 spawn_dir_symlink_swapper(root.to_path_buf(), sentinel.clone(), stop.clone());
2707
2708 let settings = Settings {
2709 fail_early: false,
2710 filter: None,
2711 dry_run: None,
2712 time_filter: None,
2713 };
2714 let mut removed = 0usize;
2715 let mut errored = 0usize;
2716 for i in 0..400 {
2717 let tree = root.join("tree");
2725 let _ = tokio::fs::create_dir(&tree).await;
2726 for d in 0..16 {
2727 let sib = tree.join(format!("sib_{d}"));
2728 let _ = tokio::fs::create_dir(&sib).await;
2729 for f in 0..4 {
2730 let _ = tokio::fs::write(sib.join(format!("f{f}.txt")), b"x").await;
2731 }
2732 }
2733 let sub = tree.join("sub");
2734 if i % 2 == 0 {
2735 let _ = tokio::fs::remove_dir_all(&sub).await;
2738 let _ = tokio::fs::remove_file(&sub).await;
2739 let _ = tokio::fs::symlink(&sentinel, &sub).await;
2740 } else if tokio::fs::symlink_metadata(&sub).await.is_err() {
2741 let _ = tokio::fs::create_dir(&sub).await;
2742 let _ = tokio::fs::write(sub.join("real.txt"), b"REAL").await;
2743 }
2744 let result = tokio::time::timeout(
2745 std::time::Duration::from_secs(30),
2746 super::rm(&RACE_PROGRESS, &tree, &settings),
2747 )
2748 .await
2749 .expect("rm must not hang under concurrent dir swapping");
2750 match result {
2751 Ok(_) => removed += 1,
2752 Err(_) => errored += 1, }
2754 assert!(
2759 sentinel.exists(),
2760 "iteration {i}: sentinel directory was deleted — rm followed the symlink"
2761 );
2762 let s1 = tokio::fs::read(sentinel.join("secret1.txt")).await;
2763 assert!(
2764 matches!(&s1, Ok(b) if b == b"SECRET-1"),
2765 "iteration {i}: sentinel/secret1.txt was deleted or altered — rm followed the symlink"
2766 );
2767 let s2 = tokio::fs::read(sentinel.join("subdir").join("secret2.txt")).await;
2768 assert!(
2769 matches!(&s2, Ok(b) if b == b"SECRET-2"),
2770 "iteration {i}: sentinel/subdir/secret2.txt was deleted — rm recursed through the symlink"
2771 );
2772 let _ = tokio::fs::remove_dir_all(root.join("tree")).await;
2774 }
2775
2776 stop.store(true, std::sync::atomic::Ordering::Relaxed);
2777 swapper.join().expect("dir swapper thread panicked");
2778 tracing::info!("intermediate dir swap: removed={removed}, errored={errored}");
2780 assert!(
2781 removed + errored > 0,
2782 "expected at least one observable outcome across the iterations"
2783 );
2784 Ok(())
2785 }
2786 }
2787}