Skip to main content

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}