Skip to main content

common/
rm.rs

1use anyhow::{Context, anyhow};
2use std::ffi::OsStr;
3use std::os::unix::fs::PermissionsExt;
4use std::path::PathBuf;
5use std::sync::Arc;
6use tracing::instrument;
7
8use crate::filter::TimeFilter;
9use crate::progress;
10use crate::safedir::{self, Dir, Handle};
11use crate::walk::{AdmittedEntry, AdmittedLeaf, EntryAdmission, EntryKind, LeafPermit, PermitKind};
12use crate::walk_driver::{
13    DirAction, DirPreResult, EntryCx, ProcessedChildren, WalkVisitor, process_admitted_entry,
14    process_entry,
15};
16
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/// A temporary directory-mode change that can be undone when filters retain the directory.
119///
120/// This is passive state, not an RAII cleanup guard. Once removal starts, errors and cancellation
121/// do not attempt to roll the directory back: its name or identity may already have changed, and
122/// the operation cannot restore the rest of the partially removed tree. The only restoration sites
123/// are branches where filtering deliberately leaves this directory in place.
124struct RelaxedDir {
125    handle: Handle,
126    original_mode: u32,
127}
128
129impl RelaxedDir {
130    /// Pins the exact inode before changing its mode.
131    fn prepare(handle: &Handle, original_mode: u32) -> std::io::Result<Self> {
132        Ok(Self {
133            handle: handle.try_clone()?,
134            original_mode,
135        })
136    }
137
138    /// Restores a filter-retained directory through normal chmod admission.
139    async fn restore(self) -> std::io::Result<()> {
140        safedir::chmod_via_proc_fd(
141            &self.handle,
142            congestion::Side::Destination,
143            self.original_mode,
144        )
145        .await
146    }
147}
148
149async fn restore_retained_dir(
150    relaxed: Option<RelaxedDir>,
151    path: &std::path::Path,
152) -> anyhow::Result<()> {
153    if let Some(relaxed) = relaxed {
154        relaxed
155            .restore()
156            .await
157            .with_context(|| format!("failed to restore filter-retained directory {path:?}"))?;
158    }
159    Ok(())
160}
161
162/// Public entry point for remove operations.
163///
164/// The walk is fd-based (see [`crate::safedir`]): the root operand is opened relative to its
165/// parent directory and every entry is classified and removed through file-descriptor-relative
166/// syscalls. A privileged `rrm` therefore cannot be redirected by a concurrent symlink swap
167/// into deleting a tree outside the intended target (the classic `rm -rf` symlink race) — the
168/// `O_NOFOLLOW|O_DIRECTORY` opens in [`Dir::open_dir`] catch a directory→symlink swap mid-walk
169/// and fail closed (ELOOP/ENOTDIR) rather than descending the link, and leaf removal uses
170/// `unlinkat` (never follows a symlink). The recursive walk skeleton — enumeration, the
171/// leaf-permit lifecycle, spawning, the single drop-before-recurse site, and the error fold —
172/// lives in the generic [`crate::walk_driver`]; this module supplies the remove visitor.
173#[instrument(skip(prog_track, settings))]
174pub async fn rm(
175    prog_track: &'static progress::Progress,
176    path: &std::path::Path,
177    settings: &Settings,
178) -> Result<Summary, Error> {
179    // command-line callers may run many root operands concurrently. admit each one before opening
180    // its parent or performing the optional fd-based filter probe, then transfer that same permit
181    // into the shared driver. an authoritative directory releases it before descent.
182    let permit = crate::walk::ensure_leaf_permit(PermitKind::PendingMeta, None).await;
183    enum RmRootSetup {
184        Complete(Summary),
185        Ready {
186            operand: crate::walk::RootOperand,
187            parent: Arc<Dir>,
188            entry: AdmittedEntry,
189        },
190    }
191    let setup = safedir::with_optional_fd_admission(
192        permit.as_ref().map(LeafPermit::admission),
193        async move {
194            // decompose the operand into (parent dir, final component) so the root entry is opened and
195            // classified relative to a directory fd — the same fd-relative shape every nested entry takes.
196            // `.`/`..` operands (e.g. `rrm .`) are canonicalized so they still name a directory; `/` is
197            // rejected. rm reads and removes "the" tree; we gate it on the Source side (matching the
198            // existing rm code, which probes its stat/readdir on Source and only the destructive
199            // unlink/rmdir on Destination).
200            let operand = crate::walk::split_root_operand(path)
201                .await
202                .map_err(|err| Error::new(err, Default::default()))?;
203            let parent_path = operand.parent.as_path();
204            let name = operand.name.as_os_str();
205            let path = operand.display.as_path();
206            // the operand's TRUSTED parent prefix is resolved following symlinks normally (the prefix is
207            // trusted up to and including the operand's container — only entries strictly below the named
208            // root are O_NOFOLLOW-hardened). a symlinked parent (e.g. `rrm symlinkdir/foo`) is followed; the
209            // operand itself is still classified via `child(name)` with O_NOFOLLOW (a symlink root is
210            // removed as the link itself).
211            let parent = Dir::open_parent_dir(parent_path, congestion::Side::Source)
212                .await
213                .with_context(|| format!("cannot open parent directory {parent_path:?}"))
214                .map_err(|err| Error::new(err, Default::default()))?;
215            // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below here).
216            let parent = Arc::new(parent.into_tree());
217            // classify the root once under admission. A filter decides from this handle, and an
218            // included entry transfers the same owner into the shared driver.
219            let entry = crate::walk::classify_admitted_entry(&parent, name, permit)
220                .await
221                .with_context(|| format!("failed reading metadata from {path:?}"))
222                .map_err(|err| Error::new(err, Default::default()))?;
223            if let Some(ref filter) = settings.filter {
224                let name_path = std::path::Path::new(name);
225                let result =
226                    filter.should_include_root_item(name_path, entry.kind() == EntryKind::Dir);
227                match result {
228                    crate::filter::FilterResult::Included => {}
229                    result => {
230                        let kind = entry.kind();
231                        if let Some(mode) = settings.dry_run {
232                            crate::dry_run::report_skip(path, &result, mode, kind.label_long());
233                        }
234                        kind.inc_skipped(prog_track);
235                        return Ok(RmRootSetup::Complete(skipped_summary_for(kind)));
236                    }
237                }
238            }
239            Ok(RmRootSetup::Ready {
240                operand,
241                parent,
242                entry,
243            })
244        },
245    )
246    .await?;
247    let (operand, parent, entry) = match setup {
248        RmRootSetup::Complete(summary) => return Ok(summary),
249        RmRootSetup::Ready {
250            operand,
251            parent,
252            entry,
253        } => (operand, parent, entry),
254    };
255    let name = operand.name.as_os_str();
256    let path = operand.display.as_path();
257    // the root entry's owned context: rel_path/filter_path empty (the root), real_path = the
258    // operand. rm has no delegated subtree, so `filter_path == rel_path`. The root is processed
259    // exactly like a nested child via `process_admitted_entry`, transferring the root
260    // classification and admission acquired during setup.
261    let visitor = Arc::new(RmVisitor {
262        prog_track,
263        settings: settings.clone(),
264    });
265    let root_cx = EntryCx {
266        parent: Arc::clone(&parent),
267        name: name.to_owned(),
268        rel_path: PathBuf::new(),
269        filter_path: PathBuf::new(),
270        real_path: path.to_path_buf(),
271        dry_run: settings.dry_run.is_some(),
272        prog_track,
273    };
274    process_admitted_entry(visitor, root_cx, (), entry).await // rm has no second tree → root context `()`
275}
276
277/// Remove a single child entry of an already-open directory, fd-relative.
278///
279/// This is the fd-based counterpart of path-based [`rm`]. It is used by two callers that
280/// already hold the relevant directory as an open [`Dir`] and want to remove one of its children
281/// through that pinned fd rather than re-resolving the entry by absolute path (the redirectable
282/// window):
283/// - `--delete` pruning (see [`crate::delete::prune_extraneous`]) removes each extraneous entry.
284/// - the remote-copy destination (`rcpd`) replaces a non-matching destination subtree (a
285///   directory/file/symlink in the way of the entry it must create) through the parent directory
286///   fd held in its directory tracker.
287///
288/// The entry is classified via `parent.child(name)` (`O_PATH|O_NOFOLLOW`, so a symlink is
289/// classified as a symlink, never followed) and then removed through the same fd-relative remove
290/// machinery (driven by [`crate::walk_driver::process_entry`]): leaves via `unlinkat`, directories
291/// by recursing with `O_NOFOLLOW|O_DIRECTORY` descent and fd-pinned permission state. A privileged
292/// caller therefore cannot be redirected by a concurrent symlink swap of `name` into deleting a
293/// tree outside the directory it holds.
294///
295/// `rel_path` is the entry's path relative to the mirror/destination root: seeded as the root
296/// entry's `rel_path`/`filter_path`/`real_path`, it both anchors include/exclude filter matching
297/// against the entry's destination-root-relative path and reconstructs the entry's display path for
298/// diagnostics / dry-run output. The caller is responsible for the top-level decision on `name`
299/// (keep-set membership, exclude-protection, or overwrite policy); this only removes the subtree.
300pub async fn rm_child(
301    prog_track: &'static progress::Progress,
302    parent: &Arc<Dir>,
303    name: &OsStr,
304    rel_path: &std::path::Path,
305    settings: &Settings,
306) -> Result<Summary, Error> {
307    rm_child_admitted(
308        prog_track,
309        parent,
310        name,
311        rel_path,
312        settings,
313        EntryAdmission::RootOrDelegated,
314    )
315    .await
316}
317
318/// Remove an already-located child while transferring its explicit admission state.
319///
320/// Unfiltered `--delete` uses this to preserve its scheduling hint. Other callers use
321/// [`rm_child`], and the shared driver acquires before its own classification. A destructive filter
322/// that already classified the entry uses [`rm_child_admitted_entry`] instead.
323pub(crate) async fn rm_child_admitted(
324    prog_track: &'static progress::Progress,
325    parent: &Arc<Dir>,
326    name: &OsStr,
327    rel_path: &std::path::Path,
328    settings: &Settings,
329    admission: EntryAdmission,
330) -> Result<Summary, Error> {
331    rm_child_with_source(
332        prog_track,
333        parent,
334        name,
335        rel_path,
336        settings,
337        RmChildSource::Unclassified(admission),
338    )
339    .await
340}
341
342/// Remove a child using the authoritative entry that informed a destructive filter decision.
343pub(crate) async fn rm_child_admitted_entry(
344    prog_track: &'static progress::Progress,
345    parent: &Arc<Dir>,
346    name: &OsStr,
347    rel_path: &std::path::Path,
348    settings: &Settings,
349    entry: AdmittedEntry,
350) -> Result<Summary, Error> {
351    rm_child_with_source(
352        prog_track,
353        parent,
354        name,
355        rel_path,
356        settings,
357        RmChildSource::Admitted(entry),
358    )
359    .await
360}
361
362enum RmChildSource {
363    Unclassified(EntryAdmission),
364    Admitted(AdmittedEntry),
365}
366
367#[allow(clippy::too_many_arguments)]
368async fn rm_child_with_source(
369    prog_track: &'static progress::Progress,
370    parent: &Arc<Dir>,
371    name: &OsStr,
372    rel_path: &std::path::Path,
373    settings: &Settings,
374    source: RmChildSource,
375) -> Result<Summary, Error> {
376    // build the child's owned context rooted at `rel_path`: the display path and the filter path
377    // then each equal `rel_path` (the destination-root-relative path), anchoring include/exclude
378    // matching against the entry's full root-relative path, and each descendant extends it by one
379    // component (exactly the bare-roots behavior the old `WalkRoots { operand: "", filter: "" }`
380    // produced via `operand.join(rel_path)`). these paths are pure display/filter strings — they
381    // are never opened, so there is no dst path for an attacker to redirect.
382    let visitor = Arc::new(RmVisitor {
383        prog_track,
384        settings: settings.clone(),
385    });
386    let root_cx = EntryCx {
387        parent: Arc::clone(parent),
388        name: name.to_owned(),
389        rel_path: rel_path.to_path_buf(),
390        filter_path: rel_path.to_path_buf(),
391        real_path: rel_path.to_path_buf(),
392        dry_run: settings.dry_run.is_some(),
393        prog_track,
394    };
395    match source {
396        RmChildSource::Unclassified(admission) => {
397            process_entry(visitor, root_cx, (), admission).await
398        }
399        RmChildSource::Admitted(entry) => process_admitted_entry(visitor, root_cx, (), entry).await,
400    }
401}
402/// The remove walk's [`WalkVisitor`]. The driver owns enumeration, the leaf-permit lifecycle,
403/// spawning, the single drop-before-recurse site, and the error fold; this visitor supplies rm's
404/// per-entry bodies (leaf removal in [`WalkVisitor::visit_leaf`], permission relax + metadata
405/// snapshot in [`WalkVisitor::dir_pre`], and the post-order `rmdir` decision in
406/// [`WalkVisitor::dir_post`]). rm has no second tree, so [`Self::DirContext`] is `()`.
407struct RmVisitor {
408    prog_track: &'static progress::Progress,
409    settings: Settings,
410}
411
412/// State threaded from [`WalkVisitor::dir_pre`] to [`WalkVisitor::dir_post`] (same task) — what the
413/// post-order `rmdir` decision needs.
414struct RmDirState {
415    /// Restores the relaxed mode only if filtering deliberately retains this directory. `None` in
416    /// dry-run and when the directory already had owner rwx, because neither case changes the mode.
417    relaxed: Option<RelaxedDir>,
418    /// The directory's PRISTINE full metadata, snapshotted in `dir_pre` BEFORE the relax or any
419    /// child removal, so the directory's own time filter sees timestamps unperturbed by clearing
420    /// its contents (removing children bumps mtime). `Some` iff a time filter is configured.
421    time_metadata: Option<anyhow::Result<std::fs::Metadata>>,
422    /// Whether the directory does NOT directly match an include pattern while includes are active —
423    /// the path-only half of the "traversed only" decision. Combined in `dir_post` with
424    /// "nothing was removed" (derived from the children's folded summary) to reproduce the old
425    /// `traversed_only` exactly.
426    matches_no_include: bool,
427}
428
429impl WalkVisitor for RmVisitor {
430    type Summary = Summary;
431    type DirContext = ();
432    type DirState = RmDirState;
433
434    fn root_dir_context(&self) {}
435
436    fn permit_kind(&self) -> PermitKind {
437        // use PendingMeta because copy/link overwrite leaf work can invoke rm while retaining
438        // OpenFile admission. A distinct semaphore keeps that one-way composition from becoming a
439        // hold-and-wait cycle while still bounding fd-backed entry handles
440        PermitKind::PendingMeta
441    }
442
443    fn fail_early(&self) -> bool {
444        self.settings.fail_early
445    }
446
447    fn filter(&self) -> Option<&crate::filter::FilterSettings> {
448        self.settings.filter.as_ref()
449    }
450
451    fn filter_requires_admitted_entry(&self) -> bool {
452        true
453    }
454
455    fn filter_allows_hint_only_skip(&self, _dir_ctx: &Self::DirContext) -> bool {
456        // real removal needs no action after an exclusion, but preview counters and actions describe
457        // the current entry type and therefore require the exact admitted filter decision.
458        self.settings.dry_run.is_none()
459    }
460
461    fn on_skip(
462        &self,
463        cx: &EntryCx,
464        kind: EntryKind,
465        skip_result: &crate::filter::FilterResult,
466    ) -> Summary {
467        // mirror the old spawn loop's inline filter-skip: the dry-run "skip ..." line plus the
468        // matching `*_skipped` counter. the driver already did the shared progress increment.
469        if let Some(mode) = self.settings.dry_run {
470            crate::dry_run::report_skip(&cx.real_path, skip_result, mode, kind.label());
471        }
472        skipped_summary_for(kind)
473    }
474
475    async fn visit_leaf(
476        &self,
477        cx: &EntryCx,
478        _parent_ctx: &(),
479        leaf: AdmittedLeaf,
480    ) -> Result<Summary, Error> {
481        let kind = leaf.kind();
482        let prog_track = self.prog_track;
483        let settings = &self.settings;
484        let parent = &cx.parent;
485        let name = cx.name.as_os_str();
486        let path = cx.real_path.as_path();
487        tracing::debug!("not a directory, just remove");
488        let is_symlink = kind == EntryKind::Symlink;
489        let file_size = if is_symlink {
490            0
491        } else {
492            crate::preserve::Metadata::size(leaf.handle().meta())
493        };
494        // apply time filter before removing (files/symlinks only)
495        if let Some(ref time_filter) = settings.time_filter {
496            let entry_type = if is_symlink { "symlink" } else { "file" };
497            let make_skipped_summary = || {
498                tracing::debug!("skipping {:?} due to time filter", &path);
499                if is_symlink {
500                    prog_track.symlinks_skipped.inc();
501                    Summary {
502                        symlinks_skipped: 1,
503                        ..Default::default()
504                    }
505                } else {
506                    prog_track.files_skipped.inc();
507                    Summary {
508                        files_skipped: 1,
509                        ..Default::default()
510                    }
511                }
512            };
513            // the time filter needs full std metadata (btime for --created-before), which the
514            // fd snapshot does not carry; read it inode-exact through the pinned handle so a
515            // concurrent swap of `name` cannot redirect the stat to a different inode.
516            let metadata =
517                match safedir::stat_meta_via_proc_fd(leaf.handle(), congestion::Side::Source).await
518                {
519                    Ok(md) => md,
520                    Err(err) => {
521                        let err = anyhow::Error::new(err).context(format!(
522                            "failed reading metadata for time filter on {path:?}"
523                        ));
524                        if settings.fail_early {
525                            return Err(Error::new(err, Default::default()));
526                        }
527                        // log and skip — never delete an entry whose age we cannot verify.
528                        if is_unsupported_io_error(&err) {
529                            tracing::warn!(
530                                "time filter evaluation unsupported for {} {:?}, skipping: {:#}",
531                                entry_type,
532                                &path,
533                                &err
534                            );
535                        } else {
536                            tracing::error!(
537                                "time filter evaluation failed for {} {:?}, skipping: {:#}",
538                                entry_type,
539                                &path,
540                                &err
541                            );
542                        }
543                        return Ok(make_skipped_summary());
544                    }
545                };
546            match time_filter.matches(&metadata) {
547                Ok(result) => {
548                    if let Some(skip_reason) = result.as_skip_reason() {
549                        if let Some(mode) = settings.dry_run {
550                            crate::dry_run::report_time_skip(path, skip_reason, mode, entry_type);
551                        }
552                        return Ok(make_skipped_summary());
553                    }
554                }
555                Err(err) => {
556                    let err = err.context(format!("failed evaluating time filter on {:?}", &path));
557                    if settings.fail_early {
558                        return Err(Error::new(err, Default::default()));
559                    }
560                    // log and skip — never delete an entry whose age we cannot verify.
561                    // btime being unsupported (common for symlinks) is expected noise, so
562                    // downgrade to warn; anything else is unexpected and stays at error.
563                    if is_unsupported_io_error(&err) {
564                        tracing::warn!(
565                            "time filter evaluation unsupported for {} {:?}, skipping: {:#}",
566                            entry_type,
567                            &path,
568                            &err
569                        );
570                    } else {
571                        tracing::error!(
572                            "time filter evaluation failed for {} {:?}, skipping: {:#}",
573                            entry_type,
574                            &path,
575                            &err
576                        );
577                    }
578                    return Ok(make_skipped_summary());
579                }
580            }
581        }
582        // handle dry-run mode for files/symlinks
583        if settings.dry_run.is_some() {
584            let entry_type = if is_symlink { "symlink" } else { "file" };
585            crate::dry_run::report_action("remove", path, None, entry_type);
586            return Ok(Summary {
587                bytes_removed: file_size,
588                files_removed: if is_symlink { 0 } else { 1 },
589                symlinks_removed: if is_symlink { 1 } else { 0 },
590                ..Default::default()
591            });
592        }
593        // the filter decision depends only on path plus directory-vs-nondirectory. Every kind this
594        // branch accepts has the same nondirectory decision, while unlinkat without AT_REMOVEDIR
595        // cannot remove a directory swapped into the name. Keep the classified handle for exact
596        // metadata/time/accounting, then perform the single fd-relative removal without opening a
597        // second PendingMeta descriptor. A same-class final-component replacement remains contained
598        // to the held parent, matching the documented best-effort residual.
599        //
600        // unlink_at never follows a symlink and is resolved relative to the parent dir fd, so it
601        // cannot be redirected outside the tree.
602        // gated on the Destination side to match the side the path-based rm used for remove_file.
603        if let Err(err) = parent
604            .unlink_at_on(name, congestion::Side::Destination)
605            .await
606            .with_context(|| format!("failed removing {:?}", &path))
607        {
608            return Err(Error::new(err, Default::default()));
609        }
610        if is_symlink {
611            prog_track.symlinks_removed.inc();
612            return Ok(Summary {
613                symlinks_removed: 1,
614                ..Default::default()
615            });
616        }
617        prog_track.files_removed.inc();
618        prog_track.bytes_removed.add(file_size);
619        Ok(Summary {
620            bytes_removed: file_size,
621            files_removed: 1,
622            ..Default::default()
623        })
624    }
625
626    async fn dir_pre(&self, cx: &EntryCx, _parent_ctx: &(), handle: &Handle) -> DirPreResult<Self> {
627        let settings = &self.settings;
628        let path = cx.real_path.as_path();
629        tracing::debug!("remove contents of the directory first");
630        // Snapshot the directory's full metadata NOW, before relaxing its mode or removing any
631        // child. The directory's own time filter (evaluated in `dir_post`, after its children are
632        // processed) must use these pristine timestamps: removing children bumps the directory's
633        // mtime, so a fresh stat at the end would wrongly see the directory as "just modified".
634        // This mirrors the old code's `src_metadata` captured at entry. We read full std metadata
635        // (not just the fd snapshot) because `--created-before` needs btime; it's read inode-exact
636        // through the pinned handle. Only fetched when a time filter is configured. The relax below
637        // changes only mode/ctime, never mtime/btime, so taking the snapshot before it is correct.
638        let time_metadata: Option<anyhow::Result<std::fs::Metadata>> =
639            if settings.time_filter.is_some() {
640                Some(
641                    safedir::stat_meta_via_proc_fd(handle, congestion::Side::Source)
642                        .await
643                        .map_err(|err| {
644                            anyhow::Error::new(err).context(format!(
645                                "failed reading metadata for time filter on {path:?}"
646                            ))
647                        }),
648                )
649            } else {
650                None
651            };
652        // the path-only half of the "traversed only" decision: a directory is "traversed only" when
653        // include filters are active and the directory itself doesn't directly match an include
654        // pattern (it was entered only to search for matching content). `dir_post` combines this
655        // with "nothing was removed". exclude-only filters never produce traversed-only directories
656        // because `directly_matches_include` returns true when no includes exist.
657        let matches_no_include = settings.filter.as_ref().is_some_and(|f| {
658            f.has_includes() && !f.directly_matches_include(&cx.filter_path, true)
659        });
660        // when the directory lacks owner write/execute we relax it so its contents can be cleared.
661        // the relax goes through the directory's own `O_PATH` handle (via /proc), which works even
662        // on a 0000-mode dir a non-root owner cannot open O_RDONLY — so it must happen BEFORE
663        // `open_dir`. the passive state carried in `RmDirState` restores the original mode only if
664        // filtering deliberately retains the directory. errors, cancellation, and intended removal
665        // simply drop it. dry-run skips the relax entirely.
666        let mut relaxed: Option<RelaxedDir> = None;
667        if settings.dry_run.is_none() {
668            let original_mode =
669                crate::preserve::Metadata::permissions(handle.meta()).mode() & 0o7777;
670            // relax unless the owner already has full r/w/x. read is needed to enumerate the dir
671            // (open_dir + getdents), and write+execute to unlink/rmdir its entries. 0o700 = u+rwx.
672            // this is a superset of the old `readonly()` (no-write) trigger, and also covers dirs
673            // missing owner read/execute that the old path-based read_dir would have failed on.
674            if original_mode & 0o700 != 0o700 {
675                tracing::debug!("directory is not writable/traversable - relax the permissions");
676                // pin the inode before relaxing so a filter-retention branch can later restore that
677                // exact directory even if its name has changed.
678                let prepared = RelaxedDir::prepare(handle, original_mode)
679                    .with_context(|| format!("failed to pin relaxed directory {path:?}"))
680                    .map_err(|err| Error::new(err, Default::default()))?;
681                // gated as a Destination Chmod: the relax is a permission mutation that enables the
682                // removal, so it shares the destructive side/bucket with the unlink/rmdir below.
683                safedir::chmod_via_proc_fd(handle, congestion::Side::Destination, 0o700)
684                    .await
685                    .with_context(|| {
686                        format!("failed to make {path:?} directory readable and writable")
687                    })
688                    .map_err(|err| Error::new(err, Default::default()))?;
689                relaxed = Some(prepared);
690            }
691        }
692        // open the directory's real fd via the parent fd with O_NOFOLLOW|O_DIRECTORY: a directory
693        // entry swapped to a symlink mid-walk fails closed here (ELOOP/ENOTDIR) — descent never
694        // follows it outside the tree. this is the core rm -rf race closure.
695        let dir = cx
696            .parent
697            .open_dir(&cx.name)
698            .await
699            .with_context(|| format!("failed reading directory {path:?}"))
700            .map_err(|err| Error::new(err, Default::default()))?;
701        dir.verify_same_inode(handle)
702            .await
703            .with_context(|| {
704                format!("directory {path:?} changed identity before descent (possible TOCTOU swap)")
705            })
706            .map_err(|err| Error::new(err, Default::default()))?;
707        let dir = Arc::new(dir);
708        Ok(DirAction::Descend {
709            dir,
710            child_ctx: (),
711            state: RmDirState {
712                relaxed,
713                time_metadata,
714                matches_no_include,
715            },
716        })
717    }
718
719    async fn dir_post(
720        &self,
721        cx: &EntryCx,
722        state: RmDirState,
723        _processed: &ProcessedChildren,
724        child_result: Result<Summary, Error>,
725    ) -> Result<Summary, Error> {
726        let prog_track = self.prog_track;
727        let settings = &self.settings;
728        let path = cx.real_path.as_path();
729        let RmDirState {
730            relaxed,
731            time_metadata,
732            matches_no_include,
733        } = state;
734        // a child failed (keep-going mode — fail-early aborts before `dir_post`). do not rmdir or
735        // attempt metadata rollback; return the partial summary with the error.
736        let mut rm_summary = match child_result {
737            Ok(summary) => summary,
738            Err(err) => return Err(err),
739        };
740        tracing::debug!("finally remove the empty directory");
741        let anything_removed = rm_summary.files_removed > 0
742            || rm_summary.symlinks_removed > 0
743            || rm_summary.directories_removed > 0;
744        let anything_skipped = rm_summary.files_skipped > 0
745            || rm_summary.symlinks_skipped > 0
746            || rm_summary.directories_skipped > 0;
747        // directories that directly match an include pattern (e.g. --include target/) should be
748        // removed even if empty; only those merely traversed for matches are left intact.
749        let traversed_only = !anything_removed && matches_no_include;
750        // evaluate the directory's own time filter to decide whether to remove it.
751        // the time filter is an entry filter, not a subtree gate: children are already handled
752        // by their own recursive calls, so this decision only controls the final rmdir.
753        // the metadata SNAPSHOT taken in `dir_pre` — before relaxing the mode or removing any
754        // child — is used so those mutations (which bump the directory's mtime) don't change the
755        // answer. this mirrors the old code's `src_metadata` captured at entry.
756        let dir_passes_time_filter: bool = if let Some(ref time_filter) = settings.time_filter {
757            // unwrap is safe: time_metadata is Some whenever time_filter is Some (both gated on
758            // settings.time_filter.is_some()).
759            let matched = time_metadata.unwrap().and_then(|md| {
760                time_filter.matches(&md).map_err(|err| {
761                    err.context(format!("failed evaluating time filter on {path:?}"))
762                })
763            });
764            match matched {
765                Ok(result) => match result.as_skip_reason() {
766                    Some(reason) => {
767                        if let Some(mode) = settings.dry_run {
768                            crate::dry_run::report_time_skip(path, reason, mode, "dir");
769                        }
770                        false
771                    }
772                    None => true,
773                },
774                Err(err) => {
775                    if settings.fail_early {
776                        return Err(Error::new(err, rm_summary));
777                    }
778                    // log and skip — never remove a directory whose age we cannot verify.
779                    // btime being unsupported on the filesystem is expected noise; downgrade
780                    // to warn. anything else is unexpected and stays at error.
781                    if is_unsupported_io_error(&err) {
782                        tracing::warn!(
783                            "time filter evaluation unsupported for dir {:?}, leaving it intact: {:#}",
784                            &path,
785                            &err
786                        );
787                    } else {
788                        tracing::error!(
789                            "time filter evaluation failed for dir {:?}, leaving it intact: {:#}",
790                            &path,
791                            &err
792                        );
793                    }
794                    false
795                }
796            }
797        } else {
798            true
799        };
800        // handle dry-run mode for directories.
801        // `traversed_only` catches dirs only entered to search for include pattern matches.
802        // `anything_skipped` catches dirs that would still have content after partial removal.
803        // `!dir_passes_time_filter` catches dirs whose own timestamps disqualify removal.
804        // the real-mode path below only needs `traversed_only` and `!dir_passes_time_filter`
805        // because the subsequent `rmdir_at` call handles the non-empty case via ENOTEMPTY.
806        if settings.dry_run.is_some() {
807            if traversed_only || anything_skipped || !dir_passes_time_filter {
808                tracing::debug!(
809                    "dry-run: directory {:?} would not be removed (removed={}, skipped={}, time_ok={})",
810                    &path,
811                    anything_removed,
812                    anything_skipped,
813                    dir_passes_time_filter
814                );
815                if !dir_passes_time_filter {
816                    prog_track.directories_skipped.inc();
817                    rm_summary.directories_skipped += 1;
818                }
819            } else {
820                crate::dry_run::report_action("remove", path, None, "dir");
821                rm_summary.directories_removed += 1;
822            }
823            return Ok(rm_summary);
824        }
825        // skip directories that were only traversed to look for include matches.
826        // not needed for exclude-only filters or directly-matched directories.
827        // non-empty directories are handled by the ENOTEMPTY check below.
828        if traversed_only {
829            tracing::debug!(
830                "directory {:?} had nothing removed, leaving it intact",
831                &path
832            );
833            restore_retained_dir(relaxed, path)
834                .await
835                .map_err(|err| Error::new(err, rm_summary))?;
836            return Ok(rm_summary);
837        }
838        // skip directories whose own timestamps don't satisfy the time filter.
839        // children have already been processed; this only gates the dir's own removal.
840        if !dir_passes_time_filter {
841            tracing::debug!(
842                "directory {:?} skipped by time filter, leaving it intact",
843                &path
844            );
845            prog_track.directories_skipped.inc();
846            rm_summary.directories_skipped += 1;
847            restore_retained_dir(relaxed, path)
848                .await
849                .map_err(|err| Error::new(err, rm_summary))?;
850            return Ok(rm_summary);
851        }
852        // when filtering is active, directories may not be empty because we only removed matching
853        // files (includes) or skipped excluded files; use rmdir (not a recursive remove) so
854        // non-empty directories fail gracefully with ENOTEMPTY. fd-relative rmdir cannot escape
855        // the held parent and can remove only the empty directory occupying this name when the
856        // syscall runs. A separate identity observation could not bind this by-name mutation, so
857        // finalization performs the one removal syscall directly.
858        #[cfg(test)]
859        let _final_rmdir_gate_visit = {
860            // this gate tests mode-restoration ordering only; finalization intentionally retains
861            // no entry fd or identity handle for the by-name rmdir
862            crate::testutils::wait_on_blocking_path_gate(path, libc::AT_FDCWD)
863        };
864        let any_filter_active = settings.filter.is_some() || settings.time_filter.is_some();
865        match cx
866            .parent
867            .rmdir_at_on(&cx.name, congestion::Side::Destination)
868            .await
869        {
870            Ok(()) => {
871                prog_track.directories_removed.inc();
872                rm_summary.directories_removed += 1;
873            }
874            Err(err) if any_filter_active => {
875                // with filtering, it's expected that directories may not be empty because we only
876                // removed matching files; raw_os_error 39 is ENOTEMPTY on Linux. this is not an
877                // error — surface it at info so users can see which directories survived.
878                if err.kind() == std::io::ErrorKind::DirectoryNotEmpty
879                    || err.raw_os_error() == Some(39)
880                {
881                    tracing::info!(
882                        "directory {:?} not empty after filtering, leaving it intact",
883                        &path
884                    );
885                    restore_retained_dir(relaxed, path)
886                        .await
887                        .map_err(|restore_err| Error::new(restore_err, rm_summary))?;
888                } else {
889                    return Err(Error::new(
890                        anyhow!(err).context(format!("failed removing directory {:?}", &path)),
891                        rm_summary,
892                    ));
893                }
894            }
895            Err(err) => {
896                return Err(Error::new(
897                    anyhow!(err).context(format!("failed removing directory {:?}", &path)),
898                    rm_summary,
899                ));
900            }
901        }
902        Ok(rm_summary)
903    }
904}
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909    use crate::config::DryRunMode;
910    use crate::testutils;
911    use tracing_test::traced_test;
912
913    static PROGRESS: std::sync::LazyLock<progress::Progress> =
914        std::sync::LazyLock::new(progress::Progress::new);
915
916    #[tokio::test]
917    #[traced_test]
918    async fn no_write_permission() -> Result<(), anyhow::Error> {
919        let tmp_dir = testutils::setup_test_dir().await?;
920        let test_path = tmp_dir.as_path();
921        let filepaths = vec![
922            test_path.join("foo").join("0.txt"),
923            test_path.join("foo").join("bar").join("2.txt"),
924            test_path.join("foo").join("baz").join("4.txt"),
925            test_path.join("foo").join("baz"),
926        ];
927        for fpath in &filepaths {
928            // change file permissions to not readable and not writable
929            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o555)).await?;
930        }
931        let summary = rm(
932            &PROGRESS,
933            &test_path.join("foo"),
934            &Settings {
935                fail_early: false,
936                filter: None,
937                dry_run: None,
938                time_filter: None,
939            },
940        )
941        .await?;
942        assert!(!test_path.join("foo").exists());
943        assert_eq!(summary.files_removed, 5);
944        assert_eq!(summary.symlinks_removed, 2);
945        assert_eq!(summary.directories_removed, 3);
946        Ok(())
947    }
948
949    #[tokio::test]
950    #[traced_test]
951    async fn removal_error_leaves_temporary_directory_mode() -> Result<(), anyhow::Error> {
952        // once removal starts, rrm does not try to roll temporary directory metadata back on an
953        // error. the directory could disappear or change identity immediately after any attempted
954        // cleanup, so restoration is limited to branches that deliberately retain the directory.
955        //
956        // trigger the remove_dir error path by making the dst's parent non-writable: rm can
957        // chmod-relax dst and successfully unlink its contents (write on dst is granted by the
958        // relax), but the final remove_dir(dst) needs write permission on the parent, which it
959        // doesn't have → EACCES. the error leaves dst at the temporary 0o700 mode.
960        let tmp = tempfile::tempdir()?;
961        let parent = tmp.path().join("parent");
962        let dst = parent.join("dst");
963        tokio::fs::create_dir(&parent).await?;
964        tokio::fs::create_dir(&dst).await?;
965        tokio::fs::write(dst.join("inside.txt"), b"x").await?;
966        // dst is read-only → rm relaxes it.
967        tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o555)).await?;
968        // parent is read-only (still traversable via the execute bit) → remove_dir(dst) fails.
969        tokio::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).await?;
970
971        let result = rm(
972            &PROGRESS,
973            &dst,
974            &Settings {
975                fail_early: false,
976                filter: None,
977                dry_run: None,
978                time_filter: None,
979            },
980        )
981        .await;
982
983        // restore parent writability so we can stat dst and clean up regardless of the assertion.
984        tokio::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).await?;
985
986        assert!(
987            result.is_err(),
988            "rm must fail when its dst can be emptied but the parent dir blocks remove_dir"
989        );
990        let mode = tokio::fs::metadata(&dst).await?.permissions().mode() & 0o7777;
991        assert_eq!(
992            mode, 0o700,
993            "a removal error unexpectedly restored the temporary directory mode (got {mode:o}o)"
994        );
995
996        // cleanup
997        tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o755)).await?;
998        Ok(())
999    }
1000
1001    #[tokio::test]
1002    #[traced_test]
1003    async fn parent_dir_no_write_permission() -> Result<(), anyhow::Error> {
1004        let tmp_dir = testutils::setup_test_dir().await?;
1005        let test_path = tmp_dir.as_path();
1006        // make parent directory read-only (no write permission)
1007        tokio::fs::set_permissions(
1008            &test_path.join("foo").join("bar"),
1009            std::fs::Permissions::from_mode(0o555),
1010        )
1011        .await?;
1012        let result = rm(
1013            &PROGRESS,
1014            &test_path.join("foo").join("bar").join("2.txt"),
1015            &Settings {
1016                fail_early: true,
1017                filter: None,
1018                dry_run: None,
1019                time_filter: None,
1020            },
1021        )
1022        .await;
1023        // should fail with permission denied error
1024        assert!(result.is_err());
1025        let err = result.unwrap_err();
1026        let err_string = format!("{:#}", err);
1027        // verify the error chain includes "Permission denied"
1028        assert!(
1029            err_string.contains("Permission denied") || err_string.contains("permission denied"),
1030            "Error should contain 'Permission denied' but got: {}",
1031            err_string
1032        );
1033        Ok(())
1034    }
1035
1036    mod filter_tests {
1037        use super::*;
1038        use crate::filter::FilterSettings;
1039        /// Test that path-based patterns (with /) work correctly with nested paths.
1040        #[tokio::test]
1041        #[traced_test]
1042        async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
1043            let tmp_dir = testutils::setup_test_dir().await?;
1044            let test_path = tmp_dir.as_path();
1045            // create filter that should only remove files in bar/ directory
1046            let mut filter = FilterSettings::new();
1047            filter.add_include("bar/*.txt").unwrap();
1048            let summary = rm(
1049                &PROGRESS,
1050                &test_path.join("foo"),
1051                &Settings {
1052                    fail_early: false,
1053                    filter: Some(filter),
1054                    dry_run: None,
1055                    time_filter: None,
1056                },
1057            )
1058            .await?;
1059            // should only remove files matching bar/*.txt pattern (bar/1.txt, bar/2.txt, bar/3.txt)
1060            assert_eq!(
1061                summary.files_removed, 3,
1062                "should remove 3 files matching bar/*.txt"
1063            );
1064            // each file is 1 byte ("1", "2", "3")
1065            assert_eq!(summary.bytes_removed, 3, "should report 3 bytes removed");
1066            // verify the right files were removed
1067            assert!(
1068                !test_path.join("foo/bar/1.txt").exists(),
1069                "bar/1.txt should be removed"
1070            );
1071            assert!(
1072                !test_path.join("foo/bar/2.txt").exists(),
1073                "bar/2.txt should be removed"
1074            );
1075            assert!(
1076                !test_path.join("foo/bar/3.txt").exists(),
1077                "bar/3.txt should be removed"
1078            );
1079            // verify files outside the pattern still exist
1080            assert!(
1081                test_path.join("foo/0.txt").exists(),
1082                "0.txt should still exist"
1083            );
1084            Ok(())
1085        }
1086        /// Test that filters are applied to top-level file arguments.
1087        #[tokio::test]
1088        #[traced_test]
1089        async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
1090            let tmp_dir = testutils::setup_test_dir().await?;
1091            let test_path = tmp_dir.as_path();
1092            // create filter that excludes .txt files
1093            let mut filter = FilterSettings::new();
1094            filter.add_exclude("*.txt").unwrap();
1095            let summary = rm(
1096                &PROGRESS,
1097                &test_path.join("foo/0.txt"), // single file source
1098                &Settings {
1099                    fail_early: false,
1100                    filter: Some(filter),
1101                    dry_run: None,
1102                    time_filter: None,
1103                },
1104            )
1105            .await?;
1106            // the file should NOT be removed because it matches the exclude pattern
1107            assert_eq!(
1108                summary.files_removed, 0,
1109                "file matching exclude pattern should not be removed"
1110            );
1111            assert!(
1112                test_path.join("foo/0.txt").exists(),
1113                "excluded file should still exist"
1114            );
1115            Ok(())
1116        }
1117        /// Test that filters apply to root directories with simple exclude patterns.
1118        #[tokio::test]
1119        #[traced_test]
1120        async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
1121            let test_path = testutils::create_temp_dir().await?;
1122            // create a directory that should be excluded
1123            tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
1124            tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
1125            // create filter that excludes *_dir/ directories
1126            let mut filter = FilterSettings::new();
1127            filter.add_exclude("*_dir/").unwrap();
1128            let result = rm(
1129                &PROGRESS,
1130                &test_path.join("excluded_dir"),
1131                &Settings {
1132                    fail_early: false,
1133                    filter: Some(filter),
1134                    dry_run: None,
1135                    time_filter: None,
1136                },
1137            )
1138            .await?;
1139            // directory should NOT be removed because it matches exclude pattern
1140            assert_eq!(
1141                result.directories_removed, 0,
1142                "root directory matching exclude should not be removed"
1143            );
1144            assert!(
1145                test_path.join("excluded_dir").exists(),
1146                "excluded root directory should still exist"
1147            );
1148            Ok(())
1149        }
1150        /// Test that filters apply to root symlinks with simple exclude patterns.
1151        #[tokio::test]
1152        #[traced_test]
1153        async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
1154            let test_path = testutils::create_temp_dir().await?;
1155            // create a target file and a symlink to it
1156            tokio::fs::write(test_path.join("target.txt"), "content").await?;
1157            tokio::fs::symlink(
1158                test_path.join("target.txt"),
1159                test_path.join("excluded_link"),
1160            )
1161            .await?;
1162            // create filter that excludes *_link
1163            let mut filter = FilterSettings::new();
1164            filter.add_exclude("*_link").unwrap();
1165            let result = rm(
1166                &PROGRESS,
1167                &test_path.join("excluded_link"),
1168                &Settings {
1169                    fail_early: false,
1170                    filter: Some(filter),
1171                    dry_run: None,
1172                    time_filter: None,
1173                },
1174            )
1175            .await?;
1176            // symlink should NOT be removed because it matches exclude pattern
1177            assert_eq!(
1178                result.symlinks_removed, 0,
1179                "root symlink matching exclude should not be removed"
1180            );
1181            assert!(
1182                test_path.join("excluded_link").exists(),
1183                "excluded root symlink should still exist"
1184            );
1185            Ok(())
1186        }
1187        /// Test combined include and exclude patterns (exclude takes precedence).
1188        #[tokio::test]
1189        #[traced_test]
1190        async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
1191            let tmp_dir = testutils::setup_test_dir().await?;
1192            let test_path = tmp_dir.as_path();
1193            // test structure from setup_test_dir:
1194            // foo/
1195            //   0.txt
1196            //   bar/ (1.txt, 2.txt, 3.txt)
1197            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
1198            // include all .txt files in bar/, but exclude 2.txt specifically
1199            let mut filter = FilterSettings::new();
1200            filter.add_include("bar/*.txt").unwrap();
1201            filter.add_exclude("bar/2.txt").unwrap();
1202            let summary = rm(
1203                &PROGRESS,
1204                &test_path.join("foo"),
1205                &Settings {
1206                    fail_early: false,
1207                    filter: Some(filter),
1208                    dry_run: None,
1209                    time_filter: None,
1210                },
1211            )
1212            .await?;
1213            // should remove: bar/1.txt, bar/3.txt = 2 files
1214            // should skip: bar/2.txt (excluded by pattern), 0.txt (excluded by default - no match) = 2 files
1215            assert_eq!(summary.files_removed, 2, "should remove 2 files");
1216            assert_eq!(
1217                summary.files_skipped, 2,
1218                "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
1219            );
1220            // verify
1221            assert!(
1222                !test_path.join("foo/bar/1.txt").exists(),
1223                "bar/1.txt should be removed"
1224            );
1225            assert!(
1226                test_path.join("foo/bar/2.txt").exists(),
1227                "bar/2.txt should be excluded"
1228            );
1229            assert!(
1230                !test_path.join("foo/bar/3.txt").exists(),
1231                "bar/3.txt should be removed"
1232            );
1233            Ok(())
1234        }
1235        /// Test that skipped counts accurately reflect what was filtered.
1236        #[tokio::test]
1237        #[traced_test]
1238        async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
1239            let tmp_dir = testutils::setup_test_dir().await?;
1240            let test_path = tmp_dir.as_path();
1241            // test structure from setup_test_dir:
1242            // foo/
1243            //   0.txt
1244            //   bar/ (1.txt, 2.txt, 3.txt)
1245            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
1246            // exclude bar/ directory entirely
1247            let mut filter = FilterSettings::new();
1248            filter.add_exclude("bar/").unwrap();
1249            let summary = rm(
1250                &PROGRESS,
1251                &test_path.join("foo"),
1252                &Settings {
1253                    fail_early: false,
1254                    filter: Some(filter),
1255                    dry_run: None,
1256                    time_filter: None,
1257                },
1258            )
1259            .await?;
1260            // removed: 0.txt, baz/4.txt = 2 files
1261            // removed: baz/5.txt symlink, baz/6.txt symlink = 2 symlinks
1262            // removed: baz = 1 directory (foo cannot be removed because bar still exists)
1263            // skipped: bar directory (1 dir) - contents not counted since whole dir skipped
1264            assert_eq!(summary.files_removed, 2, "should remove 2 files");
1265            assert_eq!(summary.symlinks_removed, 2, "should remove 2 symlinks");
1266            assert_eq!(
1267                summary.directories_removed, 1,
1268                "should remove 1 directory (baz only, foo not empty)"
1269            );
1270            assert_eq!(
1271                summary.directories_skipped, 1,
1272                "should skip 1 directory (bar)"
1273            );
1274            // bar should still exist
1275            assert!(
1276                test_path.join("foo/bar").exists(),
1277                "bar directory should still exist"
1278            );
1279            // foo should still exist (not empty because bar is still there)
1280            assert!(
1281                test_path.join("foo").exists(),
1282                "foo directory should still exist (contains bar)"
1283            );
1284            Ok(())
1285        }
1286        /// Test that empty directories are not removed when they were only traversed to look
1287        /// for matches (regression test for bug where --include='foo' would remove empty dir baz).
1288        #[tokio::test]
1289        #[traced_test]
1290        async fn test_empty_dir_not_removed_when_only_traversed() -> Result<(), anyhow::Error> {
1291            let test_path = testutils::create_temp_dir().await?;
1292            // create structure:
1293            // test/
1294            //   foo (file)
1295            //   bar (file)
1296            //   baz/ (empty directory)
1297            tokio::fs::write(test_path.join("foo"), "content").await?;
1298            tokio::fs::write(test_path.join("bar"), "content").await?;
1299            tokio::fs::create_dir(test_path.join("baz")).await?;
1300            // include only 'foo' file
1301            let mut filter = FilterSettings::new();
1302            filter.add_include("foo").unwrap();
1303            let summary = rm(
1304                &PROGRESS,
1305                &test_path,
1306                &Settings {
1307                    fail_early: false,
1308                    filter: Some(filter),
1309                    dry_run: None,
1310                    time_filter: None,
1311                },
1312            )
1313            .await?;
1314            // only 'foo' should be removed
1315            assert_eq!(summary.files_removed, 1, "should remove only 'foo' file");
1316            assert_eq!(
1317                summary.directories_removed, 0,
1318                "should NOT remove empty 'baz' directory"
1319            );
1320            // verify foo was removed
1321            assert!(!test_path.join("foo").exists(), "foo should be removed");
1322            // verify bar still exists (not matching include pattern)
1323            assert!(test_path.join("bar").exists(), "bar should still exist");
1324            // verify empty baz directory still exists
1325            assert!(
1326                test_path.join("baz").exists(),
1327                "empty baz directory should NOT be removed"
1328            );
1329            Ok(())
1330        }
1331        /// Test that empty directories ARE removed with exclude-only filters.
1332        /// Unlike include filters (where empty dirs are only traversed for matches),
1333        /// exclude-only filters should not prevent removal of empty directories.
1334        #[tokio::test]
1335        #[traced_test]
1336        async fn test_exclude_only_removes_empty_directory() -> Result<(), anyhow::Error> {
1337            let test_path = testutils::create_temp_dir().await?;
1338            // create structure:
1339            // test/
1340            //   foo (file)
1341            //   bar.log (file)
1342            //   baz/ (empty directory)
1343            tokio::fs::write(test_path.join("foo"), "content").await?;
1344            tokio::fs::write(test_path.join("bar.log"), "content").await?;
1345            tokio::fs::create_dir(test_path.join("baz")).await?;
1346            // exclude only .log files
1347            let mut filter = FilterSettings::new();
1348            filter.add_exclude("*.log").unwrap();
1349            let summary = rm(
1350                &PROGRESS,
1351                &test_path,
1352                &Settings {
1353                    fail_early: false,
1354                    filter: Some(filter),
1355                    dry_run: None,
1356                    time_filter: None,
1357                },
1358            )
1359            .await?;
1360            // foo should be removed, bar.log should be skipped, baz/ should be removed
1361            assert_eq!(summary.files_removed, 1, "should remove 'foo'");
1362            assert_eq!(summary.files_skipped, 1, "should skip 'bar.log'");
1363            assert_eq!(
1364                summary.directories_removed, 1,
1365                "should remove empty 'baz' directory"
1366            );
1367            assert!(!test_path.join("foo").exists(), "foo should be removed");
1368            assert!(
1369                test_path.join("bar.log").exists(),
1370                "bar.log should still exist"
1371            );
1372            assert!(
1373                !test_path.join("baz").exists(),
1374                "empty baz directory should be removed"
1375            );
1376            Ok(())
1377        }
1378        /// Test that empty directories are not removed in dry-run mode when only traversed.
1379        #[tokio::test]
1380        #[traced_test]
1381        async fn test_dry_run_empty_dir_not_reported_as_removed() -> Result<(), anyhow::Error> {
1382            let test_path = testutils::create_temp_dir().await?;
1383            // create structure:
1384            // test/
1385            //   foo (file)
1386            //   bar (file)
1387            //   baz/ (empty directory)
1388            tokio::fs::write(test_path.join("foo"), "content").await?;
1389            tokio::fs::write(test_path.join("bar"), "content").await?;
1390            tokio::fs::create_dir(test_path.join("baz")).await?;
1391            // include only 'foo' file
1392            let mut filter = FilterSettings::new();
1393            filter.add_include("foo").unwrap();
1394            let summary = rm(
1395                &PROGRESS,
1396                &test_path,
1397                &Settings {
1398                    fail_early: false,
1399                    filter: Some(filter),
1400                    dry_run: Some(DryRunMode::Explain),
1401                    time_filter: None,
1402                },
1403            )
1404            .await?;
1405            // only 'foo' should be reported as would-be-removed
1406            assert_eq!(
1407                summary.files_removed, 1,
1408                "should report only 'foo' would be removed"
1409            );
1410            assert_eq!(
1411                summary.directories_removed, 0,
1412                "should NOT report empty 'baz' would be removed"
1413            );
1414            // verify nothing was actually removed (dry-run mode)
1415            assert!(test_path.join("foo").exists(), "foo should still exist");
1416            assert!(test_path.join("bar").exists(), "bar should still exist");
1417            assert!(test_path.join("baz").exists(), "baz should still exist");
1418            Ok(())
1419        }
1420        /// Test that an empty directory directly matching an include pattern IS removed.
1421        /// Unlike traversed-only directories, directly matched ones are explicit targets.
1422        #[tokio::test]
1423        #[traced_test]
1424        async fn test_include_directly_matched_empty_dir_is_removed() -> Result<(), anyhow::Error> {
1425            let test_path = testutils::create_temp_dir().await?;
1426            // create structure:
1427            // test/
1428            //   foo (file)
1429            //   baz/ (empty directory)
1430            tokio::fs::write(test_path.join("foo"), "content").await?;
1431            tokio::fs::create_dir(test_path.join("baz")).await?;
1432            // include pattern that directly matches the directory
1433            let mut filter = FilterSettings::new();
1434            filter.add_include("baz/").unwrap();
1435            let summary = rm(
1436                &PROGRESS,
1437                &test_path,
1438                &Settings {
1439                    fail_early: false,
1440                    filter: Some(filter),
1441                    dry_run: None,
1442                    time_filter: None,
1443                },
1444            )
1445            .await?;
1446            assert_eq!(
1447                summary.directories_removed, 1,
1448                "should remove directly matched empty 'baz' directory"
1449            );
1450            assert_eq!(summary.files_removed, 0, "should not remove 'foo'");
1451            assert!(test_path.join("foo").exists(), "foo should still exist");
1452            assert!(
1453                !test_path.join("baz").exists(),
1454                "directly matched empty baz directory should be removed"
1455            );
1456            Ok(())
1457        }
1458    }
1459    mod dry_run_tests {
1460        use super::*;
1461        use crate::filter::FilterSettings;
1462
1463        #[tokio::test(flavor = "current_thread")]
1464        async fn dry_run_rechecks_a_directory_hint_before_previewing_an_included_file()
1465        -> Result<(), anyhow::Error> {
1466            let root = testutils::create_temp_dir().await?;
1467            let target = root.join("target");
1468            tokio::fs::create_dir(&target).await?;
1469            tokio::fs::write(target.join("node"), "CURRENT").await?;
1470            let mut filter = FilterSettings::new();
1471            filter.add_exclude("node/")?;
1472            assert!(!matches!(
1473                filter.should_include(std::path::Path::new("node"), true),
1474                crate::filter::FilterResult::Included
1475            ));
1476            assert!(matches!(
1477                filter.should_include(std::path::Path::new("node"), false),
1478                crate::filter::FilterResult::Included
1479            ));
1480            let settings = Settings {
1481                fail_early: false,
1482                filter: Some(filter),
1483                dry_run: Some(DryRunMode::Brief),
1484                time_filter: None,
1485            };
1486            let admission = testutils::AdmissionLimit::new().await;
1487            admission.set_files_in_flight(1);
1488            let target_dir =
1489                Arc::new(Dir::open_root_dir(&target, false, congestion::Side::Source).await?);
1490            let visitor = Arc::new(RmVisitor {
1491                prog_track: &PROGRESS,
1492                settings,
1493            });
1494            let root_cx = EntryCx {
1495                parent: Arc::clone(&target_dir),
1496                name: std::ffi::OsString::from("target"),
1497                rel_path: std::path::PathBuf::new(),
1498                filter_path: std::path::PathBuf::new(),
1499                real_path: target.clone(),
1500                dry_run: true,
1501                prog_track: &PROGRESS,
1502            };
1503            let result = admission
1504                .run_with_timeout(
1505                    std::time::Duration::from_secs(20),
1506                    crate::walk_driver::scope_tasks(crate::walk_driver::walk_dir_entries(
1507                        visitor,
1508                        target_dir,
1509                        &root_cx,
1510                        &(),
1511                        vec![(std::ffi::OsString::from("node"), Some(EntryKind::Dir))],
1512                    )),
1513                )
1514                .await
1515                .context("rm dry-run hint recheck did not finish at a cap of one")??;
1516            let returned = admission
1517                .run_with_timeout(
1518                    std::time::Duration::from_secs(20),
1519                    throttle::pending_meta_permit(),
1520                )
1521                .await
1522                .context("rm dry-run hint recheck did not return pending-meta capacity")?;
1523            drop(returned);
1524            let (summary, processed) = result;
1525            assert_eq!(processed.names(), &[std::ffi::OsString::from("node")]);
1526            assert_eq!(summary.files_removed, 1);
1527            assert_eq!(summary.directories_skipped, 0);
1528            assert_eq!(
1529                tokio::fs::read_to_string(target.join("node")).await?,
1530                "CURRENT"
1531            );
1532            Ok(())
1533        }
1534
1535        /// Test that dry-run mode doesn't modify permissions on read-only directories.
1536        #[tokio::test]
1537        #[traced_test]
1538        async fn test_dry_run_preserves_readonly_permissions() -> Result<(), anyhow::Error> {
1539            let tmp_dir = testutils::setup_test_dir().await?;
1540            let test_path = tmp_dir.as_path();
1541            let readonly_dir = test_path.join("foo/bar");
1542            // make the directory read-only
1543            tokio::fs::set_permissions(&readonly_dir, std::fs::Permissions::from_mode(0o555))
1544                .await?;
1545            // verify it's read-only
1546            let before_mode = tokio::fs::metadata(&readonly_dir)
1547                .await?
1548                .permissions()
1549                .mode()
1550                & 0o777;
1551            assert_eq!(
1552                before_mode, 0o555,
1553                "directory should be read-only before dry-run"
1554            );
1555            let summary = rm(
1556                &PROGRESS,
1557                &readonly_dir,
1558                &Settings {
1559                    fail_early: false,
1560                    filter: None,
1561                    dry_run: Some(DryRunMode::Brief),
1562                    time_filter: None,
1563                },
1564            )
1565            .await?;
1566            // verify the directory still exists (dry-run shouldn't remove it)
1567            assert!(
1568                readonly_dir.exists(),
1569                "directory should still exist after dry-run"
1570            );
1571            // verify permissions weren't changed
1572            let after_mode = tokio::fs::metadata(&readonly_dir)
1573                .await?
1574                .permissions()
1575                .mode()
1576                & 0o777;
1577            assert_eq!(
1578                after_mode, 0o555,
1579                "dry-run should not modify directory permissions"
1580            );
1581            // verify summary shows what would be removed
1582            assert!(
1583                summary.directories_removed > 0 || summary.files_removed > 0,
1584                "dry-run should report what would be removed"
1585            );
1586            Ok(())
1587        }
1588        /// Test that dry-run mode with filtering correctly handles directories that
1589        /// wouldn't be empty after filtering.
1590        #[tokio::test]
1591        #[traced_test]
1592        async fn test_dry_run_with_filter_non_empty_directory() -> Result<(), anyhow::Error> {
1593            let tmp_dir = testutils::setup_test_dir().await?;
1594            let test_path = tmp_dir.as_path();
1595            // test structure from setup_test_dir:
1596            // foo/
1597            //   0.txt
1598            //   bar/ (1.txt, 2.txt, 3.txt)
1599            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
1600            // exclude bar/ - so foo would not be empty after removing (bar still there)
1601            let mut filter = crate::filter::FilterSettings::new();
1602            filter.add_exclude("bar/").unwrap();
1603            let summary = rm(
1604                &PROGRESS,
1605                &test_path.join("foo"),
1606                &Settings {
1607                    fail_early: false,
1608                    filter: Some(filter),
1609                    dry_run: Some(DryRunMode::Brief),
1610                    time_filter: None,
1611                },
1612            )
1613            .await?;
1614            // dry-run shouldn't actually remove anything
1615            assert!(
1616                test_path.join("foo").exists(),
1617                "foo should still exist after dry-run"
1618            );
1619            // verify summary reflects what WOULD happen:
1620            // - files: 0.txt, baz/4.txt would be removed = 2
1621            // - symlinks: baz/5.txt, baz/6.txt would be removed = 2
1622            // - directories: baz would be removed, but NOT foo (bar is skipped, so foo not empty)
1623            // - skipped: bar directory = 1
1624            assert_eq!(
1625                summary.files_removed, 2,
1626                "should report 2 files would be removed"
1627            );
1628            assert_eq!(
1629                summary.symlinks_removed, 2,
1630                "should report 2 symlinks would be removed"
1631            );
1632            assert_eq!(
1633                summary.directories_removed, 1,
1634                "should report only baz (not foo) would be removed"
1635            );
1636            assert_eq!(
1637                summary.directories_skipped, 1,
1638                "should report bar directory skipped"
1639            );
1640            Ok(())
1641        }
1642        /// Test that dry-run with exclude-only filter correctly reports empty directories
1643        /// as would-be-removed (unlike include filters where empty dirs are only traversed).
1644        #[tokio::test]
1645        #[traced_test]
1646        async fn test_dry_run_exclude_only_reports_empty_dir_removed() -> Result<(), anyhow::Error>
1647        {
1648            let test_path = testutils::create_temp_dir().await?;
1649            // create structure:
1650            // test/
1651            //   foo (file)
1652            //   bar.log (file)
1653            //   baz/ (empty directory)
1654            tokio::fs::write(test_path.join("foo"), "content").await?;
1655            tokio::fs::write(test_path.join("bar.log"), "content").await?;
1656            tokio::fs::create_dir(test_path.join("baz")).await?;
1657            // exclude only .log files
1658            let mut filter = FilterSettings::new();
1659            filter.add_exclude("*.log").unwrap();
1660            let summary = rm(
1661                &PROGRESS,
1662                &test_path,
1663                &Settings {
1664                    fail_early: false,
1665                    filter: Some(filter),
1666                    dry_run: Some(DryRunMode::Explain),
1667                    time_filter: None,
1668                },
1669            )
1670            .await?;
1671            // foo should be reported as would-be-removed, bar.log skipped, baz/ removed
1672            assert_eq!(
1673                summary.files_removed, 1,
1674                "should report 'foo' would be removed"
1675            );
1676            assert_eq!(
1677                summary.files_skipped, 1,
1678                "should report 'bar.log' would be skipped"
1679            );
1680            assert_eq!(
1681                summary.directories_removed, 1,
1682                "should report empty 'baz' directory would be removed"
1683            );
1684            // verify nothing was actually removed (dry-run mode)
1685            assert!(test_path.join("foo").exists(), "foo should still exist");
1686            assert!(
1687                test_path.join("bar.log").exists(),
1688                "bar.log should still exist"
1689            );
1690            assert!(test_path.join("baz").exists(), "baz should still exist");
1691            Ok(())
1692        }
1693        /// Test that dry-run correctly reports removal of an empty directory that directly
1694        /// matches an include pattern (not merely traversed).
1695        #[tokio::test]
1696        #[traced_test]
1697        async fn test_dry_run_include_directly_matched_empty_dir_reported()
1698        -> Result<(), anyhow::Error> {
1699            let test_path = testutils::create_temp_dir().await?;
1700            // create structure:
1701            // test/
1702            //   foo (file)
1703            //   baz/ (empty directory)
1704            tokio::fs::write(test_path.join("foo"), "content").await?;
1705            tokio::fs::create_dir(test_path.join("baz")).await?;
1706            // include pattern that directly matches the directory
1707            let mut filter = FilterSettings::new();
1708            filter.add_include("baz/").unwrap();
1709            let summary = rm(
1710                &PROGRESS,
1711                &test_path,
1712                &Settings {
1713                    fail_early: false,
1714                    filter: Some(filter),
1715                    dry_run: Some(DryRunMode::Explain),
1716                    time_filter: None,
1717                },
1718            )
1719            .await?;
1720            assert_eq!(
1721                summary.directories_removed, 1,
1722                "should report directly matched empty 'baz' would be removed"
1723            );
1724            assert_eq!(summary.files_removed, 0, "should not report 'foo'");
1725            // verify nothing was actually removed (dry-run mode)
1726            assert!(test_path.join("foo").exists(), "foo should still exist");
1727            assert!(test_path.join("baz").exists(), "baz should still exist");
1728            Ok(())
1729        }
1730    }
1731    mod time_filter_tests {
1732        use super::*;
1733        use crate::filter::TimeFilter;
1734
1735        fn set_mtime_age(path: &std::path::Path, age: std::time::Duration) -> anyhow::Result<()> {
1736            let past = filetime::FileTime::from_system_time(std::time::SystemTime::now() - age);
1737            filetime::set_file_mtime(path, past)?;
1738            Ok(())
1739        }
1740
1741        /// File with mtime older than threshold is removed.
1742        #[tokio::test]
1743        #[traced_test]
1744        async fn removes_files_older_than_modified_before() -> Result<(), anyhow::Error> {
1745            let test_path = testutils::create_temp_dir().await?;
1746            let file = test_path.join("old.txt");
1747            tokio::fs::write(&file, "x").await?;
1748            set_mtime_age(&file, std::time::Duration::from_secs(7200))?;
1749            // age test_path so the root dir passes its own time filter check
1750            set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1751            let summary = rm(
1752                &PROGRESS,
1753                &test_path,
1754                &Settings {
1755                    fail_early: false,
1756                    filter: None,
1757                    time_filter: Some(TimeFilter {
1758                        modified_before: Some(std::time::Duration::from_secs(3600)),
1759                        created_before: None,
1760                    }),
1761                    dry_run: None,
1762                },
1763            )
1764            .await?;
1765            assert_eq!(summary.files_removed, 1, "old file should be removed");
1766            assert_eq!(summary.files_skipped, 0);
1767            assert!(!file.exists(), "old.txt should be removed");
1768            Ok(())
1769        }
1770
1771        /// File with mtime newer than threshold is skipped.
1772        #[tokio::test]
1773        #[traced_test]
1774        async fn keeps_files_newer_than_modified_before() -> Result<(), anyhow::Error> {
1775            let test_path = testutils::create_temp_dir().await?;
1776            let file = test_path.join("new.txt");
1777            tokio::fs::write(&file, "x").await?;
1778            set_mtime_age(&file, std::time::Duration::from_secs(60))?;
1779            set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1780            let summary = rm(
1781                &PROGRESS,
1782                &test_path,
1783                &Settings {
1784                    fail_early: false,
1785                    filter: None,
1786                    time_filter: Some(TimeFilter {
1787                        modified_before: Some(std::time::Duration::from_secs(3600)),
1788                        created_before: None,
1789                    }),
1790                    dry_run: None,
1791                },
1792            )
1793            .await?;
1794            assert_eq!(summary.files_removed, 0, "new file should not be removed");
1795            assert_eq!(summary.files_skipped, 1, "new file should be skipped");
1796            assert!(file.exists(), "new.txt should still exist");
1797            Ok(())
1798        }
1799
1800        /// A fresh subdirectory is descended into (children are handled individually),
1801        /// but the fresh_dir itself is not removed because its own mtime is too recent.
1802        #[tokio::test]
1803        #[traced_test]
1804        async fn fresh_subdirectory_is_descended_but_not_removed() -> Result<(), anyhow::Error> {
1805            let test_path = testutils::create_temp_dir().await?;
1806            let old_file = test_path.join("old.txt");
1807            let fresh_dir = test_path.join("fresh_dir");
1808            let fresh_child = fresh_dir.join("fresh_child.txt");
1809            let old_child = fresh_dir.join("old_child.txt");
1810            tokio::fs::write(&old_file, "x").await?;
1811            tokio::fs::create_dir(&fresh_dir).await?;
1812            tokio::fs::write(&fresh_child, "x").await?;
1813            tokio::fs::write(&old_child, "x").await?;
1814            set_mtime_age(&old_file, std::time::Duration::from_secs(7200))?;
1815            set_mtime_age(&old_child, std::time::Duration::from_secs(7200))?;
1816            // fresh_child keeps its recent mtime; so does fresh_dir (we took the mtime
1817            // snapshot before remove_file mutates it)
1818            set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1819            let summary = rm(
1820                &PROGRESS,
1821                &test_path,
1822                &Settings {
1823                    fail_early: false,
1824                    filter: None,
1825                    time_filter: Some(TimeFilter {
1826                        modified_before: Some(std::time::Duration::from_secs(3600)),
1827                        created_before: None,
1828                    }),
1829                    dry_run: None,
1830                },
1831            )
1832            .await?;
1833            // we descend into fresh_dir: old_child removed, fresh_child skipped
1834            assert_eq!(summary.files_removed, 2, "old.txt and old_child removed");
1835            assert_eq!(
1836                summary.files_skipped, 1,
1837                "fresh_child skipped inside fresh_dir"
1838            );
1839            assert_eq!(
1840                summary.directories_skipped, 1,
1841                "fresh_dir itself is skipped at removal time"
1842            );
1843            assert_eq!(
1844                summary.directories_removed, 0,
1845                "root survives because fresh_dir is still inside it"
1846            );
1847            assert!(!old_file.exists());
1848            assert!(!old_child.exists(), "old_child inside fresh_dir removed");
1849            assert!(
1850                fresh_dir.exists(),
1851                "fresh_dir kept despite its old child being removed"
1852            );
1853            assert!(fresh_child.exists(), "fresh_child inside fresh_dir kept");
1854            Ok(())
1855        }
1856
1857        /// An old directory that still holds a new (skipped) file survives as non-empty.
1858        /// The leftover-dir case is not treated as an error.
1859        #[tokio::test]
1860        #[traced_test]
1861        async fn old_dir_with_new_file_leaves_non_empty_dir_without_error()
1862        -> Result<(), anyhow::Error> {
1863            let test_path = testutils::create_temp_dir().await?;
1864            let old_dir = test_path.join("old_dir");
1865            tokio::fs::create_dir(&old_dir).await?;
1866            let new_file = old_dir.join("new.txt");
1867            tokio::fs::write(&new_file, "x").await?;
1868            set_mtime_age(&new_file, std::time::Duration::from_secs(60))?;
1869            set_mtime_age(&old_dir, std::time::Duration::from_secs(7200))?;
1870            set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1871            tokio::fs::set_permissions(&old_dir, std::fs::Permissions::from_mode(0o555)).await?;
1872            let result = rm(
1873                &PROGRESS,
1874                &test_path,
1875                &Settings {
1876                    fail_early: false,
1877                    filter: None,
1878                    time_filter: Some(TimeFilter {
1879                        modified_before: Some(std::time::Duration::from_secs(3600)),
1880                        created_before: None,
1881                    }),
1882                    dry_run: None,
1883                },
1884            )
1885            .await;
1886            let summary = result.expect("ENOTEMPTY should not surface as an error");
1887            assert_eq!(summary.files_skipped, 1, "new file should be skipped");
1888            assert_eq!(
1889                summary.directories_removed, 0,
1890                "old_dir cannot be removed while new.txt remains"
1891            );
1892            assert!(old_dir.exists(), "old_dir should still exist");
1893            assert!(new_file.exists(), "new.txt should still exist");
1894            assert_eq!(
1895                tokio::fs::metadata(&old_dir).await?.permissions().mode() & 0o7777,
1896                0o555,
1897                "ENOTEMPTY retention did not restore the original directory mode"
1898            );
1899            // the 'left intact' message is logged at info level
1900            assert!(
1901                logs_contain("not empty after filtering, leaving it intact"),
1902                "should log ENOTEMPTY case at info"
1903            );
1904            Ok(())
1905        }
1906
1907        /// An old, already-empty directory is removed by the time filter run.
1908        #[tokio::test]
1909        #[traced_test]
1910        async fn old_empty_directory_is_removed() -> Result<(), anyhow::Error> {
1911            let test_path = testutils::create_temp_dir().await?;
1912            let old_empty = test_path.join("old_empty");
1913            tokio::fs::create_dir(&old_empty).await?;
1914            set_mtime_age(&old_empty, std::time::Duration::from_secs(7200))?;
1915            set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1916            let summary = rm(
1917                &PROGRESS,
1918                &test_path,
1919                &Settings {
1920                    fail_early: false,
1921                    filter: None,
1922                    time_filter: Some(TimeFilter {
1923                        modified_before: Some(std::time::Duration::from_secs(3600)),
1924                        created_before: None,
1925                    }),
1926                    dry_run: None,
1927                },
1928            )
1929            .await?;
1930            // both old_empty and test_path itself are removed
1931            assert_eq!(summary.directories_removed, 2);
1932            assert!(!old_empty.exists());
1933            assert!(!test_path.exists());
1934            Ok(())
1935        }
1936
1937        /// Time filter combines with glob exclude — both must pass for removal.
1938        #[tokio::test]
1939        #[traced_test]
1940        async fn time_filter_combines_with_glob_exclude() -> Result<(), anyhow::Error> {
1941            let test_path = testutils::create_temp_dir().await?;
1942            let old_keep = test_path.join("keep.log");
1943            let old_drop = test_path.join("drop.txt");
1944            let new_drop = test_path.join("recent.txt");
1945            tokio::fs::write(&old_keep, "x").await?;
1946            tokio::fs::write(&old_drop, "x").await?;
1947            tokio::fs::write(&new_drop, "x").await?;
1948            set_mtime_age(&old_keep, std::time::Duration::from_secs(7200))?;
1949            set_mtime_age(&old_drop, std::time::Duration::from_secs(7200))?;
1950            set_mtime_age(&new_drop, std::time::Duration::from_secs(60))?;
1951            set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1952            let mut filter = crate::filter::FilterSettings::new();
1953            filter.add_exclude("*.log").unwrap();
1954            let summary = rm(
1955                &PROGRESS,
1956                &test_path,
1957                &Settings {
1958                    fail_early: false,
1959                    filter: Some(filter),
1960                    time_filter: Some(TimeFilter {
1961                        modified_before: Some(std::time::Duration::from_secs(3600)),
1962                        created_before: None,
1963                    }),
1964                    dry_run: None,
1965                },
1966            )
1967            .await?;
1968            // only old_drop passes both filters
1969            assert_eq!(summary.files_removed, 1, "only old_drop should be removed");
1970            assert_eq!(
1971                summary.files_skipped, 2,
1972                "old_keep and recent_drop should be skipped"
1973            );
1974            assert!(
1975                old_keep.exists(),
1976                "keep.log excluded by glob, should remain"
1977            );
1978            assert!(!old_drop.exists(), "drop.txt should be removed");
1979            assert!(new_drop.exists(), "recent.txt should remain (too new)");
1980            Ok(())
1981        }
1982
1983        /// Dry-run with time filter previews removal without modifying files.
1984        #[tokio::test]
1985        #[traced_test]
1986        async fn time_filter_with_dry_run() -> Result<(), anyhow::Error> {
1987            let test_path = testutils::create_temp_dir().await?;
1988            let old_file = test_path.join("old.txt");
1989            let new_file = test_path.join("new.txt");
1990            tokio::fs::write(&old_file, "x").await?;
1991            tokio::fs::write(&new_file, "x").await?;
1992            set_mtime_age(&old_file, std::time::Duration::from_secs(7200))?;
1993            set_mtime_age(&new_file, std::time::Duration::from_secs(60))?;
1994            set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1995            let summary = rm(
1996                &PROGRESS,
1997                &test_path,
1998                &Settings {
1999                    fail_early: false,
2000                    filter: None,
2001                    time_filter: Some(TimeFilter {
2002                        modified_before: Some(std::time::Duration::from_secs(3600)),
2003                        created_before: None,
2004                    }),
2005                    dry_run: Some(DryRunMode::Explain),
2006                },
2007            )
2008            .await?;
2009            assert_eq!(
2010                summary.files_removed, 1,
2011                "should report old file would be removed"
2012            );
2013            assert_eq!(
2014                summary.files_skipped, 1,
2015                "should report new file would be skipped"
2016            );
2017            assert!(old_file.exists(), "old.txt should still exist (dry-run)");
2018            assert!(new_file.exists(), "new.txt should still exist (dry-run)");
2019            Ok(())
2020        }
2021
2022        /// A fresh top-level directory is traversed (its old children are removed),
2023        /// but the root itself is not removed because its own mtime is too recent.
2024        #[tokio::test]
2025        #[traced_test]
2026        async fn fresh_top_level_directory_is_traversed_but_not_removed()
2027        -> Result<(), anyhow::Error> {
2028            let test_path = testutils::create_temp_dir().await?;
2029            let old_inside = test_path.join("old.txt");
2030            tokio::fs::write(&old_inside, "x").await?;
2031            set_mtime_age(&old_inside, std::time::Duration::from_secs(7200))?;
2032            // test_path itself is left fresh (recent mtime)
2033            tokio::fs::set_permissions(&test_path, std::fs::Permissions::from_mode(0o555)).await?;
2034            let summary = rm(
2035                &PROGRESS,
2036                &test_path,
2037                &Settings {
2038                    fail_early: false,
2039                    filter: None,
2040                    time_filter: Some(TimeFilter {
2041                        modified_before: Some(std::time::Duration::from_secs(3600)),
2042                        created_before: None,
2043                    }),
2044                    dry_run: None,
2045                },
2046            )
2047            .await?;
2048            assert_eq!(
2049                summary.files_removed, 1,
2050                "old child should be removed despite fresh parent"
2051            );
2052            assert_eq!(
2053                summary.directories_skipped, 1,
2054                "fresh root itself is skipped at removal time"
2055            );
2056            assert_eq!(
2057                summary.directories_removed, 0,
2058                "fresh root must not be removed"
2059            );
2060            assert!(test_path.exists(), "fresh root should still exist");
2061            assert!(!old_inside.exists(), "old child should be gone");
2062            assert_eq!(
2063                tokio::fs::metadata(&test_path).await?.permissions().mode() & 0o7777,
2064                0o555,
2065                "time-filter retention did not restore the original directory mode"
2066            );
2067            Ok(())
2068        }
2069
2070        /// Time filter on a single-file root argument increments skip when too new.
2071        #[tokio::test]
2072        #[traced_test]
2073        async fn time_filter_on_root_file_argument() -> Result<(), anyhow::Error> {
2074            let test_path = testutils::create_temp_dir().await?;
2075            let new_file = test_path.join("new.txt");
2076            tokio::fs::write(&new_file, "x").await?;
2077            set_mtime_age(&new_file, std::time::Duration::from_secs(60))?;
2078            let summary = rm(
2079                &PROGRESS,
2080                &new_file,
2081                &Settings {
2082                    fail_early: false,
2083                    filter: None,
2084                    time_filter: Some(TimeFilter {
2085                        modified_before: Some(std::time::Duration::from_secs(3600)),
2086                        created_before: None,
2087                    }),
2088                    dry_run: None,
2089                },
2090            )
2091            .await?;
2092            assert_eq!(summary.files_removed, 0);
2093            assert_eq!(
2094                summary.files_skipped, 1,
2095                "root file too new should be skipped"
2096            );
2097            assert!(new_file.exists(), "root file should still exist");
2098            Ok(())
2099        }
2100    }
2101
2102    /// Stress tests exercising max-files-in-flight saturation during rm.
2103    mod max_files_in_flight_tests {
2104        use super::*;
2105
2106        /// Public root setup must reserve metadata capacity before opening the operand parent or
2107        /// performing the fd-based root filter probe. Otherwise many CLI operands can bypass the
2108        /// shared driver's later admission point concurrently.
2109        #[tokio::test]
2110        async fn filtered_root_waits_for_admission_before_setup() -> Result<(), anyhow::Error> {
2111            let root = testutils::create_temp_dir().await?;
2112            let path = root.join("victim");
2113            tokio::fs::write(&path, b"x").await?;
2114            let mut filter = crate::filter::FilterSettings::new();
2115            filter.add_exclude("victim")?;
2116            let settings = Settings {
2117                fail_early: true,
2118                filter: Some(filter),
2119                dry_run: None,
2120                time_filter: None,
2121            };
2122            let admission = testutils::AdmissionLimit::new().await;
2123            admission.set_files_in_flight(1);
2124            let stat_resource =
2125                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
2126            admission.set_max_ops_in_flight(stat_resource, 1);
2127            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
2128            let operation = rm(&PROGRESS, &path, &settings);
2129            tokio::pin!(operation);
2130            let stopped_at_stat_gate = futures::poll!(operation.as_mut()).is_pending();
2131            let mut second_permit = Box::pin(throttle::pending_meta_permit());
2132            let setup_bypassed_admission = futures::poll!(second_permit.as_mut()).is_ready();
2133            drop(second_permit);
2134            drop(held_stat);
2135            let result = admission
2136                .run_with_timeout(std::time::Duration::from_secs(20), operation.as_mut())
2137                .await;
2138            assert!(
2139                stopped_at_stat_gate,
2140                "rm root did not reach the held stat gate"
2141            );
2142            let summary =
2143                result.context("rm root did not resume after stat capacity was released")??;
2144            assert!(
2145                !setup_bypassed_admission,
2146                "rm performed root setup before pending-metadata admission"
2147            );
2148            assert_eq!(summary.files_skipped, 1);
2149            Ok(())
2150        }
2151
2152        /// Wide rm: many files with a very low files-in-flight limit.
2153        /// Verifies all files are removed correctly under permit saturation.
2154        #[tokio::test]
2155        #[traced_test]
2156        async fn wide_rm_under_files_in_flight_saturation() -> Result<(), anyhow::Error> {
2157            let test_path = testutils::create_temp_dir().await?;
2158            let file_count = 200;
2159            for i in 0..file_count {
2160                tokio::fs::write(
2161                    test_path.join(format!("{}.txt", i)),
2162                    format!("content-{}", i),
2163                )
2164                .await?;
2165            }
2166            // set a very low limit to force permit contention
2167            let admission = testutils::AdmissionLimit::new().await;
2168            admission.set_files_in_flight(4);
2169            let summary = admission
2170                .run_with_timeout(
2171                    std::time::Duration::from_secs(30),
2172                    rm(
2173                        &PROGRESS,
2174                        &test_path,
2175                        &Settings {
2176                            fail_early: true,
2177                            filter: None,
2178                            dry_run: None,
2179                            time_filter: None,
2180                        },
2181                    ),
2182                )
2183                .await
2184                .context("rm timed out — possible deadlock")??;
2185            assert_eq!(summary.files_removed, file_count);
2186            assert_eq!(summary.directories_removed, 1);
2187            assert!(!test_path.exists());
2188            Ok(())
2189        }
2190
2191        /// Deep + wide rm: a directory tree deeper than the files-in-flight limit, with files at every
2192        /// level. Verifies directories do not retain leaf admission across recursion.
2193        #[tokio::test]
2194        #[traced_test]
2195        async fn deep_tree_no_deadlock_under_files_in_flight_saturation()
2196        -> Result<(), anyhow::Error> {
2197            let test_path = testutils::create_temp_dir().await?;
2198            let depth = 20;
2199            let files_per_level = 5;
2200            let limit = 1;
2201            // create a directory chain deeper than the permit limit, with files at each level
2202            let mut dir = test_path.clone();
2203            for level in 0..depth {
2204                tokio::fs::create_dir_all(&dir).await?;
2205                for f in 0..files_per_level {
2206                    tokio::fs::write(
2207                        dir.join(format!("f{}_{}.txt", level, f)),
2208                        format!("L{}F{}", level, f),
2209                    )
2210                    .await?;
2211                }
2212                dir = dir.join(format!("d{}", level));
2213            }
2214            let admission = testutils::AdmissionLimit::new().await;
2215            admission.set_files_in_flight(limit);
2216            let summary = admission
2217                .run_with_timeout(
2218                    std::time::Duration::from_secs(30),
2219                    rm(
2220                        &PROGRESS,
2221                        &test_path,
2222                        &Settings {
2223                            fail_early: true,
2224                            filter: None,
2225                            dry_run: None,
2226                            time_filter: None,
2227                        },
2228                    ),
2229                )
2230                .await
2231                .context("rm timed out — possible deadlock")?
2232                .context("rm failed")?;
2233            assert_eq!(summary.files_removed, depth * files_per_level);
2234            assert_eq!(summary.directories_removed, depth);
2235            assert!(!test_path.exists());
2236            Ok(())
2237        }
2238
2239        /// Regression for the hold-and-wait deadlock when a getdents leaf-hint entry is actually a
2240        /// directory (the DT_UNKNOWN edge, or a swap between `getdents` and the authoritative
2241        /// `child()`). The walk pre-acquires a `pending_meta` permit for every possible leaf; if such
2242        /// an entry turns out to be a directory, the spawned task must DROP that permit before
2243        /// recursing — otherwise it holds the permit AND its children block trying to acquire one,
2244        /// and with a saturated pool the whole walk hangs.
2245        ///
2246        /// This reproduces that exact shape deterministically by driving the `RmVisitor` through the
2247        /// driver's [`process_entry`] with the one-and-only permit pre-acquired by the caller
2248        /// (mirroring the spawn loop's hinted-leaf pre-acquire). With the bug, the directory branch
2249        /// would hold that permit while recursing into the directory, whose child file then blocks
2250        /// forever on `pending_meta` (pool size 1, already held by us) — the timeout fires. With the
2251        /// fix, the driver's single drop-before-recurse site releases the permit on the directory
2252        /// path before recursing, the child acquires it, and removal completes well within the
2253        /// timeout.
2254        #[tokio::test]
2255        #[traced_test]
2256        async fn hinted_leaf_that_is_dir_drops_permit_before_recursion() -> anyhow::Result<()> {
2257            let root = testutils::create_temp_dir().await?;
2258            // `d` is a directory (the authoritative type) holding one child file `c`.
2259            let dir_path = root.join("d");
2260            tokio::fs::create_dir(&dir_path).await?;
2261            tokio::fs::write(dir_path.join("c"), b"x").await?;
2262            // size the pending-meta pool to a single permit so a held-across-recursion permit
2263            // strands the child's pre-acquire — the saturation the fd-walk must tolerate.
2264            let admission = testutils::AdmissionLimit::new().await;
2265            admission.set_files_in_flight(1);
2266            // open the container of `d` and classify `d` itself: an authoritative directory.
2267            let parent = Dir::open_parent_dir(&root, congestion::Side::Source)
2268                .await
2269                .context("open parent dir")?;
2270            let parent = Arc::new(parent.into_tree());
2271            let name = std::ffi::OsStr::new("d");
2272            let handle = parent.child(name).await.context("classify d")?;
2273            assert_eq!(
2274                handle.kind(),
2275                EntryKind::Dir,
2276                "fixture `d` must be a directory"
2277            );
2278            drop(handle);
2279            let settings = Settings {
2280                fail_early: true,
2281                filter: None,
2282                dry_run: None,
2283                time_filter: None,
2284            };
2285            let visitor = Arc::new(RmVisitor {
2286                prog_track: &PROGRESS,
2287                settings: settings.clone(),
2288            });
2289            let cx = EntryCx {
2290                parent: Arc::clone(&parent),
2291                name: name.to_owned(),
2292                rel_path: std::path::PathBuf::new(),
2293                filter_path: std::path::PathBuf::new(),
2294                real_path: dir_path.clone(),
2295                dry_run: false,
2296                prog_track: &PROGRESS,
2297            };
2298            // pre-acquire the single permit exactly as the spawn loop does for a hinted leaf, and
2299            // hand it to `process_entry`. The fix drops it before recursing into the directory.
2300            let permit =
2301                crate::walk::preacquire_leaf_permit(PermitKind::PendingMeta, Some(EntryKind::File))
2302                    .await;
2303            assert!(permit.is_some(), "the pre-acquire must take the one permit");
2304            let result = admission
2305                .run_with_timeout(
2306                    std::time::Duration::from_secs(20),
2307                    process_entry(visitor, cx, (), permit),
2308                )
2309                .await;
2310            let summary = result
2311                .context(
2312                    "process_entry hung — leaf permit held across directory recursion (deadlock)",
2313                )?
2314                .map_err(|e| e.source)?;
2315            assert_eq!(summary.files_removed, 1, "child file should be removed");
2316            assert_eq!(
2317                summary.directories_removed, 1,
2318                "directory should be removed"
2319            );
2320            assert!(!dir_path.exists(), "directory `d` should be gone");
2321            Ok(())
2322        }
2323    }
2324
2325    /// TOCTOU race tests for the fd-based recursive removal.
2326    mod race_tests {
2327        use super::*;
2328
2329        static RACE_PROGRESS: std::sync::LazyLock<progress::Progress> =
2330            std::sync::LazyLock::new(progress::Progress::new);
2331
2332        #[tokio::test]
2333        async fn non_directory_action_cannot_remove_directory_replacement() -> anyhow::Result<()> {
2334            let tmp = tempfile::tempdir()?;
2335            let entry = tmp.path().join("entry");
2336            let original = tmp.path().join("original");
2337            tokio::fs::write(&entry, b"original").await?;
2338            let parent = Arc::new(
2339                Dir::open_parent_dir(tmp.path(), congestion::Side::Source)
2340                    .await?
2341                    .into_tree(),
2342            );
2343            let handle = parent.child(OsStr::new("entry")).await?;
2344            let leaf = AdmittedLeaf::try_new(handle, None)
2345                .map_err(|_| anyhow::anyhow!("file fixture classified as a directory"))?;
2346            tokio::fs::rename(&entry, &original).await?;
2347            tokio::fs::create_dir(&entry).await?;
2348            tokio::fs::write(entry.join("replacement-child"), b"replacement").await?;
2349            let visitor = RmVisitor {
2350                prog_track: &RACE_PROGRESS,
2351                settings: Settings {
2352                    fail_early: true,
2353                    filter: None,
2354                    time_filter: None,
2355                    dry_run: None,
2356                },
2357            };
2358            let cx = EntryCx {
2359                parent,
2360                name: OsStr::new("entry").to_owned(),
2361                rel_path: std::path::PathBuf::from("entry"),
2362                filter_path: std::path::PathBuf::from("entry"),
2363                real_path: entry.clone(),
2364                dry_run: false,
2365                prog_track: &RACE_PROGRESS,
2366            };
2367            let error = visitor
2368                .visit_leaf(&cx, &(), leaf)
2369                .await
2370                .expect_err("a nondirectory unlink must reject a replacement directory");
2371            assert!(format!("{:#}", error.source).contains("failed removing"));
2372            assert_eq!(tokio::fs::read(&original).await?, b"original");
2373            assert_eq!(
2374                tokio::fs::read(entry.join("replacement-child")).await?,
2375                b"replacement"
2376            );
2377            Ok(())
2378        }
2379
2380        #[tokio::test]
2381        async fn directory_replacement_is_not_descended_after_classification() -> anyhow::Result<()>
2382        {
2383            let tmp = tempfile::tempdir()?;
2384            let entry = tmp.path().join("entry");
2385            let original = tmp.path().join("original");
2386            tokio::fs::create_dir(&entry).await?;
2387            let parent = Arc::new(
2388                Dir::open_parent_dir(tmp.path(), congestion::Side::Source)
2389                    .await?
2390                    .into_tree(),
2391            );
2392            let handle = parent.child(OsStr::new("entry")).await?;
2393            tokio::fs::rename(&entry, &original).await?;
2394            tokio::fs::create_dir(&entry).await?;
2395            tokio::fs::write(entry.join("replacement-child"), b"replacement").await?;
2396            let visitor = RmVisitor {
2397                prog_track: &RACE_PROGRESS,
2398                settings: Settings {
2399                    fail_early: true,
2400                    filter: None,
2401                    time_filter: None,
2402                    dry_run: None,
2403                },
2404            };
2405            let cx = EntryCx {
2406                parent,
2407                name: OsStr::new("entry").to_owned(),
2408                rel_path: std::path::PathBuf::from("entry"),
2409                filter_path: std::path::PathBuf::from("entry"),
2410                real_path: entry.clone(),
2411                dry_run: false,
2412                prog_track: &RACE_PROGRESS,
2413            };
2414            let error = match visitor.dir_pre(&cx, &(), &handle).await {
2415                Ok(_) => anyhow::bail!("a replacement passed the directory identity check"),
2416                Err(error) => error,
2417            };
2418            assert!(format!("{:#}", error.source).contains("changed identity before descent"));
2419            assert_eq!(
2420                tokio::fs::read(entry.join("replacement-child")).await?,
2421                b"replacement"
2422            );
2423            Ok(())
2424        }
2425
2426        #[tokio::test]
2427        async fn final_rmdir_removes_an_empty_replacement_in_the_walked_slot() -> anyhow::Result<()>
2428        {
2429            let tmp = tempfile::tempdir()?;
2430            let entry = tmp.path().join("entry");
2431            let original = tmp.path().join("original");
2432            tokio::fs::create_dir(&entry).await?;
2433            let parent = Arc::new(
2434                Dir::open_parent_dir(tmp.path(), congestion::Side::Source)
2435                    .await?
2436                    .into_tree(),
2437            );
2438            let handle = parent.child(OsStr::new("entry")).await?;
2439            let visitor = RmVisitor {
2440                prog_track: &RACE_PROGRESS,
2441                settings: Settings {
2442                    fail_early: true,
2443                    filter: None,
2444                    time_filter: None,
2445                    dry_run: None,
2446                },
2447            };
2448            let cx = EntryCx {
2449                parent,
2450                name: OsStr::new("entry").to_owned(),
2451                rel_path: std::path::PathBuf::from("entry"),
2452                filter_path: std::path::PathBuf::from("entry"),
2453                real_path: entry.clone(),
2454                dry_run: false,
2455                prog_track: &RACE_PROGRESS,
2456            };
2457            let state = match visitor.dir_pre(&cx, &(), &handle).await? {
2458                DirAction::Descend { state, .. } => state,
2459                DirAction::Skip(_) => anyhow::bail!("unfiltered directory fixture was skipped"),
2460            };
2461            tokio::fs::rename(&entry, &original).await?;
2462            tokio::fs::create_dir(&entry).await?;
2463            let summary = visitor
2464                .dir_post(
2465                    &cx,
2466                    state,
2467                    &ProcessedChildren::default(),
2468                    Ok(Summary::default()),
2469                )
2470                .await?;
2471            assert_eq!(summary.directories_removed, 1);
2472            assert!(!entry.exists(), "the empty slot replacement survived rmdir");
2473            assert!(original.is_dir());
2474            Ok(())
2475        }
2476
2477        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2478        async fn intended_removal_keeps_temporary_mode_until_rmdir() -> anyhow::Result<()> {
2479            let tmp = tempfile::tempdir()?;
2480            let entry = tmp.path().join("entry");
2481            tokio::fs::create_dir(&entry).await?;
2482            tokio::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o555)).await?;
2483            let parent = Arc::new(
2484                Dir::open_parent_dir(tmp.path(), congestion::Side::Source)
2485                    .await?
2486                    .into_tree(),
2487            );
2488            let handle = parent.child(OsStr::new("entry")).await?;
2489            let visitor = RmVisitor {
2490                prog_track: &RACE_PROGRESS,
2491                settings: Settings {
2492                    fail_early: true,
2493                    filter: None,
2494                    time_filter: None,
2495                    dry_run: None,
2496                },
2497            };
2498            let cx = EntryCx {
2499                parent,
2500                name: OsStr::new("entry").to_owned(),
2501                rel_path: std::path::PathBuf::from("entry"),
2502                filter_path: std::path::PathBuf::from("entry"),
2503                real_path: entry.clone(),
2504                dry_run: false,
2505                prog_track: &RACE_PROGRESS,
2506            };
2507            let state = match visitor.dir_pre(&cx, &(), &handle).await? {
2508                DirAction::Descend { state, .. } => state,
2509                DirAction::Skip(_) => anyhow::bail!("unfiltered directory fixture was skipped"),
2510            };
2511            assert_eq!(
2512                tokio::fs::metadata(&entry).await?.permissions().mode() & 0o7777,
2513                0o700,
2514                "dir_pre must relax the directory before final removal"
2515            );
2516
2517            let mut gate = testutils::BlockingPathGate::install(entry.clone());
2518            let mut task = tokio::spawn(async move {
2519                visitor
2520                    .dir_post(
2521                        &cx,
2522                        state,
2523                        &ProcessedChildren::default(),
2524                        Ok(Summary::default()),
2525                    )
2526                    .await
2527            });
2528            let timeout = std::time::Duration::from_secs(20);
2529            let gate_start = tokio::time::timeout(timeout, gate.wait_started()).await;
2530            if !matches!(&gate_start, Ok(Ok(_))) {
2531                gate.release_all();
2532                task.abort();
2533                let _ = task.await;
2534                match gate_start {
2535                    Ok(Err(error)) => {
2536                        return Err(error.context("final rmdir did not reach its pre-syscall gate"));
2537                    }
2538                    Err(error) => {
2539                        return Err(anyhow::Error::new(error)
2540                            .context("final rmdir did not reach its pre-syscall gate"));
2541                    }
2542                    Ok(Ok(_)) => unreachable!(),
2543                }
2544            }
2545            let checked_mode = tokio::fs::metadata(&entry)
2546                .await
2547                .map(|metadata| metadata.permissions().mode() & 0o7777);
2548            gate.release_all();
2549            let owner_join = match tokio::time::timeout(timeout, &mut task).await {
2550                Ok(result) => result,
2551                Err(error) => {
2552                    task.abort();
2553                    let _ = task.await;
2554                    return Err(anyhow::Error::new(error)
2555                        .context("final rmdir did not quiesce after its gate released"));
2556                }
2557            };
2558            let result = owner_join.context("final rmdir task panicked")?;
2559            let summary = result.map_err(|error| error.source)?;
2560            assert_eq!(
2561                checked_mode?, 0o700,
2562                "the intended-removal path restored metadata before the rmdir boundary"
2563            );
2564            assert!(!entry.exists(), "the by-name rmdir left the directory name");
2565            assert_eq!(summary.directories_removed, 1);
2566            Ok(())
2567        }
2568
2569        #[tokio::test]
2570        async fn filtered_retention_restores_mode_through_chmod_admission() -> anyhow::Result<()> {
2571            let tmp = tempfile::tempdir()?;
2572            let entry = tmp.path().join("entry");
2573            tokio::fs::create_dir(&entry).await?;
2574            tokio::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o555)).await?;
2575            let mut filter = crate::filter::FilterSettings::new();
2576            filter.add_include("other")?;
2577            let parent = Arc::new(
2578                Dir::open_parent_dir(tmp.path(), congestion::Side::Source)
2579                    .await?
2580                    .into_tree(),
2581            );
2582            let handle = parent.child(OsStr::new("entry")).await?;
2583            let visitor = RmVisitor {
2584                prog_track: &RACE_PROGRESS,
2585                settings: Settings {
2586                    fail_early: true,
2587                    filter: Some(filter),
2588                    time_filter: None,
2589                    dry_run: None,
2590                },
2591            };
2592            let cx = EntryCx {
2593                parent,
2594                name: OsStr::new("entry").to_owned(),
2595                rel_path: std::path::PathBuf::from("entry"),
2596                filter_path: std::path::PathBuf::from("entry"),
2597                real_path: entry.clone(),
2598                dry_run: false,
2599                prog_track: &RACE_PROGRESS,
2600            };
2601            let state = match visitor.dir_pre(&cx, &(), &handle).await? {
2602                DirAction::Descend { state, .. } => state,
2603                DirAction::Skip(_) => anyhow::bail!("include-filter ancestor was skipped"),
2604            };
2605            assert_eq!(
2606                tokio::fs::metadata(&entry).await?.permissions().mode() & 0o7777,
2607                0o700,
2608                "dir_pre did not relax the protected directory"
2609            );
2610
2611            let admission = testutils::AdmissionLimit::new().await;
2612            let chmod_resource =
2613                throttle::Resource::meta(throttle::Side::Destination, throttle::MetadataOp::Chmod);
2614            admission.set_max_ops_in_flight(chmod_resource, 1);
2615            let held_chmod = throttle::ops_in_flight_permit(chmod_resource).await;
2616            let processed = ProcessedChildren::default();
2617            let restore = visitor.dir_post(&cx, state, &processed, Ok(Summary::default()));
2618            tokio::pin!(restore);
2619            assert!(
2620                futures::poll!(restore.as_mut()).is_pending(),
2621                "filtered retention bypassed Destination/Chmod admission"
2622            );
2623            assert_eq!(
2624                tokio::fs::metadata(&entry).await?.permissions().mode() & 0o7777,
2625                0o700,
2626                "filtered retention restored the mode while chmod admission was held"
2627            );
2628            drop(held_chmod);
2629            let summary = admission
2630                .run_with_timeout(std::time::Duration::from_secs(20), restore.as_mut())
2631                .await
2632                .context("filtered mode restore did not resume after chmod admission")?
2633                .map_err(|error| error.source)?;
2634            assert!(entry.is_dir(), "filtered retention removed the directory");
2635            assert_eq!(summary.directories_removed, 0);
2636            assert_eq!(
2637                tokio::fs::metadata(&entry).await?.permissions().mode() & 0o7777,
2638                0o555,
2639                "filtered retention did not restore the original directory mode"
2640            );
2641            Ok(())
2642        }
2643
2644        /// Repeatedly swap `tree/sub` between a real directory (holding a real file) and a symlink
2645        /// to an OUT-OF-TREE sentinel directory, using rename so each individual state is atomic.
2646        /// Two staging names live alongside `sub` and are renamed over it in a tight loop until
2647        /// `stop` is set. Runs on a dedicated OS thread so it makes progress regardless of the
2648        /// tokio runtime's scheduling. Mirrors copy's `intermediate_dir_swap_never_follows_symlink`
2649        /// swapper.
2650        fn spawn_dir_symlink_swapper(
2651            tree: std::path::PathBuf,
2652            sentinel: std::path::PathBuf,
2653            stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
2654        ) -> std::thread::JoinHandle<()> {
2655            std::thread::spawn(move || {
2656                let sub = tree.join("sub");
2657                let staged_dir = tree.join("__staged_sub_dir");
2658                let staged_link = tree.join("__staged_sub_link");
2659                while !stop.load(std::sync::atomic::Ordering::Relaxed) {
2660                    // stage a real directory (with a real file) then swap it in over `sub`.
2661                    let _ = std::fs::remove_dir_all(&staged_dir);
2662                    if std::fs::create_dir(&staged_dir).is_ok() {
2663                        let _ = std::fs::write(staged_dir.join("real.txt"), b"REAL");
2664                        // RENAME_EXCHANGE isn't portable here; remove-then-rename. The window where
2665                        // `sub` is briefly absent is fine — rm may error, an accepted failed-closed
2666                        // outcome. (rm must still never touch the out-of-tree sentinel.)
2667                        let _ = std::fs::remove_dir_all(&sub);
2668                        let _ = std::fs::remove_file(&sub);
2669                        let _ = std::fs::rename(&staged_dir, &sub);
2670                    }
2671                    // stage a symlink to the out-of-tree sentinel dir, then swap it in over `sub`.
2672                    let _ = std::fs::remove_file(&staged_link);
2673                    if std::os::unix::fs::symlink(&sentinel, &staged_link).is_ok() {
2674                        let _ = std::fs::remove_dir_all(&sub);
2675                        let _ = std::fs::remove_file(&sub);
2676                        let _ = std::fs::rename(&staged_link, &sub);
2677                    }
2678                }
2679            })
2680        }
2681
2682        /// While `rm` removes a tree, a background thread rapidly flips an intermediate directory
2683        /// `tree/sub` between a real directory and a symlink to a SENTINEL directory tree that
2684        /// lives OUTSIDE the target, holding files that must never be deleted.
2685        ///
2686        /// [`RmVisitor::dir_pre`] descends a directory via [`Dir::open_dir`] (`O_NOFOLLOW|O_DIRECTORY`), so if
2687        /// `sub` is a symlink at the moment of descent the open fails closed (ELOOP/ENOTDIR) and
2688        /// the walk never follows it into the sentinel. If `sub` is a symlink at the moment of
2689        /// classification it is treated as a leaf and `unlink_at` removes the LINK, never its
2690        /// target. Either way the out-of-tree sentinel files survive — that is the safety
2691        /// assertion, checked on every iteration regardless of timing. Also confirms the run
2692        /// terminates (per-op timeout) rather than hanging or following the link.
2693        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2694        async fn intermediate_dir_swap_never_deletes_out_of_tree_sentinel() -> anyhow::Result<()> {
2695            let tmp = testutils::create_temp_dir().await?;
2696            let root = tmp.as_path();
2697            // the sentinel tree lives OUTSIDE the rm target, reachable only via the swapped symlink.
2698            let sentinel = root.join("sentinel_tree");
2699            tokio::fs::create_dir(&sentinel).await?;
2700            tokio::fs::write(sentinel.join("secret1.txt"), b"SECRET-1").await?;
2701            tokio::fs::create_dir(sentinel.join("subdir")).await?;
2702            tokio::fs::write(sentinel.join("subdir").join("secret2.txt"), b"SECRET-2").await?;
2703
2704            let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2705            let swapper =
2706                spawn_dir_symlink_swapper(root.to_path_buf(), sentinel.clone(), stop.clone());
2707
2708            let settings = Settings {
2709                fail_early: false,
2710                filter: None,
2711                dry_run: None,
2712                time_filter: None,
2713            };
2714            let mut removed = 0usize;
2715            let mut errored = 0usize;
2716            for i in 0..400 {
2717                // (re)create the target tree. To MAXIMIZE the chance rm encounters `tree/sub` while
2718                // the background thread has it in the symlink state, give `tree` many sibling
2719                // subdirectories with files: rm spends real time enumerating/removing them
2720                // concurrently with the swapper's flips, widening the window in which `sub` is
2721                // classified/descended mid-swap. On even iterations we additionally seed `sub` as a
2722                // symlink-to-sentinel up front, deterministically exercising the "intermediate is a
2723                // symlink at classification time → unlink the link, never recurse the target" path.
2724                let tree = root.join("tree");
2725                let _ = tokio::fs::create_dir(&tree).await;
2726                for d in 0..16 {
2727                    let sib = tree.join(format!("sib_{d}"));
2728                    let _ = tokio::fs::create_dir(&sib).await;
2729                    for f in 0..4 {
2730                        let _ = tokio::fs::write(sib.join(format!("f{f}.txt")), b"x").await;
2731                    }
2732                }
2733                let sub = tree.join("sub");
2734                if i % 2 == 0 {
2735                    // deterministically place a symlink-to-sentinel at `sub` (best-effort; the
2736                    // swapper may immediately flip it — that's fine, both states are safe).
2737                    let _ = tokio::fs::remove_dir_all(&sub).await;
2738                    let _ = tokio::fs::remove_file(&sub).await;
2739                    let _ = tokio::fs::symlink(&sentinel, &sub).await;
2740                } else if tokio::fs::symlink_metadata(&sub).await.is_err() {
2741                    let _ = tokio::fs::create_dir(&sub).await;
2742                    let _ = tokio::fs::write(sub.join("real.txt"), b"REAL").await;
2743                }
2744                let result = tokio::time::timeout(
2745                    std::time::Duration::from_secs(30),
2746                    super::rm(&RACE_PROGRESS, &tree, &settings),
2747                )
2748                .await
2749                .expect("rm must not hang under concurrent dir swapping");
2750                match result {
2751                    Ok(_) => removed += 1,
2752                    Err(_) => errored += 1, // a swap was caught mid-walk (failed closed) — accepted
2753                }
2754                // CORE SAFETY ASSERTION (holds on every iteration regardless of timing): the
2755                // out-of-tree sentinel tree and its files are NEVER deleted by rm — neither by
2756                // following a symlinked `sub` (unlink removes the link, not the target) nor by
2757                // descending it (open_dir's O_NOFOLLOW fails closed).
2758                assert!(
2759                    sentinel.exists(),
2760                    "iteration {i}: sentinel directory was deleted — rm followed the symlink"
2761                );
2762                let s1 = tokio::fs::read(sentinel.join("secret1.txt")).await;
2763                assert!(
2764                    matches!(&s1, Ok(b) if b == b"SECRET-1"),
2765                    "iteration {i}: sentinel/secret1.txt was deleted or altered — rm followed the symlink"
2766                );
2767                let s2 = tokio::fs::read(sentinel.join("subdir").join("secret2.txt")).await;
2768                assert!(
2769                    matches!(&s2, Ok(b) if b == b"SECRET-2"),
2770                    "iteration {i}: sentinel/subdir/secret2.txt was deleted — rm recursed through the symlink"
2771                );
2772                // clean up any leftover tree before the next iteration (best-effort).
2773                let _ = tokio::fs::remove_dir_all(root.join("tree")).await;
2774            }
2775
2776            stop.store(true, std::sync::atomic::Ordering::Relaxed);
2777            swapper.join().expect("dir swapper thread panicked");
2778            // sanity (not the safety assertion): the run did observable work across the iterations.
2779            tracing::info!("intermediate dir swap: removed={removed}, errored={errored}");
2780            assert!(
2781                removed + errored > 0,
2782                "expected at least one observable outcome across the iterations"
2783            );
2784            Ok(())
2785        }
2786    }
2787}