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}