common/rm.rs
1use anyhow::{Context, anyhow};
2use std::ffi::OsStr;
3use std::os::fd::{AsFd, OwnedFd};
4use std::os::unix::fs::PermissionsExt;
5use std::path::PathBuf;
6use std::sync::Arc;
7use tracing::instrument;
8
9use crate::filter::TimeFilter;
10use crate::progress;
11use crate::safedir::{self, Dir, Handle};
12use crate::walk::{EntryKind, LeafPermit, PermitKind};
13use crate::walk_driver::{
14 DirAction, DirPreResult, EntryCx, ProcessedChildren, WalkVisitor, process_entry,
15};
16
17/// Error type for remove operations. See [`crate::error::OperationError`] for
18/// logging conventions and rationale.
19pub type Error = crate::error::OperationError<Summary>;
20
21#[derive(Debug, Clone)]
22pub struct Settings {
23 pub fail_early: bool,
24 /// filter settings for include/exclude patterns
25 pub filter: Option<crate::filter::FilterSettings>,
26 /// time-based filter (mtime/btime); applied to each entry individually (files,
27 /// symlinks, and directories). This is an entry filter, not a subtree gate:
28 /// directories are always traversed, and the filter only decides whether each
29 /// entry — including the directory itself, after its children are processed — is
30 /// eligible for removal. A directory whose own timestamps are too recent is left
31 /// intact even when its children have been removed; a non-empty leftover directory
32 /// is logged at info and not treated as an error.
33 pub time_filter: Option<TimeFilter>,
34 /// dry-run mode for previewing operations
35 pub dry_run: Option<crate::config::DryRunMode>,
36}
37
38/// Returns true when `err`'s chain contains an `io::Error` with `ErrorKind::Unsupported`.
39/// Used to downgrade time-filter eval failures on filesystems / entry types that don't
40/// report btime (e.g. many symlinks) from `error!` to `warn!` so they don't flood logs
41/// on otherwise-successful runs.
42fn 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
50/// Summary with the appropriate `*_skipped` counter set to 1 for the given entry kind.
51/// Special files count as `files_skipped` to match the historical mapping used
52/// when filters skip an entry.
53fn 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
118/// RAII guard that restores a relaxed directory's original mode on drop, fd-pinned.
119///
120/// To clear a read-only directory's contents, [`RmVisitor::dir_pre`] chmod-relaxes it (via the directory's
121/// own `O_PATH` handle, before `open_dir`). If the directory is then retained (filter-protected
122/// children, time-filter skip, ENOTEMPTY) or any error occurs after the relax, the original mode
123/// must be restored — otherwise an `rrm` run leaves a protected tree world-readable/executable.
124/// Drop fires on every exit path (retain, error, panic-unwind) without callers having to remember
125/// it; the success-remove path calls [`Self::defuse`] because the directory no longer exists.
126///
127/// # Race safety
128///
129/// The guard holds a dup of the directory's `O_PATH` handle fd, and restores via
130/// [`safedir::chmod_via_proc_fd_sync`] — a chmod of the EXACT inode that fd pins, through its
131/// `/proc/self/fd/N` magic symlink. This is inode-exact and immune to a concurrent
132/// rename/symlink swap of the directory's name: there is no path re-resolution that an attacker
133/// could redirect. It is the synchronous counterpart of the relax (`chmod_via_proc_fd`), so the
134/// relax and the restore target the same pinned inode. This is the TOCTOU-safe replacement for
135/// the old path-based `std::fs::set_permissions(path, ...)` restore.
136///
137/// Drop runs synchronously (it can't be async), so it issues one ungated `fchmodat` — negligible
138/// cost, no need to round-trip through the tokio blocking pool just for cleanup. Best-effort: a
139/// restore failure is logged, not fatal.
140///
141/// # Lifecycle (leak-free even under fd exhaustion)
142///
143/// The guard is built in two steps so the relax can never outlive the ability to restore:
144/// 1. [`Self::prepare`] dups the `O_PATH` fd and records the original mode while leaving the guard
145/// *disarmed* (Drop is a no-op). This is the only fallible step (the dup) — and it runs BEFORE
146/// the relax chmod. If it fails (e.g. `EMFILE`), no relax has happened yet, so there is nothing
147/// to leak.
148/// 2. After the relax chmod succeeds, [`Self::arm`] flips the guard to active, so every subsequent
149/// exit path (retain, error, panic-unwind) restores the original mode.
150///
151/// The success-delete path calls [`Self::defuse`] (the directory no longer exists).
152struct RelaxedDirGuard {
153 /// Dup of the directory's `O_PATH` handle fd, pinning the inode to restore.
154 fd: OwnedFd,
155 /// Original mode to restore on drop. `None` means disarmed/defused (no restore).
156 mode: Option<u32>,
157}
158
159impl RelaxedDirGuard {
160 /// Dup the directory's `O_PATH` handle fd, returning a *disarmed* guard (Drop restores nothing
161 /// yet). Call this BEFORE relaxing the directory's mode so the restore fd is secured first; if
162 /// the dup fails (e.g. `EMFILE`) the caller must not relax — nothing to leak. Once the relax
163 /// chmod succeeds, call [`Self::arm`] with the original mode to make the restore fire on every
164 /// later exit path.
165 fn prepare(handle: &Handle) -> std::io::Result<Self> {
166 let fd = handle.as_fd().try_clone_to_owned()?;
167 Ok(Self { fd, mode: None })
168 }
169 /// Arm the guard so Drop restores `original_mode`. Called after the relax chmod succeeds.
170 fn arm(&mut self, original_mode: u32) {
171 self.mode = Some(original_mode);
172 }
173 /// Cancel the pending restore (call after successfully removing the directory).
174 fn defuse(&mut self) {
175 self.mode = None;
176 }
177}
178
179impl Drop for RelaxedDirGuard {
180 fn drop(&mut self) {
181 if let Some(mode) = self.mode.take()
182 && let Err(err) = safedir::chmod_via_proc_fd_sync(self.fd.as_fd(), mode)
183 {
184 tracing::warn!(
185 "failed to restore original permissions on retained directory (fd-pinned inode): {:#}",
186 err
187 );
188 }
189 }
190}
191
192/// Public entry point for remove operations.
193///
194/// The walk is fd-based (see [`crate::safedir`]): the root operand is opened relative to its
195/// parent directory and every entry is classified and removed through file-descriptor-relative
196/// syscalls. A privileged `rrm` therefore cannot be redirected by a concurrent symlink swap
197/// into deleting a tree outside the intended target (the classic `rm -rf` symlink race) — the
198/// `O_NOFOLLOW|O_DIRECTORY` opens in [`Dir::open_dir`] catch a directory→symlink swap mid-walk
199/// and fail closed (ELOOP/ENOTDIR) rather than descending the link, and leaf removal uses
200/// `unlinkat` (never follows a symlink). The recursive walk skeleton — enumeration, the
201/// leaf-permit lifecycle, spawning, the single drop-before-recurse site, and the error fold —
202/// lives in the generic [`crate::walk_driver`]; this module supplies the remove visitor.
203#[instrument(skip(prog_track, settings))]
204pub async fn rm(
205 prog_track: &'static progress::Progress,
206 path: &std::path::Path,
207 settings: &Settings,
208) -> Result<Summary, Error> {
209 // decompose the operand into (parent dir, final component) so the root entry is opened and
210 // classified relative to a directory fd — the same fd-relative shape every nested entry takes.
211 // `.`/`..` operands (e.g. `rrm .`) are canonicalized so they still name a directory; `/` is
212 // rejected. rm reads and removes "the" tree; we gate it on the Source side (matching the
213 // existing rm code, which probes its stat/readdir on Source and only the destructive
214 // unlink/rmdir on Destination).
215 let operand = crate::walk::split_root_operand(path)
216 .await
217 .map_err(|err| Error::new(err, Default::default()))?;
218 let parent_path = operand.parent.as_path();
219 let name = operand.name.as_os_str();
220 let path = operand.display.as_path();
221 // the operand's TRUSTED parent prefix is resolved following symlinks normally (the prefix is
222 // trusted up to and including the operand's container — only entries strictly below the named
223 // root are O_NOFOLLOW-hardened). a symlinked parent (e.g. `rrm symlinkdir/foo`) is followed; the
224 // operand itself is still classified via `child(name)` with O_NOFOLLOW (a symlink root is
225 // removed as the link itself).
226 let parent = Dir::open_parent_dir(parent_path, congestion::Side::Source)
227 .await
228 .with_context(|| format!("cannot open parent directory {parent_path:?}"))
229 .map_err(|err| Error::new(err, Default::default()))?;
230 // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below here).
231 let parent = Arc::new(parent.into_tree());
232 if let Some(ref filter) = settings.filter {
233 // the top-level include/exclude filter is checked against `path` itself (its file name);
234 // classify the root via its parent fd purely to evaluate the root filter. the driver
235 // re-classifies the root authoritatively in `process_entry`, so this handle is just a probe.
236 let root_handle = parent
237 .child(name)
238 .await
239 .with_context(|| format!("failed reading metadata from {path:?}"))
240 .map_err(|err| Error::new(err, Default::default()))?;
241 let name_path = std::path::Path::new(name);
242 let result =
243 filter.should_include_root_item(name_path, root_handle.kind() == EntryKind::Dir);
244 match result {
245 crate::filter::FilterResult::Included => {}
246 result => {
247 let kind = root_handle.kind();
248 if let Some(mode) = settings.dry_run {
249 crate::dry_run::report_skip(path, &result, mode, kind.label_long());
250 }
251 kind.inc_skipped(prog_track);
252 return Ok(skipped_summary_for(kind));
253 }
254 }
255 }
256 // the root entry's owned context: rel_path/filter_path empty (the root), real_path = the
257 // operand. rm has no delegated subtree, so `filter_path == rel_path`. The root is processed
258 // exactly like a nested child via `process_entry`, with no pre-acquired permit (it is not
259 // spawned from the backpressure-limited walk loop).
260 let visitor = Arc::new(RmVisitor {
261 prog_track,
262 settings: settings.clone(),
263 });
264 let root_cx = EntryCx {
265 parent: Arc::clone(&parent),
266 name: name.to_owned(),
267 rel_path: PathBuf::new(),
268 filter_path: PathBuf::new(),
269 real_path: path.to_path_buf(),
270 dry_run: settings.dry_run.is_some(),
271 prog_track,
272 };
273 process_entry(visitor, root_cx, (), None).await // rm has no second tree → root context `()`
274}
275
276/// Remove a single child entry of an already-open directory, fd-relative.
277///
278/// This is the fd-based counterpart of path-based [`rm`]. It is used by two callers that
279/// already hold the relevant directory as an open [`Dir`] and want to remove one of its children
280/// through that pinned fd rather than re-resolving the entry by absolute path (the redirectable
281/// window):
282/// - `--delete` pruning (see [`crate::delete::prune_extraneous`]) removes each extraneous entry.
283/// - the remote-copy destination (`rcpd`) replaces a non-matching destination subtree (a
284/// directory/file/symlink in the way of the entry it must create) through the parent directory
285/// fd held in its directory tracker.
286///
287/// The entry is classified via `parent.child(name)` (`O_PATH|O_NOFOLLOW`, so a symlink is
288/// classified as a symlink, never followed) and then removed through the same fd-relative remove
289/// machinery (driven by [`crate::walk_driver::process_entry`]): leaves via `unlinkat`, directories
290/// by recursing with `O_NOFOLLOW|O_DIRECTORY` descent and the fd-pinned relax guard. A privileged
291/// caller therefore cannot be redirected by a concurrent symlink swap of `name` into deleting a
292/// tree outside the directory it holds.
293///
294/// `rel_path` is the entry's path relative to the mirror/destination root: seeded as the root
295/// entry's `rel_path`/`filter_path`/`real_path`, it both anchors include/exclude filter matching
296/// against the entry's destination-root-relative path and reconstructs the entry's display path for
297/// diagnostics / dry-run output. The caller is responsible for the top-level decision on `name`
298/// (keep-set membership, exclude-protection, overwrite-recheck); this only removes the subtree.
299pub async fn rm_child(
300 prog_track: &'static progress::Progress,
301 parent: &Arc<Dir>,
302 name: &OsStr,
303 rel_path: &std::path::Path,
304 settings: &Settings,
305) -> Result<Summary, Error> {
306 // build the child's owned context rooted at `rel_path`: the display path and the filter path
307 // then each equal `rel_path` (the destination-root-relative path), anchoring include/exclude
308 // matching against the entry's full root-relative path, and each descendant extends it by one
309 // component (exactly the bare-roots behavior the old `WalkRoots { operand: "", filter: "" }`
310 // produced via `operand.join(rel_path)`). these paths are pure display/filter strings — they
311 // are never opened, so there is no dst path for an attacker to redirect.
312 let visitor = Arc::new(RmVisitor {
313 prog_track,
314 settings: settings.clone(),
315 });
316 let root_cx = EntryCx {
317 parent: Arc::clone(parent),
318 name: name.to_owned(),
319 rel_path: rel_path.to_path_buf(),
320 filter_path: rel_path.to_path_buf(),
321 real_path: rel_path.to_path_buf(),
322 dry_run: settings.dry_run.is_some(),
323 prog_track,
324 };
325 // a `rm_child` entry point is not spawned from the backpressure-limited walk loop, so it never
326 // pre-acquires a pending-meta permit. `process_entry` classifies `name` authoritatively via
327 // `child()` (a swap of `name` to a symlink is caught there — classified as a symlink leaf,
328 // never descended) and dispatches to the visitor.
329 process_entry(visitor, root_cx, (), None).await
330}
331/// The remove walk's [`WalkVisitor`]. The driver owns enumeration, the leaf-permit lifecycle,
332/// spawning, the single drop-before-recurse site, and the error fold; this visitor supplies rm's
333/// per-entry bodies (leaf removal in [`WalkVisitor::visit_leaf`], the relax-guard + metadata
334/// snapshot in [`WalkVisitor::dir_pre`], and the post-order `rmdir` decision in
335/// [`WalkVisitor::dir_post`]). rm has no second tree, so [`Self::DirContext`] is `()`.
336struct RmVisitor {
337 prog_track: &'static progress::Progress,
338 settings: Settings,
339}
340
341/// State threaded from [`WalkVisitor::dir_pre`] to [`WalkVisitor::dir_post`] (same task) — what the
342/// post-order `rmdir` decision needs.
343///
344/// The [`RelaxedDirGuard`] lives here precisely so the error path restores the relaxed mode: on a
345/// child failure the `?` after the contents walk in the driver's `process_entry` returns early
346/// (fail-early) or `dir_post` returns the combined error (keep-going) — either way `dir_post` never
347/// reaches its `defuse()`, so the guard's `Drop` restores the original directory mode (matching the
348/// old code's local `relaxed_guard` dropping on the early-return error paths).
349struct RmDirState {
350 /// Restores the relaxed directory mode on every retain/error path (fd-pinned, inode-exact);
351 /// `defuse`d only after a successful `rmdir`. `None` in dry-run (no relax happens) and when the
352 /// directory already had owner rwx (no relax needed).
353 guard: Option<RelaxedDirGuard>,
354 /// The directory's PRISTINE full metadata, snapshotted in `dir_pre` BEFORE the relax or any
355 /// child removal, so the directory's own time filter sees timestamps unperturbed by clearing
356 /// its contents (removing children bumps mtime). `Some` iff a time filter is configured.
357 time_metadata: Option<anyhow::Result<std::fs::Metadata>>,
358 /// Whether the directory does NOT directly match an include pattern while includes are active —
359 /// the path-only half of the "traversed only" decision. Combined in `dir_post` with
360 /// "nothing was removed" (derived from the children's folded summary) to reproduce the old
361 /// `traversed_only` exactly.
362 matches_no_include: bool,
363}
364
365impl WalkVisitor for RmVisitor {
366 type Summary = Summary;
367 type DirContext = ();
368 type DirState = RmDirState;
369
370 fn root_dir_context(&self) {}
371
372 fn permit_kind(&self) -> PermitKind {
373 // we use the pending-meta semaphore (not open-files) because rm is reachable from
374 // copy_file's overwrite path, which already holds an open-files permit; using a distinct
375 // semaphore avoids that cross-pool deadlock. rm holds no open fd across leaf work.
376 PermitKind::PendingMeta
377 }
378
379 fn want_permit(&self, hint: Option<EntryKind>) -> bool {
380 // pre-acquire only for a positively-known leaf hint. We deliberately skip pre-acquire when
381 // the getdents hint is unknown (DT_UNKNOWN -> None): the entry could actually be a
382 // directory, and a chain of such unknown-typed directories holding permits while recursing
383 // would deadlock the pending-meta pool. Directories also skip pre-acquire for the same
384 // reason. The authoritative type is still the child's fstat; this is only the hint.
385 hint.is_some_and(|ft| ft != EntryKind::Dir)
386 }
387
388 fn fail_early(&self) -> bool {
389 self.settings.fail_early
390 }
391
392 fn filter(&self) -> Option<&crate::filter::FilterSettings> {
393 self.settings.filter.as_ref()
394 }
395
396 fn on_skip(
397 &self,
398 cx: &EntryCx,
399 kind: EntryKind,
400 skip_result: &crate::filter::FilterResult,
401 ) -> Summary {
402 // mirror the old spawn loop's inline filter-skip: the dry-run "skip ..." line plus the
403 // matching `*_skipped` counter. the driver already did the shared progress increment.
404 if let Some(mode) = self.settings.dry_run {
405 crate::dry_run::report_skip(&cx.real_path, skip_result, mode, kind.label());
406 }
407 skipped_summary_for(kind)
408 }
409
410 async fn visit_leaf(
411 &self,
412 cx: &EntryCx,
413 _parent_ctx: &(),
414 handle: Handle,
415 kind: EntryKind,
416 permit: Option<LeafPermit>,
417 ) -> Result<Summary, Error> {
418 // leaf: hold the permit through the (non-recursive) removal, then drop on return (the
419 // driver drops it for directories, never here).
420 let _permit = permit;
421 let prog_track = self.prog_track;
422 let settings = &self.settings;
423 let parent = &cx.parent;
424 let name = cx.name.as_os_str();
425 let path = cx.real_path.as_path();
426 tracing::debug!("not a directory, just remove");
427 let is_symlink = kind == EntryKind::Symlink;
428 let file_size = if is_symlink {
429 0
430 } else {
431 crate::preserve::Metadata::size(handle.meta())
432 };
433 // apply time filter before removing (files/symlinks only)
434 if let Some(ref time_filter) = settings.time_filter {
435 let entry_type = if is_symlink { "symlink" } else { "file" };
436 let make_skipped_summary = || {
437 tracing::debug!("skipping {:?} due to time filter", &path);
438 if is_symlink {
439 prog_track.symlinks_skipped.inc();
440 Summary {
441 symlinks_skipped: 1,
442 ..Default::default()
443 }
444 } else {
445 prog_track.files_skipped.inc();
446 Summary {
447 files_skipped: 1,
448 ..Default::default()
449 }
450 }
451 };
452 // the time filter needs full std metadata (btime for --created-before), which the
453 // fd snapshot does not carry; read it inode-exact through the pinned handle so a
454 // concurrent swap of `name` cannot redirect the stat to a different inode.
455 let metadata =
456 match safedir::stat_meta_via_proc_fd(&handle, congestion::Side::Source).await {
457 Ok(md) => md,
458 Err(err) => {
459 let err = anyhow::Error::new(err).context(format!(
460 "failed reading metadata for time filter on {path:?}"
461 ));
462 if settings.fail_early {
463 return Err(Error::new(err, Default::default()));
464 }
465 // log and skip — never delete an entry whose age we cannot verify.
466 if is_unsupported_io_error(&err) {
467 tracing::warn!(
468 "time filter evaluation unsupported for {} {:?}, skipping: {:#}",
469 entry_type,
470 &path,
471 &err
472 );
473 } else {
474 tracing::error!(
475 "time filter evaluation failed for {} {:?}, skipping: {:#}",
476 entry_type,
477 &path,
478 &err
479 );
480 }
481 return Ok(make_skipped_summary());
482 }
483 };
484 match time_filter.matches(&metadata) {
485 Ok(result) => {
486 if let Some(skip_reason) = result.as_skip_reason() {
487 if let Some(mode) = settings.dry_run {
488 crate::dry_run::report_time_skip(path, skip_reason, mode, entry_type);
489 }
490 return Ok(make_skipped_summary());
491 }
492 }
493 Err(err) => {
494 let err = err.context(format!("failed evaluating time filter on {:?}", &path));
495 if settings.fail_early {
496 return Err(Error::new(err, Default::default()));
497 }
498 // log and skip — never delete an entry whose age we cannot verify.
499 // btime being unsupported (common for symlinks) is expected noise, so
500 // downgrade to warn; anything else is unexpected and stays at error.
501 if is_unsupported_io_error(&err) {
502 tracing::warn!(
503 "time filter evaluation unsupported for {} {:?}, skipping: {:#}",
504 entry_type,
505 &path,
506 &err
507 );
508 } else {
509 tracing::error!(
510 "time filter evaluation failed for {} {:?}, skipping: {:#}",
511 entry_type,
512 &path,
513 &err
514 );
515 }
516 return Ok(make_skipped_summary());
517 }
518 }
519 }
520 // handle dry-run mode for files/symlinks
521 if settings.dry_run.is_some() {
522 let entry_type = if is_symlink { "symlink" } else { "file" };
523 crate::dry_run::report_action("remove", path, None, entry_type);
524 return Ok(Summary {
525 bytes_removed: file_size,
526 files_removed: if is_symlink { 0 } else { 1 },
527 symlinks_removed: if is_symlink { 1 } else { 0 },
528 ..Default::default()
529 });
530 }
531 // fd-relative removal of the link/file itself: unlink_at never follows a symlink, and is
532 // resolved relative to the parent dir fd, so it cannot be redirected outside the tree.
533 // gated on the Destination side to match the side the path-based rm used for remove_file.
534 if let Err(err) = parent
535 .unlink_at_on(name, congestion::Side::Destination)
536 .await
537 .with_context(|| format!("failed removing {:?}", &path))
538 {
539 return Err(Error::new(err, Default::default()));
540 }
541 if is_symlink {
542 prog_track.symlinks_removed.inc();
543 return Ok(Summary {
544 symlinks_removed: 1,
545 ..Default::default()
546 });
547 }
548 prog_track.files_removed.inc();
549 prog_track.bytes_removed.add(file_size);
550 Ok(Summary {
551 bytes_removed: file_size,
552 files_removed: 1,
553 ..Default::default()
554 })
555 }
556
557 async fn dir_pre(&self, cx: &EntryCx, _parent_ctx: &(), handle: &Handle) -> DirPreResult<Self> {
558 let settings = &self.settings;
559 let path = cx.real_path.as_path();
560 tracing::debug!("remove contents of the directory first");
561 // Snapshot the directory's full metadata NOW, before relaxing its mode or removing any
562 // child. The directory's own time filter (evaluated in `dir_post`, after its children are
563 // processed) must use these pristine timestamps: removing children bumps the directory's
564 // mtime, so a fresh stat at the end would wrongly see the directory as "just modified".
565 // This mirrors the old code's `src_metadata` captured at entry. We read full std metadata
566 // (not just the fd snapshot) because `--created-before` needs btime; it's read inode-exact
567 // through the pinned handle. Only fetched when a time filter is configured. The relax below
568 // changes only mode/ctime, never mtime/btime, so taking the snapshot before it is correct.
569 let time_metadata: Option<anyhow::Result<std::fs::Metadata>> =
570 if settings.time_filter.is_some() {
571 Some(
572 safedir::stat_meta_via_proc_fd(handle, congestion::Side::Source)
573 .await
574 .map_err(|err| {
575 anyhow::Error::new(err).context(format!(
576 "failed reading metadata for time filter on {path:?}"
577 ))
578 }),
579 )
580 } else {
581 None
582 };
583 // the path-only half of the "traversed only" decision: a directory is "traversed only" when
584 // include filters are active and the directory itself doesn't directly match an include
585 // pattern (it was entered only to search for matching content). `dir_post` combines this
586 // with "nothing was removed". exclude-only filters never produce traversed-only directories
587 // because `directly_matches_include` returns true when no includes exist.
588 let matches_no_include = settings.filter.as_ref().is_some_and(|f| {
589 f.has_includes() && !f.directly_matches_include(&cx.filter_path, true)
590 });
591 // When the directory lacks owner write/execute we relax it so its contents can be cleared.
592 // The relax goes through the directory's OWN `O_PATH` handle (via /proc), which works even
593 // on a 0000-mode dir a non-root owner cannot open O_RDONLY — so it must happen BEFORE
594 // `open_dir`. The guard (carried in `RmDirState`) restores the original mode on Drop
595 // (fd-pinned, inode-exact) — covering every retain branch (filter-protected children,
596 // time-filter skip, ENOTEMPTY) AND every error path that returns before the directory is
597 // removed (the driver drops the state without calling `dir_post`, or `dir_post` returns the
598 // error before `defuse`). The success-remove path calls `defuse()` because the directory no
599 // longer exists. Dry-run skips the relax entirely, so no guard.
600 let mut guard: Option<RelaxedDirGuard> = None;
601 if settings.dry_run.is_none() {
602 let original_mode =
603 crate::preserve::Metadata::permissions(handle.meta()).mode() & 0o7777;
604 // relax unless the owner already has full r/w/x. read is needed to enumerate the dir
605 // (open_dir + getdents), and write+execute to unlink/rmdir its entries. 0o700 = u+rwx.
606 // this is a superset of the old `readonly()` (no-write) trigger, and also covers dirs
607 // missing owner read/execute that the old path-based read_dir would have failed on.
608 if original_mode & 0o700 != 0o700 {
609 tracing::debug!("directory is not writable/traversable - relax the permissions");
610 // SECURE the restore fd BEFORE relaxing: dup the O_PATH handle into a (still
611 // disarmed) guard first. The dup is the only fallible step and it runs while the dir
612 // still has its original restrictive mode — so if it fails (e.g. EMFILE under fd
613 // exhaustion) we return without ever relaxing, and there is no more-permissive mode
614 // left behind.
615 let mut g = RelaxedDirGuard::prepare(handle)
616 .with_context(|| {
617 format!("failed to set up permission-restore guard for {path:?}")
618 })
619 .map_err(|err| Error::new(err, Default::default()))?;
620 // gated as a Destination Chmod: the relax is a permission mutation that enables the
621 // removal, so it shares the destructive side/bucket with the unlink/rmdir below.
622 safedir::chmod_via_proc_fd(handle, congestion::Side::Destination, 0o700)
623 .await
624 .with_context(|| {
625 format!("failed to make {path:?} directory readable and writeable")
626 })
627 .map_err(|err| Error::new(err, Default::default()))?;
628 // relax succeeded: arm so Drop restores the original mode on every exit path below.
629 g.arm(original_mode);
630 guard = Some(g);
631 }
632 }
633 // open the directory's real fd via the parent fd with O_NOFOLLOW|O_DIRECTORY: a directory
634 // entry swapped to a symlink mid-walk fails closed here (ELOOP/ENOTDIR) — descent never
635 // follows it outside the tree. this is the core rm -rf race closure. an open failure
636 // returns the error with the guard still armed in scope, so the relaxed mode is restored on
637 // Drop (matching the old early-return error path).
638 let dir = cx
639 .parent
640 .open_dir(&cx.name)
641 .await
642 .with_context(|| format!("failed reading directory {path:?}"))
643 .map_err(|err| Error::new(err, Default::default()))?;
644 Ok(DirAction::Descend {
645 dir: Arc::new(dir),
646 child_ctx: (),
647 state: RmDirState {
648 guard,
649 time_metadata,
650 matches_no_include,
651 },
652 })
653 }
654
655 async fn dir_post(
656 &self,
657 cx: &EntryCx,
658 state: RmDirState,
659 _processed: &ProcessedChildren,
660 child_result: Result<Summary, Error>,
661 ) -> Result<Summary, Error> {
662 let prog_track = self.prog_track;
663 let settings = &self.settings;
664 let path = cx.real_path.as_path();
665 let RmDirState {
666 mut guard,
667 time_metadata,
668 matches_no_include,
669 } = state;
670 // a child failed (keep-going mode — fail-early aborts before `dir_post`). do NOT rmdir:
671 // return the combined error with the partial summary, and let `guard` drop to restore the
672 // relaxed mode (the old code's early-return-on-error left its local guard to do the same).
673 let mut rm_summary = match child_result {
674 Ok(summary) => summary,
675 Err(err) => return Err(err),
676 };
677 tracing::debug!("finally remove the empty directory");
678 let anything_removed = rm_summary.files_removed > 0
679 || rm_summary.symlinks_removed > 0
680 || rm_summary.directories_removed > 0;
681 let anything_skipped = rm_summary.files_skipped > 0
682 || rm_summary.symlinks_skipped > 0
683 || rm_summary.directories_skipped > 0;
684 // directories that directly match an include pattern (e.g. --include target/) should be
685 // removed even if empty; only those merely traversed for matches are left intact.
686 let traversed_only = !anything_removed && matches_no_include;
687 // evaluate the directory's own time filter to decide whether to remove it.
688 // the time filter is an entry filter, not a subtree gate: children are already handled
689 // by their own recursive calls, so this decision only controls the final rmdir.
690 // the metadata SNAPSHOT taken in `dir_pre` — before relaxing the mode or removing any
691 // child — is used so those mutations (which bump the directory's mtime) don't change the
692 // answer. this mirrors the old code's `src_metadata` captured at entry.
693 let dir_passes_time_filter: bool = if let Some(ref time_filter) = settings.time_filter {
694 // unwrap is safe: time_metadata is Some whenever time_filter is Some (both gated on
695 // settings.time_filter.is_some()).
696 let matched = time_metadata.unwrap().and_then(|md| {
697 time_filter.matches(&md).map_err(|err| {
698 err.context(format!("failed evaluating time filter on {path:?}"))
699 })
700 });
701 match matched {
702 Ok(result) => match result.as_skip_reason() {
703 Some(reason) => {
704 if let Some(mode) = settings.dry_run {
705 crate::dry_run::report_time_skip(path, reason, mode, "dir");
706 }
707 false
708 }
709 None => true,
710 },
711 Err(err) => {
712 if settings.fail_early {
713 return Err(Error::new(err, rm_summary));
714 }
715 // log and skip — never remove a directory whose age we cannot verify.
716 // btime being unsupported on the filesystem is expected noise; downgrade
717 // to warn. anything else is unexpected and stays at error.
718 if is_unsupported_io_error(&err) {
719 tracing::warn!(
720 "time filter evaluation unsupported for dir {:?}, leaving it intact: {:#}",
721 &path,
722 &err
723 );
724 } else {
725 tracing::error!(
726 "time filter evaluation failed for dir {:?}, leaving it intact: {:#}",
727 &path,
728 &err
729 );
730 }
731 false
732 }
733 }
734 } else {
735 true
736 };
737 // handle dry-run mode for directories.
738 // `traversed_only` catches dirs only entered to search for include pattern matches.
739 // `anything_skipped` catches dirs that would still have content after partial removal.
740 // `!dir_passes_time_filter` catches dirs whose own timestamps disqualify removal.
741 // the real-mode path below only needs `traversed_only` and `!dir_passes_time_filter`
742 // because the subsequent `rmdir_at` call handles the non-empty case via ENOTEMPTY.
743 if settings.dry_run.is_some() {
744 if traversed_only || anything_skipped || !dir_passes_time_filter {
745 tracing::debug!(
746 "dry-run: directory {:?} would not be removed (removed={}, skipped={}, time_ok={})",
747 &path,
748 anything_removed,
749 anything_skipped,
750 dir_passes_time_filter
751 );
752 if !dir_passes_time_filter {
753 prog_track.directories_skipped.inc();
754 rm_summary.directories_skipped += 1;
755 }
756 } else {
757 crate::dry_run::report_action("remove", path, None, "dir");
758 rm_summary.directories_removed += 1;
759 }
760 return Ok(rm_summary);
761 }
762 // skip directories that were only traversed to look for include matches.
763 // not needed for exclude-only filters or directly-matched directories.
764 // non-empty directories are handled by the ENOTEMPTY check below.
765 if traversed_only {
766 tracing::debug!(
767 "directory {:?} had nothing removed, leaving it intact",
768 &path
769 );
770 return Ok(rm_summary);
771 }
772 // skip directories whose own timestamps don't satisfy the time filter.
773 // children have already been processed; this only gates the dir's own removal.
774 if !dir_passes_time_filter {
775 tracing::debug!(
776 "directory {:?} skipped by time filter, leaving it intact",
777 &path
778 );
779 prog_track.directories_skipped.inc();
780 rm_summary.directories_skipped += 1;
781 return Ok(rm_summary);
782 }
783 // when filtering is active, directories may not be empty because we only removed
784 // matching files (includes) or skipped excluded files; use rmdir (not a recursive remove)
785 // so non-empty directories fail gracefully with ENOTEMPTY. the rmdir is fd-relative
786 // (resolved against the parent fd) so it cannot be redirected to a different directory.
787 // gated on the Destination side to match the side the path-based rm used for remove_dir.
788 let any_filter_active = settings.filter.is_some() || settings.time_filter.is_some();
789 match cx
790 .parent
791 .rmdir_at_on(&cx.name, congestion::Side::Destination)
792 .await
793 {
794 Ok(()) => {
795 prog_track.directories_removed.inc();
796 rm_summary.directories_removed += 1;
797 // the directory is gone, so there's nothing to restore on drop.
798 if let Some(guard) = guard.as_mut() {
799 guard.defuse();
800 }
801 }
802 Err(err) if any_filter_active => {
803 // with filtering, it's expected that directories may not be empty because we only
804 // removed matching files; raw_os_error 39 is ENOTEMPTY on Linux. this is not an
805 // error — surface it at info so users can see which directories survived.
806 if err.kind() == std::io::ErrorKind::DirectoryNotEmpty
807 || err.raw_os_error() == Some(39)
808 {
809 tracing::info!(
810 "directory {:?} not empty after filtering, leaving it intact",
811 &path
812 );
813 } else {
814 return Err(Error::new(
815 anyhow!(err).context(format!("failed removing directory {:?}", &path)),
816 rm_summary,
817 ));
818 }
819 }
820 Err(err) => {
821 return Err(Error::new(
822 anyhow!(err).context(format!("failed removing directory {:?}", &path)),
823 rm_summary,
824 ));
825 }
826 }
827 Ok(rm_summary)
828 }
829}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834 use crate::config::DryRunMode;
835 use crate::testutils;
836 use tracing_test::traced_test;
837
838 static PROGRESS: std::sync::LazyLock<progress::Progress> =
839 std::sync::LazyLock::new(progress::Progress::new);
840
841 #[tokio::test]
842 #[traced_test]
843 async fn no_write_permission() -> Result<(), anyhow::Error> {
844 let tmp_dir = testutils::setup_test_dir().await?;
845 let test_path = tmp_dir.as_path();
846 let filepaths = vec![
847 test_path.join("foo").join("0.txt"),
848 test_path.join("foo").join("bar").join("2.txt"),
849 test_path.join("foo").join("baz").join("4.txt"),
850 test_path.join("foo").join("baz"),
851 ];
852 for fpath in &filepaths {
853 // change file permissions to not readable and not writable
854 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o555)).await?;
855 }
856 let summary = rm(
857 &PROGRESS,
858 &test_path.join("foo"),
859 &Settings {
860 fail_early: false,
861 filter: None,
862 dry_run: None,
863 time_filter: None,
864 },
865 )
866 .await?;
867 assert!(!test_path.join("foo").exists());
868 assert_eq!(summary.files_removed, 5);
869 assert_eq!(summary.symlinks_removed, 2);
870 assert_eq!(summary.directories_removed, 3);
871 Ok(())
872 }
873
874 #[tokio::test]
875 #[traced_test]
876 async fn relaxed_dir_mode_restored_on_error_exit() -> Result<(), anyhow::Error> {
877 // Regression: when rm_internal chmod-relaxes a read-only directory to 0o777 to clear
878 // its contents, it must restore the original mode on ERROR paths too — not just on the
879 // retain paths (traversed-only, time-filtered skip, ENOTEMPTY under filter). Without
880 // that, a partial failure leaves the directory world-writable.
881 //
882 // We trigger the remove_dir error path by making the dst's PARENT non-writable: rm can
883 // chmod-relax dst and successfully unlink its contents (write on dst is granted by the
884 // relax), but the final remove_dir(dst) needs write permission on the parent, which it
885 // doesn't have → EACCES. The fix's inline restore at that error site must put dst back
886 // to 0o555 before propagating.
887 let tmp = tempfile::tempdir()?;
888 let parent = tmp.path().join("parent");
889 let dst = parent.join("dst");
890 tokio::fs::create_dir(&parent).await?;
891 tokio::fs::create_dir(&dst).await?;
892 tokio::fs::write(dst.join("inside.txt"), b"x").await?;
893 // dst is read-only → rm relaxes it.
894 tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o555)).await?;
895 // parent is read-only (still traversable via the execute bit) → remove_dir(dst) fails.
896 tokio::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).await?;
897
898 let result = rm(
899 &PROGRESS,
900 &dst,
901 &Settings {
902 fail_early: false,
903 filter: None,
904 dry_run: None,
905 time_filter: None,
906 },
907 )
908 .await;
909
910 // restore parent writability so we can stat dst and clean up regardless of the assertion.
911 tokio::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).await?;
912
913 assert!(
914 result.is_err(),
915 "rm must fail when its dst can be emptied but the parent dir blocks remove_dir"
916 );
917 let mode = tokio::fs::metadata(&dst).await?.permissions().mode() & 0o7777;
918 assert_eq!(
919 mode, 0o555,
920 "relaxed-then-erroring directory must be restored to its original mode (got {mode:o}o); leaving it 0o777 leaks permissions on partial failure"
921 );
922
923 // cleanup
924 tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o755)).await?;
925 Ok(())
926 }
927
928 #[tokio::test]
929 #[traced_test]
930 async fn parent_dir_no_write_permission() -> Result<(), anyhow::Error> {
931 let tmp_dir = testutils::setup_test_dir().await?;
932 let test_path = tmp_dir.as_path();
933 // make parent directory read-only (no write permission)
934 tokio::fs::set_permissions(
935 &test_path.join("foo").join("bar"),
936 std::fs::Permissions::from_mode(0o555),
937 )
938 .await?;
939 let result = rm(
940 &PROGRESS,
941 &test_path.join("foo").join("bar").join("2.txt"),
942 &Settings {
943 fail_early: true,
944 filter: None,
945 dry_run: None,
946 time_filter: None,
947 },
948 )
949 .await;
950 // should fail with permission denied error
951 assert!(result.is_err());
952 let err = result.unwrap_err();
953 let err_string = format!("{:#}", err);
954 // verify the error chain includes "Permission denied"
955 assert!(
956 err_string.contains("Permission denied") || err_string.contains("permission denied"),
957 "Error should contain 'Permission denied' but got: {}",
958 err_string
959 );
960 Ok(())
961 }
962
963 mod filter_tests {
964 use super::*;
965 use crate::filter::FilterSettings;
966 /// Test that path-based patterns (with /) work correctly with nested paths.
967 #[tokio::test]
968 #[traced_test]
969 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
970 let tmp_dir = testutils::setup_test_dir().await?;
971 let test_path = tmp_dir.as_path();
972 // create filter that should only remove files in bar/ directory
973 let mut filter = FilterSettings::new();
974 filter.add_include("bar/*.txt").unwrap();
975 let summary = rm(
976 &PROGRESS,
977 &test_path.join("foo"),
978 &Settings {
979 fail_early: false,
980 filter: Some(filter),
981 dry_run: None,
982 time_filter: None,
983 },
984 )
985 .await?;
986 // should only remove files matching bar/*.txt pattern (bar/1.txt, bar/2.txt, bar/3.txt)
987 assert_eq!(
988 summary.files_removed, 3,
989 "should remove 3 files matching bar/*.txt"
990 );
991 // each file is 1 byte ("1", "2", "3")
992 assert_eq!(summary.bytes_removed, 3, "should report 3 bytes removed");
993 // verify the right files were removed
994 assert!(
995 !test_path.join("foo/bar/1.txt").exists(),
996 "bar/1.txt should be removed"
997 );
998 assert!(
999 !test_path.join("foo/bar/2.txt").exists(),
1000 "bar/2.txt should be removed"
1001 );
1002 assert!(
1003 !test_path.join("foo/bar/3.txt").exists(),
1004 "bar/3.txt should be removed"
1005 );
1006 // verify files outside the pattern still exist
1007 assert!(
1008 test_path.join("foo/0.txt").exists(),
1009 "0.txt should still exist"
1010 );
1011 Ok(())
1012 }
1013 /// Test that filters are applied to top-level file arguments.
1014 #[tokio::test]
1015 #[traced_test]
1016 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
1017 let tmp_dir = testutils::setup_test_dir().await?;
1018 let test_path = tmp_dir.as_path();
1019 // create filter that excludes .txt files
1020 let mut filter = FilterSettings::new();
1021 filter.add_exclude("*.txt").unwrap();
1022 let summary = rm(
1023 &PROGRESS,
1024 &test_path.join("foo/0.txt"), // single file source
1025 &Settings {
1026 fail_early: false,
1027 filter: Some(filter),
1028 dry_run: None,
1029 time_filter: None,
1030 },
1031 )
1032 .await?;
1033 // the file should NOT be removed because it matches the exclude pattern
1034 assert_eq!(
1035 summary.files_removed, 0,
1036 "file matching exclude pattern should not be removed"
1037 );
1038 assert!(
1039 test_path.join("foo/0.txt").exists(),
1040 "excluded file should still exist"
1041 );
1042 Ok(())
1043 }
1044 /// Test that filters apply to root directories with simple exclude patterns.
1045 #[tokio::test]
1046 #[traced_test]
1047 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
1048 let test_path = testutils::create_temp_dir().await?;
1049 // create a directory that should be excluded
1050 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
1051 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
1052 // create filter that excludes *_dir/ directories
1053 let mut filter = FilterSettings::new();
1054 filter.add_exclude("*_dir/").unwrap();
1055 let result = rm(
1056 &PROGRESS,
1057 &test_path.join("excluded_dir"),
1058 &Settings {
1059 fail_early: false,
1060 filter: Some(filter),
1061 dry_run: None,
1062 time_filter: None,
1063 },
1064 )
1065 .await?;
1066 // directory should NOT be removed because it matches exclude pattern
1067 assert_eq!(
1068 result.directories_removed, 0,
1069 "root directory matching exclude should not be removed"
1070 );
1071 assert!(
1072 test_path.join("excluded_dir").exists(),
1073 "excluded root directory should still exist"
1074 );
1075 Ok(())
1076 }
1077 /// Test that filters apply to root symlinks with simple exclude patterns.
1078 #[tokio::test]
1079 #[traced_test]
1080 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
1081 let test_path = testutils::create_temp_dir().await?;
1082 // create a target file and a symlink to it
1083 tokio::fs::write(test_path.join("target.txt"), "content").await?;
1084 tokio::fs::symlink(
1085 test_path.join("target.txt"),
1086 test_path.join("excluded_link"),
1087 )
1088 .await?;
1089 // create filter that excludes *_link
1090 let mut filter = FilterSettings::new();
1091 filter.add_exclude("*_link").unwrap();
1092 let result = rm(
1093 &PROGRESS,
1094 &test_path.join("excluded_link"),
1095 &Settings {
1096 fail_early: false,
1097 filter: Some(filter),
1098 dry_run: None,
1099 time_filter: None,
1100 },
1101 )
1102 .await?;
1103 // symlink should NOT be removed because it matches exclude pattern
1104 assert_eq!(
1105 result.symlinks_removed, 0,
1106 "root symlink matching exclude should not be removed"
1107 );
1108 assert!(
1109 test_path.join("excluded_link").exists(),
1110 "excluded root symlink should still exist"
1111 );
1112 Ok(())
1113 }
1114 /// Test combined include and exclude patterns (exclude takes precedence).
1115 #[tokio::test]
1116 #[traced_test]
1117 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
1118 let tmp_dir = testutils::setup_test_dir().await?;
1119 let test_path = tmp_dir.as_path();
1120 // test structure from setup_test_dir:
1121 // foo/
1122 // 0.txt
1123 // bar/ (1.txt, 2.txt, 3.txt)
1124 // baz/ (4.txt, 5.txt symlink, 6.txt symlink)
1125 // include all .txt files in bar/, but exclude 2.txt specifically
1126 let mut filter = FilterSettings::new();
1127 filter.add_include("bar/*.txt").unwrap();
1128 filter.add_exclude("bar/2.txt").unwrap();
1129 let summary = rm(
1130 &PROGRESS,
1131 &test_path.join("foo"),
1132 &Settings {
1133 fail_early: false,
1134 filter: Some(filter),
1135 dry_run: None,
1136 time_filter: None,
1137 },
1138 )
1139 .await?;
1140 // should remove: bar/1.txt, bar/3.txt = 2 files
1141 // should skip: bar/2.txt (excluded by pattern), 0.txt (excluded by default - no match) = 2 files
1142 assert_eq!(summary.files_removed, 2, "should remove 2 files");
1143 assert_eq!(
1144 summary.files_skipped, 2,
1145 "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
1146 );
1147 // verify
1148 assert!(
1149 !test_path.join("foo/bar/1.txt").exists(),
1150 "bar/1.txt should be removed"
1151 );
1152 assert!(
1153 test_path.join("foo/bar/2.txt").exists(),
1154 "bar/2.txt should be excluded"
1155 );
1156 assert!(
1157 !test_path.join("foo/bar/3.txt").exists(),
1158 "bar/3.txt should be removed"
1159 );
1160 Ok(())
1161 }
1162 /// Test that skipped counts accurately reflect what was filtered.
1163 #[tokio::test]
1164 #[traced_test]
1165 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
1166 let tmp_dir = testutils::setup_test_dir().await?;
1167 let test_path = tmp_dir.as_path();
1168 // test structure from setup_test_dir:
1169 // foo/
1170 // 0.txt
1171 // bar/ (1.txt, 2.txt, 3.txt)
1172 // baz/ (4.txt, 5.txt symlink, 6.txt symlink)
1173 // exclude bar/ directory entirely
1174 let mut filter = FilterSettings::new();
1175 filter.add_exclude("bar/").unwrap();
1176 let summary = rm(
1177 &PROGRESS,
1178 &test_path.join("foo"),
1179 &Settings {
1180 fail_early: false,
1181 filter: Some(filter),
1182 dry_run: None,
1183 time_filter: None,
1184 },
1185 )
1186 .await?;
1187 // removed: 0.txt, baz/4.txt = 2 files
1188 // removed: baz/5.txt symlink, baz/6.txt symlink = 2 symlinks
1189 // removed: baz = 1 directory (foo cannot be removed because bar still exists)
1190 // skipped: bar directory (1 dir) - contents not counted since whole dir skipped
1191 assert_eq!(summary.files_removed, 2, "should remove 2 files");
1192 assert_eq!(summary.symlinks_removed, 2, "should remove 2 symlinks");
1193 assert_eq!(
1194 summary.directories_removed, 1,
1195 "should remove 1 directory (baz only, foo not empty)"
1196 );
1197 assert_eq!(
1198 summary.directories_skipped, 1,
1199 "should skip 1 directory (bar)"
1200 );
1201 // bar should still exist
1202 assert!(
1203 test_path.join("foo/bar").exists(),
1204 "bar directory should still exist"
1205 );
1206 // foo should still exist (not empty because bar is still there)
1207 assert!(
1208 test_path.join("foo").exists(),
1209 "foo directory should still exist (contains bar)"
1210 );
1211 Ok(())
1212 }
1213 /// Test that empty directories are not removed when they were only traversed to look
1214 /// for matches (regression test for bug where --include='foo' would remove empty dir baz).
1215 #[tokio::test]
1216 #[traced_test]
1217 async fn test_empty_dir_not_removed_when_only_traversed() -> Result<(), anyhow::Error> {
1218 let test_path = testutils::create_temp_dir().await?;
1219 // create structure:
1220 // test/
1221 // foo (file)
1222 // bar (file)
1223 // baz/ (empty directory)
1224 tokio::fs::write(test_path.join("foo"), "content").await?;
1225 tokio::fs::write(test_path.join("bar"), "content").await?;
1226 tokio::fs::create_dir(test_path.join("baz")).await?;
1227 // include only 'foo' file
1228 let mut filter = FilterSettings::new();
1229 filter.add_include("foo").unwrap();
1230 let summary = rm(
1231 &PROGRESS,
1232 &test_path,
1233 &Settings {
1234 fail_early: false,
1235 filter: Some(filter),
1236 dry_run: None,
1237 time_filter: None,
1238 },
1239 )
1240 .await?;
1241 // only 'foo' should be removed
1242 assert_eq!(summary.files_removed, 1, "should remove only 'foo' file");
1243 assert_eq!(
1244 summary.directories_removed, 0,
1245 "should NOT remove empty 'baz' directory"
1246 );
1247 // verify foo was removed
1248 assert!(!test_path.join("foo").exists(), "foo should be removed");
1249 // verify bar still exists (not matching include pattern)
1250 assert!(test_path.join("bar").exists(), "bar should still exist");
1251 // verify empty baz directory still exists
1252 assert!(
1253 test_path.join("baz").exists(),
1254 "empty baz directory should NOT be removed"
1255 );
1256 Ok(())
1257 }
1258 /// Test that empty directories ARE removed with exclude-only filters.
1259 /// Unlike include filters (where empty dirs are only traversed for matches),
1260 /// exclude-only filters should not prevent removal of empty directories.
1261 #[tokio::test]
1262 #[traced_test]
1263 async fn test_exclude_only_removes_empty_directory() -> Result<(), anyhow::Error> {
1264 let test_path = testutils::create_temp_dir().await?;
1265 // create structure:
1266 // test/
1267 // foo (file)
1268 // bar.log (file)
1269 // baz/ (empty directory)
1270 tokio::fs::write(test_path.join("foo"), "content").await?;
1271 tokio::fs::write(test_path.join("bar.log"), "content").await?;
1272 tokio::fs::create_dir(test_path.join("baz")).await?;
1273 // exclude only .log files
1274 let mut filter = FilterSettings::new();
1275 filter.add_exclude("*.log").unwrap();
1276 let summary = rm(
1277 &PROGRESS,
1278 &test_path,
1279 &Settings {
1280 fail_early: false,
1281 filter: Some(filter),
1282 dry_run: None,
1283 time_filter: None,
1284 },
1285 )
1286 .await?;
1287 // foo should be removed, bar.log should be skipped, baz/ should be removed
1288 assert_eq!(summary.files_removed, 1, "should remove 'foo'");
1289 assert_eq!(summary.files_skipped, 1, "should skip 'bar.log'");
1290 assert_eq!(
1291 summary.directories_removed, 1,
1292 "should remove empty 'baz' directory"
1293 );
1294 assert!(!test_path.join("foo").exists(), "foo should be removed");
1295 assert!(
1296 test_path.join("bar.log").exists(),
1297 "bar.log should still exist"
1298 );
1299 assert!(
1300 !test_path.join("baz").exists(),
1301 "empty baz directory should be removed"
1302 );
1303 Ok(())
1304 }
1305 /// Test that empty directories are not removed in dry-run mode when only traversed.
1306 #[tokio::test]
1307 #[traced_test]
1308 async fn test_dry_run_empty_dir_not_reported_as_removed() -> Result<(), anyhow::Error> {
1309 let test_path = testutils::create_temp_dir().await?;
1310 // create structure:
1311 // test/
1312 // foo (file)
1313 // bar (file)
1314 // baz/ (empty directory)
1315 tokio::fs::write(test_path.join("foo"), "content").await?;
1316 tokio::fs::write(test_path.join("bar"), "content").await?;
1317 tokio::fs::create_dir(test_path.join("baz")).await?;
1318 // include only 'foo' file
1319 let mut filter = FilterSettings::new();
1320 filter.add_include("foo").unwrap();
1321 let summary = rm(
1322 &PROGRESS,
1323 &test_path,
1324 &Settings {
1325 fail_early: false,
1326 filter: Some(filter),
1327 dry_run: Some(DryRunMode::Explain),
1328 time_filter: None,
1329 },
1330 )
1331 .await?;
1332 // only 'foo' should be reported as would-be-removed
1333 assert_eq!(
1334 summary.files_removed, 1,
1335 "should report only 'foo' would be removed"
1336 );
1337 assert_eq!(
1338 summary.directories_removed, 0,
1339 "should NOT report empty 'baz' would be removed"
1340 );
1341 // verify nothing was actually removed (dry-run mode)
1342 assert!(test_path.join("foo").exists(), "foo should still exist");
1343 assert!(test_path.join("bar").exists(), "bar should still exist");
1344 assert!(test_path.join("baz").exists(), "baz should still exist");
1345 Ok(())
1346 }
1347 /// Test that an empty directory directly matching an include pattern IS removed.
1348 /// Unlike traversed-only directories, directly matched ones are explicit targets.
1349 #[tokio::test]
1350 #[traced_test]
1351 async fn test_include_directly_matched_empty_dir_is_removed() -> Result<(), anyhow::Error> {
1352 let test_path = testutils::create_temp_dir().await?;
1353 // create structure:
1354 // test/
1355 // foo (file)
1356 // baz/ (empty directory)
1357 tokio::fs::write(test_path.join("foo"), "content").await?;
1358 tokio::fs::create_dir(test_path.join("baz")).await?;
1359 // include pattern that directly matches the directory
1360 let mut filter = FilterSettings::new();
1361 filter.add_include("baz/").unwrap();
1362 let summary = rm(
1363 &PROGRESS,
1364 &test_path,
1365 &Settings {
1366 fail_early: false,
1367 filter: Some(filter),
1368 dry_run: None,
1369 time_filter: None,
1370 },
1371 )
1372 .await?;
1373 assert_eq!(
1374 summary.directories_removed, 1,
1375 "should remove directly matched empty 'baz' directory"
1376 );
1377 assert_eq!(summary.files_removed, 0, "should not remove 'foo'");
1378 assert!(test_path.join("foo").exists(), "foo should still exist");
1379 assert!(
1380 !test_path.join("baz").exists(),
1381 "directly matched empty baz directory should be removed"
1382 );
1383 Ok(())
1384 }
1385 }
1386 mod dry_run_tests {
1387 use super::*;
1388 use crate::filter::FilterSettings;
1389 /// Test that dry-run mode doesn't modify permissions on read-only directories.
1390 #[tokio::test]
1391 #[traced_test]
1392 async fn test_dry_run_preserves_readonly_permissions() -> Result<(), anyhow::Error> {
1393 let tmp_dir = testutils::setup_test_dir().await?;
1394 let test_path = tmp_dir.as_path();
1395 let readonly_dir = test_path.join("foo/bar");
1396 // make the directory read-only
1397 tokio::fs::set_permissions(&readonly_dir, std::fs::Permissions::from_mode(0o555))
1398 .await?;
1399 // verify it's read-only
1400 let before_mode = tokio::fs::metadata(&readonly_dir)
1401 .await?
1402 .permissions()
1403 .mode()
1404 & 0o777;
1405 assert_eq!(
1406 before_mode, 0o555,
1407 "directory should be read-only before dry-run"
1408 );
1409 let summary = rm(
1410 &PROGRESS,
1411 &readonly_dir,
1412 &Settings {
1413 fail_early: false,
1414 filter: None,
1415 dry_run: Some(DryRunMode::Brief),
1416 time_filter: None,
1417 },
1418 )
1419 .await?;
1420 // verify the directory still exists (dry-run shouldn't remove it)
1421 assert!(
1422 readonly_dir.exists(),
1423 "directory should still exist after dry-run"
1424 );
1425 // verify permissions weren't changed
1426 let after_mode = tokio::fs::metadata(&readonly_dir)
1427 .await?
1428 .permissions()
1429 .mode()
1430 & 0o777;
1431 assert_eq!(
1432 after_mode, 0o555,
1433 "dry-run should not modify directory permissions"
1434 );
1435 // verify summary shows what would be removed
1436 assert!(
1437 summary.directories_removed > 0 || summary.files_removed > 0,
1438 "dry-run should report what would be removed"
1439 );
1440 Ok(())
1441 }
1442 /// Test that dry-run mode with filtering correctly handles directories that
1443 /// wouldn't be empty after filtering.
1444 #[tokio::test]
1445 #[traced_test]
1446 async fn test_dry_run_with_filter_non_empty_directory() -> Result<(), anyhow::Error> {
1447 let tmp_dir = testutils::setup_test_dir().await?;
1448 let test_path = tmp_dir.as_path();
1449 // test structure from setup_test_dir:
1450 // foo/
1451 // 0.txt
1452 // bar/ (1.txt, 2.txt, 3.txt)
1453 // baz/ (4.txt, 5.txt symlink, 6.txt symlink)
1454 // exclude bar/ - so foo would not be empty after removing (bar still there)
1455 let mut filter = crate::filter::FilterSettings::new();
1456 filter.add_exclude("bar/").unwrap();
1457 let summary = rm(
1458 &PROGRESS,
1459 &test_path.join("foo"),
1460 &Settings {
1461 fail_early: false,
1462 filter: Some(filter),
1463 dry_run: Some(DryRunMode::Brief),
1464 time_filter: None,
1465 },
1466 )
1467 .await?;
1468 // dry-run shouldn't actually remove anything
1469 assert!(
1470 test_path.join("foo").exists(),
1471 "foo should still exist after dry-run"
1472 );
1473 // verify summary reflects what WOULD happen:
1474 // - files: 0.txt, baz/4.txt would be removed = 2
1475 // - symlinks: baz/5.txt, baz/6.txt would be removed = 2
1476 // - directories: baz would be removed, but NOT foo (bar is skipped, so foo not empty)
1477 // - skipped: bar directory = 1
1478 assert_eq!(
1479 summary.files_removed, 2,
1480 "should report 2 files would be removed"
1481 );
1482 assert_eq!(
1483 summary.symlinks_removed, 2,
1484 "should report 2 symlinks would be removed"
1485 );
1486 assert_eq!(
1487 summary.directories_removed, 1,
1488 "should report only baz (not foo) would be removed"
1489 );
1490 assert_eq!(
1491 summary.directories_skipped, 1,
1492 "should report bar directory skipped"
1493 );
1494 Ok(())
1495 }
1496 /// Test that dry-run with exclude-only filter correctly reports empty directories
1497 /// as would-be-removed (unlike include filters where empty dirs are only traversed).
1498 #[tokio::test]
1499 #[traced_test]
1500 async fn test_dry_run_exclude_only_reports_empty_dir_removed() -> Result<(), anyhow::Error>
1501 {
1502 let test_path = testutils::create_temp_dir().await?;
1503 // create structure:
1504 // test/
1505 // foo (file)
1506 // bar.log (file)
1507 // baz/ (empty directory)
1508 tokio::fs::write(test_path.join("foo"), "content").await?;
1509 tokio::fs::write(test_path.join("bar.log"), "content").await?;
1510 tokio::fs::create_dir(test_path.join("baz")).await?;
1511 // exclude only .log files
1512 let mut filter = FilterSettings::new();
1513 filter.add_exclude("*.log").unwrap();
1514 let summary = rm(
1515 &PROGRESS,
1516 &test_path,
1517 &Settings {
1518 fail_early: false,
1519 filter: Some(filter),
1520 dry_run: Some(DryRunMode::Explain),
1521 time_filter: None,
1522 },
1523 )
1524 .await?;
1525 // foo should be reported as would-be-removed, bar.log skipped, baz/ removed
1526 assert_eq!(
1527 summary.files_removed, 1,
1528 "should report 'foo' would be removed"
1529 );
1530 assert_eq!(
1531 summary.files_skipped, 1,
1532 "should report 'bar.log' would be skipped"
1533 );
1534 assert_eq!(
1535 summary.directories_removed, 1,
1536 "should report empty 'baz' directory would be removed"
1537 );
1538 // verify nothing was actually removed (dry-run mode)
1539 assert!(test_path.join("foo").exists(), "foo should still exist");
1540 assert!(
1541 test_path.join("bar.log").exists(),
1542 "bar.log should still exist"
1543 );
1544 assert!(test_path.join("baz").exists(), "baz should still exist");
1545 Ok(())
1546 }
1547 /// Test that dry-run correctly reports removal of an empty directory that directly
1548 /// matches an include pattern (not merely traversed).
1549 #[tokio::test]
1550 #[traced_test]
1551 async fn test_dry_run_include_directly_matched_empty_dir_reported()
1552 -> Result<(), anyhow::Error> {
1553 let test_path = testutils::create_temp_dir().await?;
1554 // create structure:
1555 // test/
1556 // foo (file)
1557 // baz/ (empty directory)
1558 tokio::fs::write(test_path.join("foo"), "content").await?;
1559 tokio::fs::create_dir(test_path.join("baz")).await?;
1560 // include pattern that directly matches the directory
1561 let mut filter = FilterSettings::new();
1562 filter.add_include("baz/").unwrap();
1563 let summary = rm(
1564 &PROGRESS,
1565 &test_path,
1566 &Settings {
1567 fail_early: false,
1568 filter: Some(filter),
1569 dry_run: Some(DryRunMode::Explain),
1570 time_filter: None,
1571 },
1572 )
1573 .await?;
1574 assert_eq!(
1575 summary.directories_removed, 1,
1576 "should report directly matched empty 'baz' would be removed"
1577 );
1578 assert_eq!(summary.files_removed, 0, "should not report 'foo'");
1579 // verify nothing was actually removed (dry-run mode)
1580 assert!(test_path.join("foo").exists(), "foo should still exist");
1581 assert!(test_path.join("baz").exists(), "baz should still exist");
1582 Ok(())
1583 }
1584 }
1585 mod time_filter_tests {
1586 use super::*;
1587 use crate::filter::TimeFilter;
1588
1589 fn set_mtime_age(path: &std::path::Path, age: std::time::Duration) -> anyhow::Result<()> {
1590 let past = filetime::FileTime::from_system_time(std::time::SystemTime::now() - age);
1591 filetime::set_file_mtime(path, past)?;
1592 Ok(())
1593 }
1594
1595 /// File with mtime older than threshold is removed.
1596 #[tokio::test]
1597 #[traced_test]
1598 async fn removes_files_older_than_modified_before() -> Result<(), anyhow::Error> {
1599 let test_path = testutils::create_temp_dir().await?;
1600 let file = test_path.join("old.txt");
1601 tokio::fs::write(&file, "x").await?;
1602 set_mtime_age(&file, std::time::Duration::from_secs(7200))?;
1603 // age test_path so the root dir passes its own time filter check
1604 set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1605 let summary = rm(
1606 &PROGRESS,
1607 &test_path,
1608 &Settings {
1609 fail_early: false,
1610 filter: None,
1611 time_filter: Some(TimeFilter {
1612 modified_before: Some(std::time::Duration::from_secs(3600)),
1613 created_before: None,
1614 }),
1615 dry_run: None,
1616 },
1617 )
1618 .await?;
1619 assert_eq!(summary.files_removed, 1, "old file should be removed");
1620 assert_eq!(summary.files_skipped, 0);
1621 assert!(!file.exists(), "old.txt should be removed");
1622 Ok(())
1623 }
1624
1625 /// File with mtime newer than threshold is skipped.
1626 #[tokio::test]
1627 #[traced_test]
1628 async fn keeps_files_newer_than_modified_before() -> Result<(), anyhow::Error> {
1629 let test_path = testutils::create_temp_dir().await?;
1630 let file = test_path.join("new.txt");
1631 tokio::fs::write(&file, "x").await?;
1632 set_mtime_age(&file, std::time::Duration::from_secs(60))?;
1633 set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1634 let summary = rm(
1635 &PROGRESS,
1636 &test_path,
1637 &Settings {
1638 fail_early: false,
1639 filter: None,
1640 time_filter: Some(TimeFilter {
1641 modified_before: Some(std::time::Duration::from_secs(3600)),
1642 created_before: None,
1643 }),
1644 dry_run: None,
1645 },
1646 )
1647 .await?;
1648 assert_eq!(summary.files_removed, 0, "new file should not be removed");
1649 assert_eq!(summary.files_skipped, 1, "new file should be skipped");
1650 assert!(file.exists(), "new.txt should still exist");
1651 Ok(())
1652 }
1653
1654 /// A fresh subdirectory is descended into (children are handled individually),
1655 /// but the fresh_dir itself is not removed because its own mtime is too recent.
1656 #[tokio::test]
1657 #[traced_test]
1658 async fn fresh_subdirectory_is_descended_but_not_removed() -> Result<(), anyhow::Error> {
1659 let test_path = testutils::create_temp_dir().await?;
1660 let old_file = test_path.join("old.txt");
1661 let fresh_dir = test_path.join("fresh_dir");
1662 let fresh_child = fresh_dir.join("fresh_child.txt");
1663 let old_child = fresh_dir.join("old_child.txt");
1664 tokio::fs::write(&old_file, "x").await?;
1665 tokio::fs::create_dir(&fresh_dir).await?;
1666 tokio::fs::write(&fresh_child, "x").await?;
1667 tokio::fs::write(&old_child, "x").await?;
1668 set_mtime_age(&old_file, std::time::Duration::from_secs(7200))?;
1669 set_mtime_age(&old_child, std::time::Duration::from_secs(7200))?;
1670 // fresh_child keeps its recent mtime; so does fresh_dir (we took the mtime
1671 // snapshot before remove_file mutates it)
1672 set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1673 let summary = rm(
1674 &PROGRESS,
1675 &test_path,
1676 &Settings {
1677 fail_early: false,
1678 filter: None,
1679 time_filter: Some(TimeFilter {
1680 modified_before: Some(std::time::Duration::from_secs(3600)),
1681 created_before: None,
1682 }),
1683 dry_run: None,
1684 },
1685 )
1686 .await?;
1687 // we descend into fresh_dir: old_child removed, fresh_child skipped
1688 assert_eq!(summary.files_removed, 2, "old.txt and old_child removed");
1689 assert_eq!(
1690 summary.files_skipped, 1,
1691 "fresh_child skipped inside fresh_dir"
1692 );
1693 assert_eq!(
1694 summary.directories_skipped, 1,
1695 "fresh_dir itself is skipped at removal time"
1696 );
1697 assert_eq!(
1698 summary.directories_removed, 0,
1699 "root survives because fresh_dir is still inside it"
1700 );
1701 assert!(!old_file.exists());
1702 assert!(!old_child.exists(), "old_child inside fresh_dir removed");
1703 assert!(
1704 fresh_dir.exists(),
1705 "fresh_dir kept despite its old child being removed"
1706 );
1707 assert!(fresh_child.exists(), "fresh_child inside fresh_dir kept");
1708 Ok(())
1709 }
1710
1711 /// An old directory that still holds a new (skipped) file survives as non-empty.
1712 /// The leftover-dir case is not treated as an error.
1713 #[tokio::test]
1714 #[traced_test]
1715 async fn old_dir_with_new_file_leaves_non_empty_dir_without_error()
1716 -> Result<(), anyhow::Error> {
1717 let test_path = testutils::create_temp_dir().await?;
1718 let old_dir = test_path.join("old_dir");
1719 tokio::fs::create_dir(&old_dir).await?;
1720 let new_file = old_dir.join("new.txt");
1721 tokio::fs::write(&new_file, "x").await?;
1722 set_mtime_age(&new_file, std::time::Duration::from_secs(60))?;
1723 set_mtime_age(&old_dir, std::time::Duration::from_secs(7200))?;
1724 set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1725 let result = rm(
1726 &PROGRESS,
1727 &test_path,
1728 &Settings {
1729 fail_early: false,
1730 filter: None,
1731 time_filter: Some(TimeFilter {
1732 modified_before: Some(std::time::Duration::from_secs(3600)),
1733 created_before: None,
1734 }),
1735 dry_run: None,
1736 },
1737 )
1738 .await;
1739 let summary = result.expect("ENOTEMPTY should not surface as an error");
1740 assert_eq!(summary.files_skipped, 1, "new file should be skipped");
1741 assert_eq!(
1742 summary.directories_removed, 0,
1743 "old_dir cannot be removed while new.txt remains"
1744 );
1745 assert!(old_dir.exists(), "old_dir should still exist");
1746 assert!(new_file.exists(), "new.txt should still exist");
1747 // the 'left intact' message is logged at info level
1748 assert!(
1749 logs_contain("not empty after filtering, leaving it intact"),
1750 "should log ENOTEMPTY case at info"
1751 );
1752 Ok(())
1753 }
1754
1755 /// An old, already-empty directory is removed by the time filter run.
1756 #[tokio::test]
1757 #[traced_test]
1758 async fn old_empty_directory_is_removed() -> Result<(), anyhow::Error> {
1759 let test_path = testutils::create_temp_dir().await?;
1760 let old_empty = test_path.join("old_empty");
1761 tokio::fs::create_dir(&old_empty).await?;
1762 set_mtime_age(&old_empty, std::time::Duration::from_secs(7200))?;
1763 set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1764 let summary = rm(
1765 &PROGRESS,
1766 &test_path,
1767 &Settings {
1768 fail_early: false,
1769 filter: None,
1770 time_filter: Some(TimeFilter {
1771 modified_before: Some(std::time::Duration::from_secs(3600)),
1772 created_before: None,
1773 }),
1774 dry_run: None,
1775 },
1776 )
1777 .await?;
1778 // both old_empty and test_path itself are removed
1779 assert_eq!(summary.directories_removed, 2);
1780 assert!(!old_empty.exists());
1781 assert!(!test_path.exists());
1782 Ok(())
1783 }
1784
1785 /// Time filter combines with glob exclude — both must pass for removal.
1786 #[tokio::test]
1787 #[traced_test]
1788 async fn time_filter_combines_with_glob_exclude() -> Result<(), anyhow::Error> {
1789 let test_path = testutils::create_temp_dir().await?;
1790 let old_keep = test_path.join("keep.log");
1791 let old_drop = test_path.join("drop.txt");
1792 let new_drop = test_path.join("recent.txt");
1793 tokio::fs::write(&old_keep, "x").await?;
1794 tokio::fs::write(&old_drop, "x").await?;
1795 tokio::fs::write(&new_drop, "x").await?;
1796 set_mtime_age(&old_keep, std::time::Duration::from_secs(7200))?;
1797 set_mtime_age(&old_drop, std::time::Duration::from_secs(7200))?;
1798 set_mtime_age(&new_drop, std::time::Duration::from_secs(60))?;
1799 set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1800 let mut filter = crate::filter::FilterSettings::new();
1801 filter.add_exclude("*.log").unwrap();
1802 let summary = rm(
1803 &PROGRESS,
1804 &test_path,
1805 &Settings {
1806 fail_early: false,
1807 filter: Some(filter),
1808 time_filter: Some(TimeFilter {
1809 modified_before: Some(std::time::Duration::from_secs(3600)),
1810 created_before: None,
1811 }),
1812 dry_run: None,
1813 },
1814 )
1815 .await?;
1816 // only old_drop passes both filters
1817 assert_eq!(summary.files_removed, 1, "only old_drop should be removed");
1818 assert_eq!(
1819 summary.files_skipped, 2,
1820 "old_keep and recent_drop should be skipped"
1821 );
1822 assert!(
1823 old_keep.exists(),
1824 "keep.log excluded by glob, should remain"
1825 );
1826 assert!(!old_drop.exists(), "drop.txt should be removed");
1827 assert!(new_drop.exists(), "recent.txt should remain (too new)");
1828 Ok(())
1829 }
1830
1831 /// Dry-run with time filter previews removal without modifying files.
1832 #[tokio::test]
1833 #[traced_test]
1834 async fn time_filter_with_dry_run() -> Result<(), anyhow::Error> {
1835 let test_path = testutils::create_temp_dir().await?;
1836 let old_file = test_path.join("old.txt");
1837 let new_file = test_path.join("new.txt");
1838 tokio::fs::write(&old_file, "x").await?;
1839 tokio::fs::write(&new_file, "x").await?;
1840 set_mtime_age(&old_file, std::time::Duration::from_secs(7200))?;
1841 set_mtime_age(&new_file, std::time::Duration::from_secs(60))?;
1842 set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1843 let summary = rm(
1844 &PROGRESS,
1845 &test_path,
1846 &Settings {
1847 fail_early: false,
1848 filter: None,
1849 time_filter: Some(TimeFilter {
1850 modified_before: Some(std::time::Duration::from_secs(3600)),
1851 created_before: None,
1852 }),
1853 dry_run: Some(DryRunMode::Explain),
1854 },
1855 )
1856 .await?;
1857 assert_eq!(
1858 summary.files_removed, 1,
1859 "should report old file would be removed"
1860 );
1861 assert_eq!(
1862 summary.files_skipped, 1,
1863 "should report new file would be skipped"
1864 );
1865 assert!(old_file.exists(), "old.txt should still exist (dry-run)");
1866 assert!(new_file.exists(), "new.txt should still exist (dry-run)");
1867 Ok(())
1868 }
1869
1870 /// A fresh top-level directory is traversed (its old children are removed),
1871 /// but the root itself is not removed because its own mtime is too recent.
1872 #[tokio::test]
1873 #[traced_test]
1874 async fn fresh_top_level_directory_is_traversed_but_not_removed()
1875 -> Result<(), anyhow::Error> {
1876 let test_path = testutils::create_temp_dir().await?;
1877 let old_inside = test_path.join("old.txt");
1878 tokio::fs::write(&old_inside, "x").await?;
1879 set_mtime_age(&old_inside, std::time::Duration::from_secs(7200))?;
1880 // test_path itself is left fresh (recent mtime)
1881 let summary = rm(
1882 &PROGRESS,
1883 &test_path,
1884 &Settings {
1885 fail_early: false,
1886 filter: None,
1887 time_filter: Some(TimeFilter {
1888 modified_before: Some(std::time::Duration::from_secs(3600)),
1889 created_before: None,
1890 }),
1891 dry_run: None,
1892 },
1893 )
1894 .await?;
1895 assert_eq!(
1896 summary.files_removed, 1,
1897 "old child should be removed despite fresh parent"
1898 );
1899 assert_eq!(
1900 summary.directories_skipped, 1,
1901 "fresh root itself is skipped at removal time"
1902 );
1903 assert_eq!(
1904 summary.directories_removed, 0,
1905 "fresh root must not be removed"
1906 );
1907 assert!(test_path.exists(), "fresh root should still exist");
1908 assert!(!old_inside.exists(), "old child should be gone");
1909 Ok(())
1910 }
1911
1912 /// Time filter on a single-file root argument increments skip when too new.
1913 #[tokio::test]
1914 #[traced_test]
1915 async fn time_filter_on_root_file_argument() -> Result<(), anyhow::Error> {
1916 let test_path = testutils::create_temp_dir().await?;
1917 let new_file = test_path.join("new.txt");
1918 tokio::fs::write(&new_file, "x").await?;
1919 set_mtime_age(&new_file, std::time::Duration::from_secs(60))?;
1920 let summary = rm(
1921 &PROGRESS,
1922 &new_file,
1923 &Settings {
1924 fail_early: false,
1925 filter: None,
1926 time_filter: Some(TimeFilter {
1927 modified_before: Some(std::time::Duration::from_secs(3600)),
1928 created_before: None,
1929 }),
1930 dry_run: None,
1931 },
1932 )
1933 .await?;
1934 assert_eq!(summary.files_removed, 0);
1935 assert_eq!(
1936 summary.files_skipped, 1,
1937 "root file too new should be skipped"
1938 );
1939 assert!(new_file.exists(), "root file should still exist");
1940 Ok(())
1941 }
1942 }
1943
1944 /// Stress tests exercising max-open-files saturation during rm.
1945 mod max_open_files_tests {
1946 use super::*;
1947
1948 /// wide rm: many files with a very low open-files limit.
1949 /// verifies all files are removed correctly under permit saturation.
1950 #[tokio::test]
1951 #[traced_test]
1952 async fn wide_rm_under_open_files_saturation() -> Result<(), anyhow::Error> {
1953 let test_path = testutils::create_temp_dir().await?;
1954 let file_count = 200;
1955 for i in 0..file_count {
1956 tokio::fs::write(
1957 test_path.join(format!("{}.txt", i)),
1958 format!("content-{}", i),
1959 )
1960 .await?;
1961 }
1962 // set a very low limit to force permit contention
1963 throttle::set_max_open_files(4);
1964 let summary = rm(
1965 &PROGRESS,
1966 &test_path,
1967 &Settings {
1968 fail_early: true,
1969 filter: None,
1970 dry_run: None,
1971 time_filter: None,
1972 },
1973 )
1974 .await?;
1975 assert_eq!(summary.files_removed, file_count);
1976 assert_eq!(summary.directories_removed, 1);
1977 assert!(!test_path.exists());
1978 Ok(())
1979 }
1980
1981 /// deep + wide rm: directory tree deeper than the open-files limit, with files
1982 /// at every level. verifies no deadlock occurs (directories don't consume permits).
1983 #[tokio::test]
1984 #[traced_test]
1985 async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
1986 let test_path = testutils::create_temp_dir().await?;
1987 let depth = 20;
1988 let files_per_level = 5;
1989 let limit = 4;
1990 // create a directory chain deeper than the permit limit, with files at each level
1991 let mut dir = test_path.clone();
1992 for level in 0..depth {
1993 tokio::fs::create_dir_all(&dir).await?;
1994 for f in 0..files_per_level {
1995 tokio::fs::write(
1996 dir.join(format!("f{}_{}.txt", level, f)),
1997 format!("L{}F{}", level, f),
1998 )
1999 .await?;
2000 }
2001 dir = dir.join(format!("d{}", level));
2002 }
2003 throttle::set_max_open_files(limit);
2004 let summary = tokio::time::timeout(
2005 std::time::Duration::from_secs(30),
2006 rm(
2007 &PROGRESS,
2008 &test_path,
2009 &Settings {
2010 fail_early: true,
2011 filter: None,
2012 dry_run: None,
2013 time_filter: None,
2014 },
2015 ),
2016 )
2017 .await
2018 .context("rm timed out — possible deadlock")?
2019 .context("rm failed")?;
2020 assert_eq!(summary.files_removed, depth * files_per_level);
2021 assert_eq!(summary.directories_removed, depth);
2022 assert!(!test_path.exists());
2023 Ok(())
2024 }
2025
2026 /// Locks down the boolean used at the rm spawn site to decide whether
2027 /// to pre-acquire a pending-meta permit. A naive `entry_is_dir = false
2028 /// ⇒ pre-acquire` policy treats unknown-typed entries (when
2029 /// `DirEntry::file_type()` fails) as leaves, so the spawned task
2030 /// holds the permit even if the entry is actually a directory and
2031 /// recurses. A chain of such entries can deadlock the pool. The
2032 /// safer pattern — `pre-acquire iff positively-known-not-directory`
2033 /// — keeps the predicate `false` for unknown types.
2034 #[test]
2035 fn pre_acquire_skips_unknown_filetype() -> Result<(), anyhow::Error> {
2036 let tmp = std::env::temp_dir().join(format!(
2037 "rcp_pre_acquire_test_{}_{}",
2038 std::process::id(),
2039 rand::random::<u64>()
2040 ));
2041 std::fs::create_dir(&tmp)?;
2042 let dir_path = tmp.join("d");
2043 std::fs::create_dir(&dir_path)?;
2044 let file_path = tmp.join("f");
2045 std::fs::write(&file_path, "x")?;
2046 let dir_ft = std::fs::metadata(&dir_path)?.file_type();
2047 let file_ft = std::fs::metadata(&file_path)?.file_type();
2048 // The exact predicate used in the rm spawn site:
2049 let known_leaf =
2050 |ft: Option<std::fs::FileType>| ft.as_ref().is_some_and(|t| !t.is_dir());
2051 assert!(!known_leaf(None), "unknown filetype must skip pre-acquire");
2052 assert!(!known_leaf(Some(dir_ft)), "directory must skip pre-acquire");
2053 assert!(known_leaf(Some(file_ft)), "regular file must pre-acquire");
2054 std::fs::remove_dir_all(&tmp).ok();
2055 Ok(())
2056 }
2057
2058 /// Regression for the hold-and-wait deadlock when a getdents leaf-hint entry is actually a
2059 /// directory (the DT_UNKNOWN edge, or a swap between `getdents` and the authoritative
2060 /// `child()`). The walk pre-acquires a `pending_meta` permit for hinted leaves only; if such
2061 /// an entry turns out to be a directory, the spawned task must DROP that permit before
2062 /// recursing — otherwise it holds the permit AND its children block trying to acquire one,
2063 /// and with a saturated pool the whole walk hangs.
2064 ///
2065 /// This reproduces that exact shape deterministically by driving the `RmVisitor` through the
2066 /// driver's [`process_entry`] with the one-and-only permit pre-acquired by the caller
2067 /// (mirroring the spawn loop's hinted-leaf pre-acquire). With the bug, the directory branch
2068 /// would hold that permit while recursing into the directory, whose child file then blocks
2069 /// forever on `pending_meta` (pool size 1, already held by us) — the timeout fires. With the
2070 /// fix, the driver's single drop-before-recurse site releases the permit on the directory
2071 /// path before recursing, the child acquires it, and removal completes well within the
2072 /// timeout.
2073 #[tokio::test]
2074 #[traced_test]
2075 async fn hinted_leaf_that_is_dir_drops_permit_before_recursion() -> anyhow::Result<()> {
2076 let root = testutils::create_temp_dir().await?;
2077 // `d` is a directory (the authoritative type) holding one child file `c`.
2078 let dir_path = root.join("d");
2079 tokio::fs::create_dir(&dir_path).await?;
2080 tokio::fs::write(dir_path.join("c"), b"x").await?;
2081 // size the pending-meta pool to a single permit so a held-across-recursion permit
2082 // strands the child's pre-acquire — the saturation the fd-walk must tolerate.
2083 throttle::set_max_open_files(1);
2084 // open the container of `d` and classify `d` itself: an authoritative directory.
2085 let parent = Dir::open_parent_dir(&root, congestion::Side::Source)
2086 .await
2087 .context("open parent dir")?;
2088 let parent = Arc::new(parent.into_tree());
2089 let name = std::ffi::OsStr::new("d");
2090 let handle = parent.child(name).await.context("classify d")?;
2091 assert_eq!(
2092 handle.kind(),
2093 EntryKind::Dir,
2094 "fixture `d` must be a directory"
2095 );
2096 drop(handle);
2097 let settings = Settings {
2098 fail_early: true,
2099 filter: None,
2100 dry_run: None,
2101 time_filter: None,
2102 };
2103 let visitor = Arc::new(RmVisitor {
2104 prog_track: &PROGRESS,
2105 settings: settings.clone(),
2106 });
2107 let cx = EntryCx {
2108 parent: Arc::clone(&parent),
2109 name: name.to_owned(),
2110 rel_path: std::path::PathBuf::new(),
2111 filter_path: std::path::PathBuf::new(),
2112 real_path: dir_path.clone(),
2113 dry_run: false,
2114 prog_track: &PROGRESS,
2115 };
2116 // pre-acquire the single permit exactly as the spawn loop does for a hinted leaf, and
2117 // hand it to `process_entry`. The fix drops it before recursing into the directory.
2118 let permit = crate::walk::preacquire_leaf_permit(
2119 PermitKind::PendingMeta,
2120 Some(EntryKind::File),
2121 |_| true,
2122 )
2123 .await;
2124 assert!(permit.is_some(), "the pre-acquire must take the one permit");
2125 let result = tokio::time::timeout(
2126 std::time::Duration::from_secs(20),
2127 process_entry(visitor, cx, (), permit),
2128 )
2129 .await;
2130 // restore the default (disabled) pool before asserting so a failure here can't strand
2131 // the tiny limit for any concurrent test (the serial group already isolates us, but
2132 // this keeps the process-global knob clean on the failure path too).
2133 throttle::set_max_open_files(0);
2134 let summary = result
2135 .context(
2136 "process_entry hung — leaf permit held across directory recursion (deadlock)",
2137 )?
2138 .map_err(|e| e.source)?;
2139 assert_eq!(summary.files_removed, 1, "child file should be removed");
2140 assert_eq!(
2141 summary.directories_removed, 1,
2142 "directory should be removed"
2143 );
2144 assert!(!dir_path.exists(), "directory `d` should be gone");
2145 Ok(())
2146 }
2147 }
2148
2149 /// TOCTOU race tests for the fd-based recursive removal.
2150 mod race_tests {
2151 use super::*;
2152
2153 static RACE_PROGRESS: std::sync::LazyLock<progress::Progress> =
2154 std::sync::LazyLock::new(progress::Progress::new);
2155
2156 /// Repeatedly swap `tree/sub` between a real directory (holding a real file) and a symlink
2157 /// to an OUT-OF-TREE sentinel directory, using rename so each individual state is atomic.
2158 /// Two staging names live alongside `sub` and are renamed over it in a tight loop until
2159 /// `stop` is set. Runs on a dedicated OS thread so it makes progress regardless of the
2160 /// tokio runtime's scheduling. Mirrors copy's `intermediate_dir_swap_never_follows_symlink`
2161 /// swapper.
2162 fn spawn_dir_symlink_swapper(
2163 tree: std::path::PathBuf,
2164 sentinel: std::path::PathBuf,
2165 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
2166 ) -> std::thread::JoinHandle<()> {
2167 std::thread::spawn(move || {
2168 let sub = tree.join("sub");
2169 let staged_dir = tree.join("__staged_sub_dir");
2170 let staged_link = tree.join("__staged_sub_link");
2171 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
2172 // stage a real directory (with a real file) then swap it in over `sub`.
2173 let _ = std::fs::remove_dir_all(&staged_dir);
2174 if std::fs::create_dir(&staged_dir).is_ok() {
2175 let _ = std::fs::write(staged_dir.join("real.txt"), b"REAL");
2176 // RENAME_EXCHANGE isn't portable here; remove-then-rename. The window where
2177 // `sub` is briefly absent is fine — rm may error, an accepted failed-closed
2178 // outcome. (rm must still never touch the out-of-tree sentinel.)
2179 let _ = std::fs::remove_dir_all(&sub);
2180 let _ = std::fs::remove_file(&sub);
2181 let _ = std::fs::rename(&staged_dir, &sub);
2182 }
2183 // stage a symlink to the out-of-tree sentinel dir, then swap it in over `sub`.
2184 let _ = std::fs::remove_file(&staged_link);
2185 if std::os::unix::fs::symlink(&sentinel, &staged_link).is_ok() {
2186 let _ = std::fs::remove_dir_all(&sub);
2187 let _ = std::fs::remove_file(&sub);
2188 let _ = std::fs::rename(&staged_link, &sub);
2189 }
2190 }
2191 })
2192 }
2193
2194 /// While `rm` removes a tree, a background thread rapidly flips an intermediate directory
2195 /// `tree/sub` between a real directory and a symlink to a SENTINEL directory tree that
2196 /// lives OUTSIDE the target, holding files that must never be deleted.
2197 ///
2198 /// [`RmVisitor::dir_pre`] descends a directory via [`Dir::open_dir`] (`O_NOFOLLOW|O_DIRECTORY`), so if
2199 /// `sub` is a symlink at the moment of descent the open fails closed (ELOOP/ENOTDIR) and
2200 /// the walk never follows it into the sentinel. If `sub` is a symlink at the moment of
2201 /// classification it is treated as a leaf and `unlink_at` removes the LINK, never its
2202 /// target. Either way the out-of-tree sentinel files survive — that is the safety
2203 /// assertion, checked on every iteration regardless of timing. Also confirms the run
2204 /// terminates (per-op timeout) rather than hanging or following the link.
2205 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2206 async fn intermediate_dir_swap_never_deletes_out_of_tree_sentinel() -> anyhow::Result<()> {
2207 let tmp = testutils::create_temp_dir().await?;
2208 let root = tmp.as_path();
2209 // the sentinel tree lives OUTSIDE the rm target, reachable only via the swapped symlink.
2210 let sentinel = root.join("sentinel_tree");
2211 tokio::fs::create_dir(&sentinel).await?;
2212 tokio::fs::write(sentinel.join("secret1.txt"), b"SECRET-1").await?;
2213 tokio::fs::create_dir(sentinel.join("subdir")).await?;
2214 tokio::fs::write(sentinel.join("subdir").join("secret2.txt"), b"SECRET-2").await?;
2215
2216 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2217 let swapper =
2218 spawn_dir_symlink_swapper(root.to_path_buf(), sentinel.clone(), stop.clone());
2219
2220 let settings = Settings {
2221 fail_early: false,
2222 filter: None,
2223 dry_run: None,
2224 time_filter: None,
2225 };
2226 let mut removed = 0usize;
2227 let mut errored = 0usize;
2228 for i in 0..400 {
2229 // (re)create the target tree. To MAXIMIZE the chance rm encounters `tree/sub` while
2230 // the background thread has it in the symlink state, give `tree` many sibling
2231 // subdirectories with files: rm spends real time enumerating/removing them
2232 // concurrently with the swapper's flips, widening the window in which `sub` is
2233 // classified/descended mid-swap. On even iterations we additionally seed `sub` as a
2234 // symlink-to-sentinel up front, deterministically exercising the "intermediate is a
2235 // symlink at classification time → unlink the link, never recurse the target" path.
2236 let tree = root.join("tree");
2237 let _ = tokio::fs::create_dir(&tree).await;
2238 for d in 0..16 {
2239 let sib = tree.join(format!("sib_{d}"));
2240 let _ = tokio::fs::create_dir(&sib).await;
2241 for f in 0..4 {
2242 let _ = tokio::fs::write(sib.join(format!("f{f}.txt")), b"x").await;
2243 }
2244 }
2245 let sub = tree.join("sub");
2246 if i % 2 == 0 {
2247 // deterministically place a symlink-to-sentinel at `sub` (best-effort; the
2248 // swapper may immediately flip it — that's fine, both states are safe).
2249 let _ = tokio::fs::remove_dir_all(&sub).await;
2250 let _ = tokio::fs::remove_file(&sub).await;
2251 let _ = tokio::fs::symlink(&sentinel, &sub).await;
2252 } else if tokio::fs::symlink_metadata(&sub).await.is_err() {
2253 let _ = tokio::fs::create_dir(&sub).await;
2254 let _ = tokio::fs::write(sub.join("real.txt"), b"REAL").await;
2255 }
2256 let result = tokio::time::timeout(
2257 std::time::Duration::from_secs(30),
2258 super::rm(&RACE_PROGRESS, &tree, &settings),
2259 )
2260 .await
2261 .expect("rm must not hang under concurrent dir swapping");
2262 match result {
2263 Ok(_) => removed += 1,
2264 Err(_) => errored += 1, // a swap was caught mid-walk (failed closed) — accepted
2265 }
2266 // CORE SAFETY ASSERTION (holds on every iteration regardless of timing): the
2267 // out-of-tree sentinel tree and its files are NEVER deleted by rm — neither by
2268 // following a symlinked `sub` (unlink removes the link, not the target) nor by
2269 // descending it (open_dir's O_NOFOLLOW fails closed).
2270 assert!(
2271 sentinel.exists(),
2272 "iteration {i}: sentinel directory was deleted — rm followed the symlink"
2273 );
2274 let s1 = tokio::fs::read(sentinel.join("secret1.txt")).await;
2275 assert!(
2276 matches!(&s1, Ok(b) if b == b"SECRET-1"),
2277 "iteration {i}: sentinel/secret1.txt was deleted or altered — rm followed the symlink"
2278 );
2279 let s2 = tokio::fs::read(sentinel.join("subdir").join("secret2.txt")).await;
2280 assert!(
2281 matches!(&s2, Ok(b) if b == b"SECRET-2"),
2282 "iteration {i}: sentinel/subdir/secret2.txt was deleted — rm recursed through the symlink"
2283 );
2284 // clean up any leftover tree before the next iteration (best-effort).
2285 let _ = tokio::fs::remove_dir_all(root.join("tree")).await;
2286 }
2287
2288 stop.store(true, std::sync::atomic::Ordering::Relaxed);
2289 swapper.join().expect("dir swapper thread panicked");
2290 // sanity (not the safety assertion): the run did observable work across the iterations.
2291 tracing::info!("intermediate dir swap: removed={removed}, errored={errored}");
2292 assert!(
2293 removed + errored > 0,
2294 "expected at least one observable outcome across the iterations"
2295 );
2296 Ok(())
2297 }
2298 }
2299}