Skip to main content

pristine/
delete.rs

1//! The deleter: the half that cannot be undone.
2//!
3//! ## Why there is a plan
4//!
5//! Every safety check happens while the [`Plan`] is being built, and removal executes the
6//! plan without re-deciding anything. That is what makes `--dry-run` honest rather than
7//! approximate: the thing printed is the same object the deleter consumes, so a preview
8//! cannot disagree with the run it previews.
9//!
10//! ## Resolving a path without following it
11//!
12//! Proving a target is under the scan root means resolving `..` and any symlinked ancestor,
13//! which is what [`fs::canonicalize`] does — except that canonicalising the *target* would
14//! also resolve the target itself, and a symlinked claim (Bazel's `bazel-*`) must be unlinked
15//! as a link rather than followed to whatever it points at. So the parent is canonicalised
16//! and the final component is joined back on. Nothing can hide in the ancestry, and the leaf
17//! is left alone.
18//!
19//! ## The checks that fail toward "keep"
20//!
21//! Tier two's review (#588) found three bugs of the same shape: a check whose failure mode is
22//! silence, so an unreadable or unseen subtree reads as a cleared one. The deleter inherits
23//! that discipline. A directory it could not read is a failure, not an empty directory; a
24//! subtree it refused to enter leaves every ancestor standing, because the `rmdir` is only
25//! attempted when every child is known to be gone.
26//!
27//! ## The under-root check is not enough on its own, and why nothing is removed by name
28//!
29//! The plan's check is defence in depth against a malformed, stale or hostile path arriving
30//! from a caller, a config file or a scan of a tree someone else can write to. But what it
31//! proves, it proves about a *path*, and a path is a name that something else can re-point.
32//! A check like that is worth what it is worth at the moment of the `unlink`, not at the
33//! moment it ran — and in between sit a printed plan and a confirmation prompt.
34//!
35//! So the removal never re-walks a target by name. It opens the scan root once and then
36//! **descends by descriptor**: every component is opened from its already-open parent with
37//! `openat(fd, name, O_DIRECTORY | O_NOFOLLOW)`, and every removal is an `unlinkat` against
38//! the descriptor of the directory that holds the entry. A component swapped for a symlink
39//! fails the open with `ELOOP` rather than redirecting it, because the kernel resolves one
40//! name against one held descriptor and there is no path left for anything to re-point.
41//! "Under the root" becomes a property of how the syscall was issued.
42//!
43//! `cap-primitives` supplies those calls. It is the same machinery `cap-std` is built from,
44//! and it is why this does not need `libc` and therefore does not need the `unsafe` this
45//! crate forbids: the descent this module always wanted turns out to be reachable in safe
46//! Rust. The root is opened once per batch rather than once per target, so the root itself
47//! cannot be swapped mid-run either.
48//!
49//! One path still has to be resolved by name, and it cannot be avoided: the scan root has to
50//! be opened from somewhere. That makes it the most dangerous name in the program rather than
51//! an exempt one, because every descriptor descends from that handle — get it wrong and the
52//! whole batch is misdirected, not one target. So [`open_root`] opens the root's final
53//! component with `O_NOFOLLOW` from its own parent, and then checks the descriptor's
54//! `(device, inode)` against the pair recorded while the plan was built. The second check is
55//! the one that matters: a root renamed away and replaced by an ordinary directory on the same
56//! filesystem offers no symlink to refuse and crosses no boundary, so nothing about the name
57//! distinguishes it from the directory the planner validated. Only the inode does.
58//!
59//! ## Fan-out
60//!
61//! `unlink` and `rmdir` are latency-bound rather than CPU-bound, so the pool is deliberately
62//! oversubscribed — the same conclusion the Node predecessor reached empirically. The unit of
63//! work is one target, not one directory: a sweep, which is the mode this exists for, has
64//! hundreds of targets, and per-target parallelism would need a join counter per directory to
65//! know when its `rmdir` is safe. Removing a single target is therefore single-threaded, as
66//! `rm -rf` is.
67
68use std::collections::HashSet;
69use std::ffi::{OsStr, OsString};
70use std::fmt;
71use std::fs;
72use std::io::{self, BufRead, Write};
73use std::path::{Component, Path, PathBuf};
74use std::sync::atomic::{AtomicUsize, Ordering};
75use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
76use std::time::{Duration, SystemTime};
77
78use cap_primitives::ambient_authority;
79use cap_primitives::fs::{
80    FollowSymlinks, Metadata, open_ambient_dir, open_dir_nofollow, read_base_dir, remove_dir,
81    remove_file, stat,
82};
83
84use crate::git;
85use crate::size::{Size, Stat, allocated, device, identity, multiply_linked};
86use crate::walk::Hit;
87
88/// How far the pool is oversubscribed past the machine's parallelism, because the work is
89/// waiting on the filesystem rather than on a core.
90const OVERSUBSCRIPTION: usize = 4;
91
92/// An upper bound on the pool, so a many-core machine does not spawn hundreds of threads to
93/// contend for one device queue. Bounded rather than tuned: past this point the win has not
94/// been measured, and the cost — a stack each — has.
95const MAX_THREADS: usize = 64;
96
97/// A directory offered for removal.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct Target {
100    /// Where it is, as the caller knows it. Resolved when the plan is built.
101    pub path: PathBuf,
102    /// What the scan knew about its size, which on a default scan is nothing.
103    pub size: Size,
104}
105
106impl Target {
107    /// A target at `path`, with no size known.
108    #[must_use]
109    pub fn at(path: impl Into<PathBuf>) -> Self {
110        Self {
111            path: path.into(),
112            size: Size::Unmeasured,
113        }
114    }
115}
116
117impl From<&Hit> for Target {
118    fn from(hit: &Hit) -> Self {
119        Self {
120            path: hit.path.clone(),
121            size: hit.size,
122        }
123    }
124}
125
126/// Why a directory was left where it is.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum Refusal {
129    /// It did not resolve to somewhere under the scan root. Covers `..`, a symlinked
130    /// ancestor, an absolute path from somewhere else, and the scan root itself.
131    OutsideRoot,
132    /// Another target in the same plan contains it, so removing that one removes this.
133    AlreadyCovered(PathBuf),
134    /// Touched more recently than the age floor allows.
135    RecentlyUsed {
136        /// How long ago it was touched, or `None` when the clock and the filesystem
137        /// disagree about which came first.
138        age: Option<Duration>,
139    },
140    /// On a different filesystem from the scan root, and `one_file_system` is on.
141    OtherFileSystem,
142    /// It holds a git checkout, so somewhere under it is work that may exist nowhere else.
143    HoldsCheckout,
144    /// It is a linked work tree, but it has uncommitted changes or untracked files.
145    ///
146    /// Ignored files are not work — a work tree carrying 4 GiB of `node_modules` is clean, and
147    /// that content is what this program exists to regenerate rather than keep.
148    WorkTreeInUse,
149    /// It is a linked work tree whose `HEAD` is detached.
150    ///
151    /// The one way removing one of these loses a commit: work committed on a detached `HEAD` is
152    /// reachable only through that work tree's own `HEAD`, so once the directory goes and the
153    /// administrative files are pruned, nothing refers to it. On a branch it is an ordinary ref
154    /// in the repository and survives the directory by design.
155    WorkTreeDetached,
156    /// It could not be read, so nothing about it could be proved.
157    Unreadable(String),
158}
159
160impl fmt::Display for Refusal {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        match self {
163            Self::OutsideRoot => write!(f, "does not resolve to somewhere under the scan root"),
164            Self::AlreadyCovered(by) => write!(f, "already covered by {}", by.display()),
165            Self::RecentlyUsed { age: Some(age) } => {
166                write!(f, "touched {} ago", humanise(*age))
167            }
168            Self::RecentlyUsed { age: None } => write!(f, "touched in the future"),
169            Self::OtherFileSystem => write!(f, "on another filesystem"),
170            Self::HoldsCheckout => write!(f, "holds a git checkout"),
171            Self::WorkTreeInUse => write!(f, "has uncommitted or untracked work in it"),
172            Self::WorkTreeDetached => {
173                write!(
174                    f,
175                    "is on a detached HEAD, so its commits are reachable from nothing else"
176                )
177            }
178            Self::Unreadable(why) => write!(f, "{why}"),
179        }
180    }
181}
182
183/// One directory that was left alone, and why.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct Refused {
186    /// The directory. For a mid-removal refusal this is the subtree that stopped it, which
187    /// is more specific than the target it sits in.
188    pub path: PathBuf,
189    /// What stopped it.
190    pub reason: Refusal,
191}
192
193/// A target that survived every check, with its path resolved.
194#[derive(Debug, Clone)]
195pub struct PlanTarget {
196    /// The resolved path: no `..`, no symlinked ancestor, and proved to be under the root.
197    /// This — never the requested path — is what the deleter unlinks.
198    pub path: PathBuf,
199    /// What the caller asked for, kept so a report can name what the user typed.
200    pub requested: PathBuf,
201    /// What the scan knew about its size.
202    pub size: Size,
203    /// Whether the target is itself a symlink, in which case removing it is one `unlink`.
204    pub is_symlink: bool,
205    /// Whether this target is a linked work tree the plan has approved removing whole.
206    ///
207    /// The **only** thing that lets a sweep past its own checkout refusal, and it licenses that
208    /// exactly once: at this target's own root. A checkout nested anywhere inside is refused as
209    /// it always was, because nothing has been proved about that one.
210    pub checkout: bool,
211}
212
213/// A resolved, checked list of directories to remove.
214///
215/// Building one performs every check in the safety model. The deleter re-derives nothing, so
216/// what [`Plan`] says is what happens.
217#[derive(Debug, Clone)]
218pub struct Plan {
219    root: PathBuf,
220    /// Which directory the root's path named when the plan was built. The deleter has to
221    /// resolve that path once, and this is what proves the descriptor it gets back is the
222    /// same directory rather than whatever has taken the name since.
223    root_identity: Option<(u64, u64)>,
224    targets: Vec<PlanTarget>,
225    kept: Vec<Refused>,
226    boundary: u64,
227    one_file_system: bool,
228}
229
230impl Plan {
231    /// The canonical scan root. Nothing outside it is ever touched.
232    #[must_use]
233    pub fn root(&self) -> &Path {
234        &self.root
235    }
236
237    /// The directories that will be removed.
238    #[must_use]
239    pub fn targets(&self) -> &[PlanTarget] {
240        &self.targets
241    }
242
243    /// The directories that will not be, and why.
244    #[must_use]
245    pub fn kept(&self) -> &[Refused] {
246        &self.kept
247    }
248
249    /// Whether there is anything to do.
250    #[must_use]
251    pub fn is_empty(&self) -> bool {
252        self.targets.is_empty()
253    }
254
255    /// The bytes the plan can put a number on. Read it beside [`Plan::unpriced`]: a default
256    /// scan measures nothing, so a plan over a 40 GB tree can honestly report zero here.
257    #[must_use]
258    pub fn measured_bytes(&self) -> u64 {
259        self.targets
260            .iter()
261            .filter_map(|target| target.size.bytes())
262            .sum()
263    }
264
265    /// How many targets carry no size, because the scan pruned at them rather than walking
266    /// them to produce a number it was about to discard.
267    #[must_use]
268    pub fn unpriced(&self) -> usize {
269        self.targets
270            .iter()
271            .filter(|target| target.size.bytes().is_none())
272            .count()
273    }
274}
275
276/// Builds a [`Plan`] under a fixed policy.
277#[derive(Debug, Clone)]
278pub struct Planner {
279    root: PathBuf,
280    one_file_system: bool,
281    older_than: Option<Duration>,
282}
283
284impl Planner {
285    /// A planner for `root`, with the safety model's defaults: one filesystem, no age floor.
286    #[must_use]
287    pub fn new(root: impl AsRef<Path>) -> Self {
288        Self {
289            root: root.as_ref().to_path_buf(),
290            one_file_system: true,
291            older_than: None,
292        }
293    }
294
295    /// Whether to refuse a target on a different filesystem from the root. On by default:
296    /// crossing a mount is how a scan of one project reaches a network share or a backup
297    /// volume that happens to be mounted inside it.
298    #[must_use]
299    pub fn one_file_system(mut self, one_file_system: bool) -> Self {
300        self.one_file_system = one_file_system;
301        self
302    }
303
304    /// Refuse anything touched more recently than this. Off by default — a floor that is on
305    /// without being asked for silently keeps directories the user chose — but recommended,
306    /// because a `node_modules` used this morning is not reclaimable in any useful sense.
307    #[must_use]
308    pub fn older_than(mut self, older_than: Option<Duration>) -> Self {
309        self.older_than = older_than;
310        self
311    }
312
313    /// Resolves and checks every target.
314    ///
315    /// Nothing here touches the filesystem beyond `stat` and `canonicalize`. A target that
316    /// fails any check is moved to [`Plan::kept`] rather than dropped, because a directory
317    /// the user selected and did not get is something they need to be told about.
318    #[must_use]
319    pub fn plan<I>(&self, targets: I) -> Plan
320    where
321        I: IntoIterator<Item = Target>,
322    {
323        let now = SystemTime::now();
324        let ValidatedRoot {
325            path: root,
326            device: boundary,
327            identity: root_identity,
328        } = match canonical_root(&self.root) {
329            Ok(resolved) => resolved,
330            Err(err) => {
331                // With no root there is nothing to prove anything against, so every target
332                // is refused rather than judged against a path that does not exist.
333                let why = format!("{}: {err}", self.root.display());
334                return Plan {
335                    root: self.root.clone(),
336                    root_identity: None,
337                    targets: Vec::new(),
338                    kept: targets
339                        .into_iter()
340                        .map(|target| Refused {
341                            path: target.path,
342                            reason: Refusal::Unreadable(why.clone()),
343                        })
344                        .collect(),
345                    boundary: 0,
346                    one_file_system: self.one_file_system,
347                };
348            }
349        };
350
351        let mut accepted = Vec::new();
352        let mut kept = Vec::new();
353        for target in targets {
354            match self.judge(&target, &root, boundary, now) {
355                Ok(planned) => accepted.push(planned),
356                Err(reason) => kept.push(Refused {
357                    path: target.path,
358                    reason,
359                }),
360            }
361        }
362
363        // Sorted, so the only possible ancestor of a target is the last one retained: no
364        // retained target contains another, and a parent always sorts before its children.
365        accepted.sort_by(|a, b| a.path.cmp(&b.path));
366        let mut targets: Vec<PlanTarget> = Vec::with_capacity(accepted.len());
367        for target in accepted {
368            match targets.last() {
369                // Removing the outer target removes the inner one. Keeping both would report
370                // a failure for a directory that is gone because the plan worked.
371                Some(outer) if target.path.starts_with(&outer.path) => kept.push(Refused {
372                    path: target.requested,
373                    reason: Refusal::AlreadyCovered(outer.path.clone()),
374                }),
375                _ => targets.push(target),
376            }
377        }
378
379        Plan {
380            root,
381            root_identity,
382            targets,
383            kept,
384            boundary,
385            one_file_system: self.one_file_system,
386        }
387    }
388
389    /// Every check one target has to pass, in the order that costs least.
390    fn judge(
391        &self,
392        target: &Target,
393        root: &Path,
394        boundary: u64,
395        now: SystemTime,
396    ) -> Result<PlanTarget, Refusal> {
397        let path = resolve(&target.path, root)?;
398        let metadata = path
399            .symlink_metadata()
400            .map_err(|err| Refusal::Unreadable(err.to_string()))?;
401
402        if crosses_boundary(self.one_file_system, boundary, &metadata) {
403            return Err(Refusal::OtherFileSystem);
404        }
405        if let Some(floor) = self.older_than {
406            let age = metadata
407                .modified()
408                .ok()
409                .and_then(|modified| now.duration_since(modified).ok());
410            if age.is_none_or(|age| age < floor) {
411                return Err(Refusal::RecentlyUsed { age });
412            }
413        }
414
415        Ok(PlanTarget {
416            requested: target.path.clone(),
417            is_symlink: metadata.is_symlink(),
418            checkout: approve_checkout(&path)?,
419            size: target.size,
420            path,
421        })
422    }
423}
424
425/// Whether `path` is a linked work tree the deleter is allowed to remove whole.
426///
427/// **The one place a checkout is ever approved, and it is re-derived here rather than
428/// inherited.** The walk decided the same thing minutes ago, and a decision of that age is
429/// exactly what #595 was written about: a `stat` taken at a moment, with a confirmation prompt
430/// sitting between it and the `unlink`. A work tree somebody started working in while the dialog
431/// was open has to come back out of the batch, so every question is asked again here.
432///
433/// Nothing changes for a directory that is not a checkout at all, which is nearly all of them:
434/// one `lstat` says so and this answers `Ok(false)`.
435///
436/// The three questions, and what each one rules out:
437///
438/// - **A linked work tree, not a repository or a submodule.** A repository *is* the object
439///   store — its branches, stashes and reflog live in the directory being removed. A submodule
440///   is a checkout the superproject's index points at. Only a linked work tree keeps its history
441///   somewhere else, which is the whole reason any of this is allowed.
442/// - **`HEAD` on a branch.** Verified rather than argued: a commit made on a detached `HEAD` is
443///   listed by `git fsck --unreachable` the moment the directory goes and the administrative
444///   files are pruned. On a branch it is an ordinary ref in the repository and survives.
445/// - **Nothing uncommitted or untracked.** Ignored files do not count, which is what makes this
446///   usable at all — a work tree worth reclaiming is by definition one full of build output.
447///
448/// # What removing one does not do
449///
450/// The repository keeps its `worktrees/<name>` administrative directory, and `git worktree list`
451/// will call it prunable. Deliberate: pruning means **writing into the repository**, which is
452/// very often outside the scan root, and "nothing outside the root is ever touched" is worth
453/// more than the tidiness. `git worktree prune` is one command and it is the reader's to run.
454fn approve_checkout(path: &Path) -> Result<bool, Refusal> {
455    if !git::is_work_tree_root(path) {
456        return Ok(false);
457    }
458    if git::checkout_at(path) != Some(git::Checkout::Linked) {
459        return Err(Refusal::HoldsCheckout);
460    }
461    if !git::head_on_branch(path) {
462        return Err(Refusal::WorkTreeDetached);
463    }
464    if !git::is_clean(path) {
465        return Err(Refusal::WorkTreeInUse);
466    }
467    Ok(true)
468}
469
470/// Somebody watching a removal happen. See [`Deleter::watching`].
471type Watcher = Arc<dyn Fn(&Step) + Send + Sync>;
472
473/// What a removal reports while it is happening.
474///
475/// Three events rather than one, for the same reason [`crate::Found`] has three: the bytes
476/// leave the disk over seconds, the target is removed once, and the pool moves off it once.
477/// A live view needs all three — a row cannot show its size falling toward zero if the only
478/// news it ever gets is that the directory has already gone.
479///
480/// **[`Finished`](Self::Finished) and [`Swept`](Self::Swept) are different questions and that
481/// is why both exist.** "What happened to this directory" is answered only for a target
482/// something happened to, because a row dropped for a target the final report then lists as
483/// untouched is the view and the report disagreeing. "Where has the deleter got to" is
484/// answered for every target, because a batch that fails on all of them has still been worked
485/// through — and a position indicator that reads zero throughout is describing the outcome
486/// rather than the position.
487///
488/// # Every path here is the one the caller asked about
489///
490/// Not the resolved path the `unlinkat` was issued against — [`PlanTarget::requested`], the
491/// spelling that went in. The two differ whenever the target is reached through a symlinked
492/// ancestor or named relatively, which on a real run is the common case and not the exotic
493/// one: a bare `pristine` scans `.`, so every claim it finds is spelled `./…` and every one
494/// of them resolves to something else.
495///
496/// It is stated here because a caller cannot work around getting it wrong. A live view keys
497/// its rows on the paths it handed in, and a report in the other spelling matches none of
498/// them — silently, since a path that finds no row is indistinguishable from a row that was
499/// never drawn. What that looks like is a removal of 150 GiB during which nothing on screen
500/// moves except the one counter that needs no path.
501#[derive(Debug, Clone)]
502pub enum Step {
503    /// Bytes have left the disk and this target is still being swept.
504    Freeing(Freeing),
505    /// The sweep removed something from this target, in whole or in part.
506    ///
507    /// Emitted only when [`Removal::removed`] will carry this target too, which is the
508    /// condition a row disappearing is allowed to rest on.
509    Finished(Removed),
510    /// The pool has moved off this target, whatever it managed — including nothing.
511    ///
512    /// One per target in the plan, always, and always after any [`Finished`](Self::Finished)
513    /// for the same path. It is deliberately *not* a claim that anything was deleted: a target
514    /// that failed before unlinking a single entry, or that had already vanished, is one the
515    /// deleter is no longer working on, and that is the whole of what this says.
516    Swept(PathBuf),
517}
518
519/// How far into one target a sweep has got.
520#[derive(Debug, Clone)]
521pub struct Freeing {
522    /// The target.
523    pub path: PathBuf,
524    /// Allocated bytes given back **so far**, counting a hard-linked file once — the same
525    /// accounting [`Removed::bytes`] uses, because they are the same running total read at
526    /// different moments.
527    ///
528    /// **Cumulative, never a delta.** Each report supersedes the last for this path, so a
529    /// consumer that coalesces two of them loses nothing, and one that keeps the latest per
530    /// target cannot double-count however the pool interleaves them. It is also what makes
531    /// reconciling against the final [`Removal`] exact rather than approximate: the last word
532    /// on a target is a total, not a correction.
533    pub bytes: u64,
534    /// Entries unlinked so far.
535    pub entries: u64,
536}
537
538/// How many entries a sweep unlinks between progress reports.
539///
540/// A report per entry would be 24,001 channel messages for one `node_modules` and a `PathBuf`
541/// clone for each. This is the granularity a 30fps view can actually use: a target big enough
542/// to watch emits hundreds of these, and one small enough not to is over before it matters.
543const REPORT_EVERY: u64 = 64;
544
545/// …or this many bytes, whichever comes first.
546///
547/// Entries alone would leave a target that is sixteen very large files reporting nothing until
548/// it finished, which is the exact failure this event exists to remove.
549const REPORT_BYTES: u64 = 8 * 1024 * 1024;
550
551/// Removes what a [`Plan`] says to remove, and nothing else.
552#[derive(Clone, Default)]
553pub struct Deleter {
554    threads: Option<usize>,
555    watching: Option<Watcher>,
556}
557
558impl fmt::Debug for Deleter {
559    /// Hand-written because a closure has no `Debug`, and the only interesting thing about
560    /// one here is whether anybody is listening.
561    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
562        f.debug_struct("Deleter")
563            .field("threads", &self.threads)
564            .field("watching", &self.watching.is_some())
565            .finish()
566    }
567}
568
569impl Deleter {
570    /// A deleter with the default pool.
571    #[must_use]
572    pub fn new() -> Self {
573        Self::default()
574    }
575
576    /// How many threads to remove with. Defaults to the machine's parallelism times
577    /// [`OVERSUBSCRIPTION`], bounded by [`MAX_THREADS`] and by the number of targets.
578    #[must_use]
579    pub fn threads(mut self, threads: usize) -> Self {
580        self.threads = Some(threads);
581        self
582    }
583
584    /// Reports the removal as it happens, rather than only in the [`Removal`] at the end.
585    ///
586    /// The batch CLI has no use for this — it prints one report when the removal is over —
587    /// but a live view does, twice over. [`Step::Finished`] is what drops a row: a row for a
588    /// directory that is already gone is one a reader can still put a cursor on, and one they
589    /// can watch a second delete keystroke land on. [`Step::Freeing`] is what lets that row
590    /// **empty** first, on the bytes actually leaving the disk rather than on a timer — the
591    /// difference between showing what is happening and animating over the fact that it
592    /// already happened.
593    ///
594    /// Called from the pool, so `sink` is `Send + Sync` and may be called from several
595    /// threads at once and in any order. A `Finished` sees exactly what [`Removal::removed`]
596    /// will contain — same condition, same values — because both come from
597    /// [`Sweep::reported`], and a `Freeing` is the same running total read earlier.
598    #[must_use]
599    pub fn watching(mut self, sink: impl Fn(&Step) + Send + Sync + 'static) -> Self {
600        self.watching = Some(Arc::new(sink));
601        self
602    }
603
604    /// Executes the plan.
605    ///
606    /// One target's failure costs that target and nothing else: everything is collected and
607    /// reported, and the caller turns a non-empty [`Removal::failures`] into a non-zero exit.
608    #[must_use]
609    pub fn remove(&self, plan: &Plan) -> Removal {
610        let mut removal = Removal {
611            kept: plan.kept.clone(),
612            ..Removal::default()
613        };
614        if plan.targets.is_empty() {
615            return removal;
616        }
617
618        // The one path resolved by name in the whole removal, and the anchor for every
619        // descriptor below it, so it is checked hardest — see [`open_root`]. Opened once per
620        // batch rather than once per target, which also means the root cannot be swapped
621        // between two targets.
622        let root = match open_root(plan) {
623            Ok(root) => root,
624            Err(err) => {
625                removal.failures.push(Failure {
626                    path: plan.root.clone(),
627                    message: err.to_string(),
628                });
629                return removal;
630            }
631        };
632
633        let threads = self
634            .threads
635            .unwrap_or_else(default_threads)
636            .clamp(1, plan.targets.len());
637        let cursor = AtomicUsize::new(0);
638        let collected = Mutex::new(Vec::new());
639
640        std::thread::scope(|scope| {
641            for _ in 0..threads {
642                scope.spawn(|| {
643                    let mut mine = Vec::new();
644                    loop {
645                        let at = cursor.fetch_add(1, Ordering::Relaxed);
646                        let Some(target) = plan.targets.get(at) else {
647                            break;
648                        };
649                        let sweep = Sweep::new(plan, &root, self.watching.as_ref()).run(target);
650                        if let Some(watching) = self.watching.as_ref() {
651                            if let Some(removed) = sweep.reported() {
652                                watching(&Step::Finished(removed));
653                            }
654                            // Unconditional, and after the report above: this one says the
655                            // pool has moved on rather than that anything went, so a target
656                            // that failed before unlinking an entry counts here and nowhere
657                            // else. Without it a batch that fails on every target reports no
658                            // progress at all right up to the moment it ends.
659                            watching(&Step::Swept(target.requested.clone()));
660                        }
661                        mine.push(sweep);
662                    }
663                    lock(&collected).append(&mut mine);
664                });
665            }
666        });
667
668        for mut sweep in collected
669            .into_inner()
670            .unwrap_or_else(PoisonError::into_inner)
671        {
672            removal.removed.extend(sweep.reported());
673            removal.kept.append(&mut sweep.kept);
674            removal.failures.append(&mut sweep.failures);
675        }
676
677        // The pool finishes in whatever order the filesystem allows, and a report a person
678        // reads twice should not reorder itself between runs.
679        removal.removed.sort_by(|a, b| a.path.cmp(&b.path));
680        removal.kept.sort_by(|a, b| a.path.cmp(&b.path));
681        removal.failures.sort_by(|a, b| a.path.cmp(&b.path));
682        removal
683    }
684}
685
686/// One target that was removed, in whole or in part.
687#[derive(Debug, Clone)]
688pub struct Removed {
689    /// The target.
690    pub path: PathBuf,
691    /// Allocated bytes given back, counting a hard-linked file once.
692    pub bytes: u64,
693    /// Files, directories and links unlinked.
694    pub entries: u64,
695    /// Whether the target itself is gone. False when something inside it was refused or
696    /// failed, which leaves it and everything above the refusal standing.
697    pub complete: bool,
698}
699
700/// Something that went wrong. Collected rather than fatal.
701#[derive(Debug, Clone)]
702pub struct Failure {
703    /// The path involved.
704    pub path: PathBuf,
705    /// What the filesystem said.
706    pub message: String,
707}
708
709/// What a removal did.
710#[derive(Debug, Clone, Default)]
711pub struct Removal {
712    /// Targets something was removed from.
713    pub removed: Vec<Removed>,
714    /// Directories left in place: the plan's refusals, plus every subtree a sweep declined
715    /// to enter.
716    pub kept: Vec<Refused>,
717    /// Everything that failed.
718    pub failures: Vec<Failure>,
719}
720
721impl Removal {
722    /// Allocated bytes given back.
723    #[must_use]
724    pub fn bytes_freed(&self) -> u64 {
725        self.removed.iter().map(|removed| removed.bytes).sum()
726    }
727
728    /// How many files, directories and links were unlinked.
729    #[must_use]
730    pub fn entries_removed(&self) -> u64 {
731        self.removed.iter().map(|removed| removed.entries).sum()
732    }
733
734    /// Whether everything the plan asked for happened. A refusal is not a failure — it is
735    /// the safety model working — so this asks only about [`Removal::failures`].
736    #[must_use]
737    pub fn is_clean(&self) -> bool {
738        self.failures.is_empty()
739    }
740}
741
742/// One target's removal. Per-target rather than shared, so the pool contends for the
743/// filesystem and not for a mutex.
744///
745/// Every method below takes the *descriptor* of the directory holding the entry it acts on,
746/// plus the entry's name. The `path` alongside them is for reporting only — it is what the
747/// user reads in a refusal, and it is never resolved.
748struct Sweep<'a> {
749    plan: &'a Plan,
750    /// The scan root, opened once by [`Deleter::remove`] and shared across the pool. Every
751    /// descriptor this sweep holds is descended from it.
752    root: &'a fs::File,
753    path: PathBuf,
754    bytes: u64,
755    entries: u64,
756    complete: bool,
757    /// The `(device, inode)` of every multiply-linked file already counted, so a hard-linked
758    /// artefact is worth its blocks once — the same accounting [`crate::size`] uses, so a
759    /// plan's estimate and the bytes actually freed are measured the same way.
760    linked: HashSet<(u64, u64)>,
761    kept: Vec<Refused>,
762    failures: Vec<Failure>,
763    /// Where progress goes while this sweep runs, and the totals already sent, so a report is
764    /// a step forward rather than a repeat.
765    watching: Option<&'a Watcher>,
766    told_bytes: u64,
767    told_entries: u64,
768    /// The one directory in this sweep whose own `.git` does not stop it: the root of a target
769    /// the plan approved as a linked work tree. `None` for every other target, which is nearly
770    /// all of them.
771    ///
772    /// A path rather than a flag, because the licence is granted to one *directory* and not to
773    /// the sweep. A checkout nested inside the work tree — a submodule, somebody's clone parked
774    /// in a scratch folder — is refused exactly as it always was.
775    approved: Option<PathBuf>,
776}
777
778impl<'a> Sweep<'a> {
779    fn new(plan: &'a Plan, root: &'a fs::File, watching: Option<&'a Watcher>) -> Self {
780        Self {
781            plan,
782            root,
783            path: PathBuf::new(),
784            bytes: 0,
785            entries: 0,
786            complete: false,
787            linked: HashSet::new(),
788            kept: Vec::new(),
789            failures: Vec::new(),
790            watching,
791            told_bytes: 0,
792            told_entries: 0,
793            approved: None,
794        }
795    }
796
797    fn run(mut self, target: &PlanTarget) -> Self {
798        // Descend by the resolved path and **report by the requested one**. They are two names
799        // for one directory, and which one a `Step` carries is not cosmetic: a caller keys its
800        // own state on the paths it handed in, so a report in the other spelling is a lookup
801        // that misses. Nothing errors when it does — the front end simply shows a removal that
802        // never appears to happen — which is why this is stated here rather than left to each
803        // reporting site to remember.
804        self.path.clone_from(&target.requested);
805        // Granted to this target's root only, and named by the same spelling everything else in
806        // this sweep is reported by so the comparison below cannot go wrong on a symlinked
807        // ancestor.
808        self.approved = target.checkout.then(|| target.requested.clone());
809        let Some((parent, name)) = self.parent_of(target) else {
810            return self;
811        };
812        self.complete = self.entry(&parent, &name, &target.requested);
813        self
814    }
815
816    /// What this sweep did, or `None` when nothing happened to the target.
817    ///
818    /// A record only for a target something actually happened to, so [`Removal::removed`]
819    /// means what it says rather than "was considered". One function rather than the same
820    /// condition written twice, because the other reader is [`Deleter::watching`] and a live
821    /// view that dropped rows the final report then listed as untouched would be worse than
822    /// having no progress at all.
823    fn reported(&self) -> Option<Removed> {
824        (self.entries > 0 || self.complete).then(|| Removed {
825            path: self.path.clone(),
826            bytes: self.bytes,
827            entries: self.entries,
828            complete: self.complete,
829        })
830    }
831
832    /// Opens the directory that holds the target, by walking down from the root's descriptor
833    /// one component at a time.
834    ///
835    /// Each step is `openat(fd, name, O_DIRECTORY | O_NOFOLLOW)` against the descriptor the
836    /// previous step returned, so no part of the path is ever re-resolved from a name and a
837    /// component swapped for a symlink is an `ELOOP` rather than a redirect. The final
838    /// component is *not* opened: a claim may legitimately be a symlink — Bazel's `bazel-*` —
839    /// and it has to be unlinked as a link rather than followed.
840    fn parent_of(&mut self, target: &PlanTarget) -> Option<(fs::File, OsString)> {
841        // Unreachable for a planned target, which the planner proved is under the root.
842        // Refusing beats descending from a root this path has nothing to do with.
843        let Ok(relative) = target.path.strip_prefix(&self.plan.root) else {
844            self.failures.push(Failure {
845                path: target.requested.clone(),
846                message: format!("is not under {}", self.plan.root.display()),
847            });
848            return None;
849        };
850
851        let mut names: Vec<&OsStr> = relative.components().map(Component::as_os_str).collect();
852        let name = names.pop()?;
853        // The caller's spelling of the root, got by taking back off the components this loop is
854        // about to walk. The descent uses descriptors and needs no path at all; this exists so
855        // that an ancestor that fails to open is named the way the caller named it, the same as
856        // every other path this sweep reports.
857        let mut walked = target.requested.clone();
858        for _ in 0..relative.components().count() {
859            walked.pop();
860        }
861        // `dup`, so the loop can own each handle in turn without consuming the shared root.
862        let mut dir = match self.root.try_clone() {
863            Ok(dir) => dir,
864            Err(err) => {
865                self.failed(&walked, &err);
866                return None;
867            }
868        };
869        for component in names {
870            walked.push(component);
871            dir = match open_dir_nofollow(&dir, Path::new(component)) {
872                Ok(next) => next,
873                Err(err) => {
874                    self.failed(&walked, &err);
875                    return None;
876                }
877            };
878        }
879        Some((dir, name.to_owned()))
880    }
881
882    /// One filesystem entry, whatever kind it is, named relative to `parent`'s descriptor.
883    ///
884    /// The metadata is always `fstatat` with `AT_SYMLINK_NOFOLLOW`, so a symlink is a symlink
885    /// here and never the thing it points at.
886    fn entry(&mut self, parent: &fs::File, name: &OsStr, path: &Path) -> bool {
887        let metadata = match stat(parent, Path::new(name), FollowSymlinks::No) {
888            Ok(metadata) => metadata,
889            Err(err) => {
890                self.failed(path, &err);
891                return false;
892            }
893        };
894        if crosses_boundary(self.plan.one_file_system, self.plan.boundary, &metadata) {
895            self.kept.push(Refused {
896                path: path.to_path_buf(),
897                reason: Refusal::OtherFileSystem,
898            });
899            return false;
900        }
901        if metadata.is_dir() {
902            self.directory(parent, name, path, &metadata)
903        } else {
904            self.unlink(parent, name, path, &metadata)
905        }
906    }
907
908    fn directory(
909        &mut self,
910        parent: &fs::File,
911        name: &OsStr,
912        path: &Path,
913        metadata: &Metadata,
914    ) -> bool {
915        // The same `O_NOFOLLOW` open as the descent. If the directory just seen by `fstatat`
916        // has become a symlink in the meantime, this fails rather than following it — which
917        // is the whole reason the traversal is written against descriptors.
918        let dir = match open_dir_nofollow(parent, Path::new(name)) {
919            Ok(dir) => dir,
920            Err(err) => {
921                self.failed(path, &err);
922                return false;
923            }
924        };
925        let listing = match read_base_dir(&dir) {
926            Ok(listing) => listing,
927            Err(err) => {
928                // Not an empty directory. A cleaner that treats "I could not look" as "there
929                // was nothing there" removes the directory and everything it never saw.
930                self.failed(path, &err);
931                return false;
932            }
933        };
934
935        let mut children = Vec::new();
936        let mut complete = true;
937        for child in listing {
938            match child {
939                Ok(child) => children.push(child.file_name()),
940                // `readdir` gave up part-way through a directory it had already opened, so
941                // the listing is short by an unknown amount. Anything below is unaccounted
942                // for, which is exactly the state in which nothing may be removed.
943                Err(err) => {
944                    self.failed(path, &err);
945                    complete = false;
946                }
947            }
948        }
949
950        // Before anything in this directory is touched: a checkout under here may hold work
951        // that exists nowhere else, and half-removing it is worse than not starting.
952        //
953        // The single exception is the root of a target the plan proved is a linked work tree
954        // holding nothing uncommitted — see [`Planner::approve_checkout`]. Compared by path and
955        // not by a flag on the sweep, so the licence cannot travel downward: a submodule or a
956        // stray clone *inside* the work tree is refused here exactly as it was before, which is
957        // the case that makes this an exception rather than a hole.
958        let approved = self.approved.as_deref() == Some(path);
959        if !approved && children.iter().any(|child| child == ".git") {
960            self.kept.push(Refused {
961                path: path.to_path_buf(),
962                reason: Refusal::HoldsCheckout,
963            });
964            return false;
965        }
966
967        for child in children {
968            complete &= self.entry(&dir, &child, &path.join(&child));
969        }
970
971        // Only once every child is known to be gone. An `rmdir` attempted over a refusal
972        // would fail anyway, but reporting that as a failure would call the safety model a
973        // fault; and a directory left short by a `readdir` error must not be retried blind.
974        if !complete {
975            return false;
976        }
977        // `unlinkat(parent_fd, name, AT_REMOVEDIR)`, so what is removed is the entry we just
978        // walked and not whatever the name resolves to now.
979        match remove_dir(parent, Path::new(name)) {
980            Ok(()) => {
981                self.count(metadata);
982                true
983            }
984            Err(err) => {
985                self.failed(path, &err);
986                false
987            }
988        }
989    }
990
991    /// Unlinks a file or a symlink. A symlink is removed as a link: what it points at is
992    /// somewhere else, is very likely outside the root, and is not ours.
993    fn unlink(
994        &mut self,
995        parent: &fs::File,
996        name: &OsStr,
997        path: &Path,
998        metadata: &Metadata,
999    ) -> bool {
1000        match remove_file(parent, Path::new(name)) {
1001            Ok(()) => {
1002                self.count(metadata);
1003                true
1004            }
1005            Err(err) => {
1006                self.failed(path, &err);
1007                false
1008            }
1009        }
1010    }
1011
1012    fn count(&mut self, metadata: &Metadata) {
1013        self.entries += 1;
1014        if let Some(identity) = multiply_linked(metadata) {
1015            if !self.linked.insert(identity) {
1016                self.tell();
1017                return;
1018            }
1019        }
1020        self.bytes += allocated(metadata);
1021        self.tell();
1022    }
1023
1024    /// Says how far this sweep has got, when it has got far enough to be worth saying.
1025    ///
1026    /// The one place a removal speaks while it is still running, and it is deliberately here
1027    /// — inside the single function that accounts for a freed entry — rather than at the
1028    /// traversal's branches. A report emitted anywhere else would be a second opinion about
1029    /// how much has gone, and the whole value of the event is that it is the *same* running
1030    /// total the final [`Removed`] carries, read earlier.
1031    fn tell(&mut self) {
1032        let Some(watching) = self.watching else {
1033            return;
1034        };
1035        if self.entries - self.told_entries < REPORT_EVERY
1036            && self.bytes - self.told_bytes < REPORT_BYTES
1037        {
1038            return;
1039        }
1040        self.told_entries = self.entries;
1041        self.told_bytes = self.bytes;
1042        watching(&Step::Freeing(Freeing {
1043            path: self.path.clone(),
1044            bytes: self.bytes,
1045            entries: self.entries,
1046        }));
1047    }
1048
1049    fn failed(&mut self, path: &Path, err: &impl fmt::Display) {
1050        self.failures.push(Failure {
1051            path: path.to_path_buf(),
1052            message: err.to_string(),
1053        });
1054    }
1055}
1056
1057/// Asks a yes/no question whose answer defaults to **no**.
1058///
1059/// Only `y` or `yes` mean yes. Everything else does not, and that includes end of input: a
1060/// pipe with nothing in it is not consent, so a script that means to delete has to say so
1061/// with a flag rather than by being silent.
1062///
1063/// # Errors
1064///
1065/// If the prompt cannot be written or the answer cannot be read.
1066pub fn confirm(
1067    question: &str,
1068    input: &mut impl BufRead,
1069    output: &mut impl Write,
1070) -> io::Result<bool> {
1071    write!(output, "{question} [y/N] ")?;
1072    output.flush()?;
1073    let mut answer = String::new();
1074    if input.read_line(&mut answer)? == 0 {
1075        return Ok(false);
1076    }
1077    Ok(matches!(
1078        answer.trim().to_ascii_lowercase().as_str(),
1079        "y" | "yes"
1080    ))
1081}
1082
1083/// The scan root as the planner proved it: where it really is, which device everything under
1084/// it has to sit on, and — the part a path cannot carry — which directory it actually is.
1085struct ValidatedRoot {
1086    path: PathBuf,
1087    device: u64,
1088    identity: Option<(u64, u64)>,
1089}
1090
1091/// The canonical root, the device it lives on, and the inode it is.
1092fn canonical_root(root: &Path) -> io::Result<ValidatedRoot> {
1093    let canonical = fs::canonicalize(root)?;
1094    let metadata = canonical.symlink_metadata()?;
1095    Ok(ValidatedRoot {
1096        device: device(&metadata),
1097        identity: identity(&metadata),
1098        path: canonical,
1099    })
1100}
1101
1102/// Opens the scan root, and proves the descriptor is the directory the planner validated.
1103///
1104/// This is the one path still resolved by name, and therefore the one place a name decides
1105/// which directory a whole batch acts on: every other descriptor descends from this one, so
1106/// getting it wrong misdirects everything rather than one target. Two guards, because they
1107/// catch different attacks.
1108///
1109/// The final component is opened with `O_NOFOLLOW` from its own parent, so a root replaced by
1110/// a symlink fails here rather than quietly anchoring the sweep somewhere else.
1111///
1112/// Then the descriptor's `(device, inode)` is compared with the pair recorded when the plan was
1113/// built. That is the load-bearing one: a root renamed away and replaced by an ordinary
1114/// directory offers no symlink to refuse, and if the replacement is on the same filesystem the
1115/// boundary check passes too. Nothing about the *name* tells the two apart — only the inode.
1116fn open_root(plan: &Plan) -> io::Result<fs::File> {
1117    let opened = match (plan.root.parent(), plan.root.file_name()) {
1118        (Some(parent), Some(name)) => {
1119            let parent = open_ambient_dir(parent, ambient_authority())?;
1120            open_dir_nofollow(&parent, Path::new(name))?
1121        }
1122        // `/` has no parent to be opened from, and cannot itself be a symlink.
1123        _ => open_ambient_dir(&plan.root, ambient_authority())?,
1124    };
1125    // `fstat` on the descriptor rather than a stat on the path, so what is checked is the
1126    // directory now held open and not whatever the name resolves to a moment later.
1127    if identity(&opened.metadata()?) != plan.root_identity {
1128        return Err(io::Error::other(
1129            "the scan root is no longer the directory the plan was built against",
1130        ));
1131    }
1132    Ok(opened)
1133}
1134
1135/// Resolves `path` and proves it is under `root`, without resolving the final component.
1136///
1137/// The parent is canonicalised, so `..` and every symlinked ancestor are gone before the
1138/// comparison. The leaf is joined back on unresolved, because a symlinked target must be
1139/// unlinked as a link and canonicalising it would name what it points at instead.
1140fn resolve(path: &Path, root: &Path) -> Result<PathBuf, Refusal> {
1141    let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
1142        // A path with no parent or no final component is `/` or `..`. Neither is a target.
1143        return Err(Refusal::OutsideRoot);
1144    };
1145    // A parent that will not resolve is refused either way; saying which kind of refusal it
1146    // was is the difference between "you pointed outside the tree" and "it is already gone".
1147    let parent = fs::canonicalize(parent).map_err(|err| Refusal::Unreadable(err.to_string()))?;
1148    let resolved = parent.join(name);
1149    // `starts_with` compares whole components, so `/scan-backup` does not start with
1150    // `/scan`. The inequality is what keeps the root itself off every plan.
1151    if resolved == root || !resolved.starts_with(root) {
1152        return Err(Refusal::OutsideRoot);
1153    }
1154    Ok(resolved)
1155}
1156
1157/// Whether something with this metadata sits off the filesystem the plan is confined to.
1158///
1159/// One expression, called from both the plan and the sweep, so "a mount is not crossed" is
1160/// one decision rather than two that can drift apart. A mount is where a scan of one project
1161/// reaches a network share, a Time Machine volume or another user's disk.
1162///
1163/// Generic because the plan stats by path and the sweep stats by descriptor, which produce
1164/// two different metadata types for the same `st_dev`.
1165fn crosses_boundary(one_file_system: bool, boundary: u64, metadata: &impl Stat) -> bool {
1166    one_file_system && device(metadata) != boundary
1167}
1168
1169fn default_threads() -> usize {
1170    let cores = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
1171    cores.saturating_mul(OVERSUBSCRIPTION).min(MAX_THREADS)
1172}
1173
1174/// A duration in the units a person reads, rounded down to the coarsest that fits.
1175fn humanise(duration: Duration) -> String {
1176    const HOUR: u64 = 60 * 60;
1177    const DAY: u64 = 24 * HOUR;
1178    let seconds = duration.as_secs();
1179    let (value, unit) = match seconds {
1180        0..HOUR => (seconds / 60, "minute"),
1181        HOUR..DAY => (seconds / HOUR, "hour"),
1182        _ => (seconds / DAY, "day"),
1183    };
1184    format!("{value} {unit}{}", if value == 1 { "" } else { "s" })
1185}
1186
1187fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
1188    mutex.lock().unwrap_or_else(PoisonError::into_inner)
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::{confirm, humanise};
1194    use std::time::Duration;
1195
1196    /// Answers `input` and returns both the decision and what the user was shown.
1197    fn ask(input: &str) -> (bool, String) {
1198        let mut output = Vec::new();
1199        let answered = confirm("Remove 12 directories?", &mut input.as_bytes(), &mut output)
1200            .expect("a byte slice cannot fail to be read");
1201        (answered, String::from_utf8(output).expect("ASCII prompt"))
1202    }
1203
1204    #[test]
1205    fn the_confirmation_defaults_to_no() {
1206        // Bare enter, and the prompt has to say which way that goes.
1207        assert!(!ask("\n").0);
1208        assert!(ask("\n").1.ends_with("[y/N] "));
1209    }
1210
1211    #[test]
1212    fn end_of_input_is_not_consent() {
1213        // A script piping nothing at an irreversible prompt means it did not expect one.
1214        assert!(!ask("").0);
1215    }
1216
1217    #[test]
1218    fn only_yes_means_yes() {
1219        for yes in ["y", "Y", "yes", "YES", " yes \n"] {
1220            assert!(ask(yes).0, "`{yes}` was read as no");
1221        }
1222        for no in ["n", "no", "\n", "  ", "sure", "yep", "yes please", "1"] {
1223            assert!(!ask(no).0, "`{no}` was read as yes");
1224        }
1225    }
1226
1227    #[test]
1228    fn an_age_is_reported_in_the_coarsest_unit_that_fits() {
1229        assert_eq!(humanise(Duration::from_secs(90)), "1 minute");
1230        assert_eq!(humanise(Duration::from_secs(2 * 60 * 60)), "2 hours");
1231        assert_eq!(humanise(Duration::from_secs(36 * 60 * 60)), "1 day");
1232        assert_eq!(humanise(Duration::from_secs(90 * 24 * 60 * 60)), "90 days");
1233    }
1234}