Skip to main content

cabin_build/
clean.rs

1//! Plan and execute the deletion list for `cabin clean`.
2//!
3//! The on-disk layout this module operates against must stay in
4//! sync with [`crate::planner`]:
5//!
6//! ```text
7//! <build_dir>/<profile>/build.ninja
8//! <build_dir>/<profile>/compile_commands.json
9//! <build_dir>/<profile>/packages/<pkg>/...
10//! ```
11//!
12//! Safety contract:
13//!
14//! - every safety check runs before the deletion plan is even
15//!   computed, so an unsafe build directory never reaches the
16//!   filesystem step;
17//! - the plan only contains paths inside the resolved
18//!   `build_dir`;
19//! - paths are sorted so dry-run output is deterministic;
20//! - `remove_dir_all` does not follow symlinks for entries
21//!   inside the tree - it removes the link, not the target - and
22//!   the build directory itself is rejected up-front when it is a
23//!   symlink, so this module never traverses through a symlink.
24
25use std::path::{Path, PathBuf};
26
27use thiserror::Error;
28
29use cabin_core::{PackageName, ProfileName};
30
31/// What `cabin clean` should remove.
32#[derive(Debug, Clone)]
33pub enum CleanScope {
34    /// Remove the entire build directory.  Used by the no-flag
35    /// invocation `cabin clean`.
36    Whole,
37    /// Remove a single profile sub-tree
38    /// (`<build_dir>/<profile>/`).
39    Profile(ProfileName),
40    /// Remove the per-package output for one or more packages
41    /// across one or more profiles.
42    Packages {
43        profiles: Vec<ProfileName>,
44        packages: Vec<PackageName>,
45    },
46}
47
48/// Inputs to [`plan_clean`].
49#[derive(Debug, Clone)]
50pub struct CleanRequest<'a> {
51    /// Resolved absolute build directory.
52    pub build_dir: &'a Path,
53    /// Workspace root directory (manifest's parent).  Used by
54    /// the safety check that refuses to clean the workspace
55    /// itself.
56    pub workspace_root: &'a Path,
57    /// Manifest directories of every loaded package - single
58    /// package or every workspace member.  Used to refuse a
59    /// build directory that points at a package source tree.
60    pub package_roots: &'a [PathBuf],
61    /// Source files and source-owned directories that must not
62    /// be contained by the build directory.  This lets in-tree
63    /// build dirs like `<pkg>/build` keep working while rejecting
64    /// dangerous settings such as `--build-dir src`.
65    pub protected_source_paths: &'a [PathBuf],
66    /// What to clean.
67    pub scope: CleanScope,
68}
69
70/// Deterministic deletion plan: a sorted, deduplicated list of
71/// existing paths inside `build_dir` that the executor will
72/// remove.
73#[derive(Debug, Clone)]
74pub struct CleanPlan {
75    /// Resolved build directory the plan operates against.
76    pub build_dir: PathBuf,
77    /// Sorted, existing paths to remove.  Each entry lives
78    /// inside `build_dir` (see [`plan_clean`]'s contract).
79    pub removals: Vec<PathBuf>,
80}
81
82/// Result of an [`execute_clean`] call.
83#[derive(Debug, Clone, Default)]
84pub struct CleanReport {
85    /// Paths the executor removed.  May be a strict
86    /// subset of [`CleanPlan::removals`] if a concurrent process
87    /// removed an entry between planning and execution.
88    pub removed: Vec<PathBuf>,
89}
90
91/// Errors produced while validating a clean request, planning
92/// the deletion, or removing files.
93#[derive(Debug, Error)]
94pub enum CleanError {
95    #[error("build directory path is empty")]
96    EmptyBuildDir,
97
98    #[error("refusing to clean root path {}", .0.display())]
99    RootBuildDir(PathBuf),
100
101    #[error("refusing to clean home directory {}", .0.display())]
102    HomeBuildDir(PathBuf),
103
104    #[error("refusing to clean workspace root {}; the build directory must point at a separate output directory", .0.display())]
105    WorkspaceRootBuildDir(PathBuf),
106
107    #[error("refusing to clean package source directory {}; the build directory must point at a separate output directory", .0.display())]
108    PackageRootBuildDir(PathBuf),
109
110    #[error("refusing to clean build directory {} because it overlaps source file or directory {}", build_dir.display(), source_path.display())]
111    SourcePathBuildDir {
112        build_dir: PathBuf,
113        source_path: PathBuf,
114    },
115
116    #[error("refusing to clean symlink {}; replace it with a real directory before re-running `cabin clean`", .0.display())]
117    SymlinkBuildDir(PathBuf),
118
119    #[error("computed deletion path {} is not inside build directory {}", path.display(), build_dir.display())]
120    PathEscapesBuildDir { path: PathBuf, build_dir: PathBuf },
121
122    #[error("failed to remove {}: {source}", path.display())]
123    Io {
124        path: PathBuf,
125        #[source]
126        source: std::io::Error,
127    },
128}
129
130/// Validate the request's safety guards and return a sorted,
131/// deduplicated deletion plan.
132///
133/// Every path in the returned plan is an existing entry inside
134/// `req.build_dir`.  The function never touches the filesystem
135/// beyond `symlink_metadata` (safety check) and `Path::exists`
136/// (filtering candidates to those that live on disk).
137///
138/// # Errors
139/// Returns a [`CleanError`] safety-guard variant when `build_dir`
140/// is unsafe to delete: [`CleanError::EmptyBuildDir`],
141/// [`CleanError::RootBuildDir`], [`CleanError::HomeBuildDir`],
142/// [`CleanError::WorkspaceRootBuildDir`],
143/// [`CleanError::PackageRootBuildDir`],
144/// [`CleanError::SourcePathBuildDir`], or
145/// [`CleanError::SymlinkBuildDir`].  Also returns
146/// [`CleanError::PathEscapesBuildDir`] if a computed deletion
147/// candidate would fall outside `build_dir`.
148pub fn plan_clean(req: &CleanRequest<'_>) -> Result<CleanPlan, CleanError> {
149    validate_safe_build_dir(
150        req.build_dir,
151        req.workspace_root,
152        req.package_roots,
153        req.protected_source_paths,
154    )?;
155
156    let candidates = match &req.scope {
157        CleanScope::Whole => vec![req.build_dir.to_path_buf()],
158        CleanScope::Profile(profile) => vec![req.build_dir.join(profile.as_str())],
159        CleanScope::Packages { profiles, packages } => {
160            let mut out = Vec::with_capacity(profiles.len().saturating_mul(packages.len()));
161            for profile in profiles {
162                let profile_root = req.build_dir.join(profile.as_str());
163                for pkg in packages {
164                    out.push(profile_root.join("packages").join(pkg.as_str()));
165                }
166            }
167            out
168        }
169    };
170
171    for candidate in &candidates {
172        if !is_within(candidate, req.build_dir) {
173            return Err(CleanError::PathEscapesBuildDir {
174                path: candidate.clone(),
175                build_dir: req.build_dir.to_path_buf(),
176            });
177        }
178    }
179
180    let mut existing: Vec<PathBuf> = candidates.into_iter().filter(|p| p.exists()).collect();
181    existing.sort();
182    existing.dedup();
183
184    Ok(CleanPlan {
185        build_dir: req.build_dir.to_path_buf(),
186        removals: existing,
187    })
188}
189
190/// Remove every path in `plan.removals`.
191///
192/// Paths that disappeared between planning and execution
193/// (concurrent removal by another process) are silently skipped:
194/// the goal state - the path no longer existing - is already
195/// satisfied.  Symbolic links inside the build tree are removed
196/// as links rather than recursively followed.
197///
198/// # Errors
199/// Returns [`CleanError::Io`] if querying a path's metadata
200/// (`symlink_metadata`) fails for any reason other than the path
201/// already being gone, or if removing a directory or file fails.
202pub fn execute_clean(plan: &CleanPlan) -> Result<CleanReport, CleanError> {
203    let mut removed = Vec::with_capacity(plan.removals.len());
204    for path in &plan.removals {
205        let metadata = match std::fs::symlink_metadata(path) {
206            Ok(m) => m,
207            Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
208            Err(source) => {
209                return Err(CleanError::Io {
210                    path: path.clone(),
211                    source,
212                });
213            }
214        };
215        let file_type = metadata.file_type();
216        if file_type.is_dir() {
217            std::fs::remove_dir_all(path).map_err(|source| CleanError::Io {
218                path: path.clone(),
219                source,
220            })?;
221        } else {
222            std::fs::remove_file(path).map_err(|source| CleanError::Io {
223                path: path.clone(),
224                source,
225            })?;
226        }
227        removed.push(path.clone());
228    }
229    Ok(CleanReport { removed })
230}
231
232fn validate_safe_build_dir(
233    build_dir: &Path,
234    workspace_root: &Path,
235    package_roots: &[PathBuf],
236    protected_source_paths: &[PathBuf],
237) -> Result<(), CleanError> {
238    if build_dir.as_os_str().is_empty() {
239        return Err(CleanError::EmptyBuildDir);
240    }
241    if build_dir.parent().is_none() {
242        return Err(CleanError::RootBuildDir(build_dir.to_path_buf()));
243    }
244    if let Some(home) = home_dir()
245        && same_path(build_dir, &home)
246    {
247        return Err(CleanError::HomeBuildDir(build_dir.to_path_buf()));
248    }
249    if same_path(build_dir, workspace_root) {
250        return Err(CleanError::WorkspaceRootBuildDir(build_dir.to_path_buf()));
251    }
252    for root in package_roots {
253        if same_path(build_dir, root) {
254            return Err(CleanError::PackageRootBuildDir(build_dir.to_path_buf()));
255        }
256    }
257    for source_path in protected_source_paths {
258        if overlaps_source_path(build_dir, source_path) {
259            return Err(CleanError::SourcePathBuildDir {
260                build_dir: build_dir.to_path_buf(),
261                source_path: source_path.clone(),
262            });
263        }
264    }
265    if let Ok(meta) = std::fs::symlink_metadata(build_dir)
266        && meta.file_type().is_symlink()
267    {
268        return Err(CleanError::SymlinkBuildDir(build_dir.to_path_buf()));
269    }
270    Ok(())
271}
272
273/// Equality test for paths that tolerates symlink-only spelling
274/// differences (e.g. macOS exposes `/tmp/foo` as `/private/tmp/foo`).
275/// Falls back to literal equality when canonicalization fails so a
276/// non-existent build dir still matches a non-existent workspace
277/// root entered by the same path string.
278fn same_path(a: &Path, b: &Path) -> bool {
279    if a == b {
280        return true;
281    }
282    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
283        (Ok(ca), Ok(cb)) => ca == cb,
284        _ => false,
285    }
286}
287
288/// Whether `candidate` is `base` itself or lives underneath
289/// `base`.  Performed by component-wise matching so a sibling
290/// directory whose name is a string prefix of `base` does not
291/// accidentally pass the check.
292fn is_within(candidate: &Path, base: &Path) -> bool {
293    candidate.starts_with(base)
294}
295
296fn overlaps_source_path(build_dir: &Path, source_path: &Path) -> bool {
297    if build_dir.starts_with(source_path) || source_path.starts_with(build_dir) {
298        return true;
299    }
300    // The literal check above misses when the two paths reach the same
301    // location by different spellings - most visibly on Windows, where a
302    // build dir taken from the cwd may carry an 8.3 short name
303    // (`RUNNER~1`) while the manifest-derived source paths are long-name
304    // canonical (`runneradmin`), and on macOS where `/tmp` resolves to
305    // `/private/tmp`.  Canonicalize both through the project's single
306    // canonical-path boundary and re-test containment, so `cabin clean`
307    // still refuses a build dir that holds source files.  Falls back to
308    // "no overlap" when either side cannot be canonicalized (e.g. the
309    // build dir does not exist - there is nothing to protect there).
310    match (
311        cabin_fs::canonicalize(build_dir),
312        cabin_fs::canonicalize(source_path),
313    ) {
314        (Ok(cb), Ok(cs)) => cb.starts_with(&cs) || cs.starts_with(&cb),
315        _ => false,
316    }
317}
318
319fn home_dir() -> Option<PathBuf> {
320    let key = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
321    std::env::var_os(key).map(PathBuf::from)
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use assert_fs::TempDir;
328    use assert_fs::prelude::*;
329    use predicates::prelude::*;
330
331    fn profile(name: &str) -> ProfileName {
332        ProfileName::new(name.to_owned()).unwrap()
333    }
334
335    fn package(name: &str) -> PackageName {
336        PackageName::new(name.to_owned()).unwrap()
337    }
338
339    fn populate_layout(build_dir: &assert_fs::fixture::ChildPath) {
340        // dev profile.
341        build_dir.child("dev/build.ninja").write_str("x").unwrap();
342        build_dir
343            .child("dev/packages/hello/hello")
344            .write_str("x")
345            .unwrap();
346        build_dir
347            .child("dev/packages/util/libutil.a")
348            .write_str("x")
349            .unwrap();
350        // release profile.
351        build_dir
352            .child("release/build.ninja")
353            .write_str("x")
354            .unwrap();
355        build_dir
356            .child("release/packages/hello/hello")
357            .write_str("x")
358            .unwrap();
359    }
360
361    fn req<'a>(
362        build_dir: &'a Path,
363        workspace_root: &'a Path,
364        scope: CleanScope,
365    ) -> CleanRequest<'a> {
366        CleanRequest {
367            build_dir,
368            workspace_root,
369            package_roots: &[],
370            protected_source_paths: &[],
371            scope,
372        }
373    }
374
375    #[test]
376    fn plan_whole_lists_build_dir() {
377        let tmp = TempDir::new().unwrap();
378        let build_dir = tmp.child("build");
379        populate_layout(&build_dir);
380        let plan = plan_clean(&req(build_dir.path(), tmp.path(), CleanScope::Whole)).unwrap();
381        assert_eq!(plan.removals, vec![build_dir.to_path_buf()]);
382    }
383
384    #[test]
385    fn plan_profile_lists_only_that_profile() {
386        let tmp = TempDir::new().unwrap();
387        let build_dir = tmp.child("build");
388        populate_layout(&build_dir);
389        let plan = plan_clean(&req(
390            build_dir.path(),
391            tmp.path(),
392            CleanScope::Profile(profile("dev")),
393        ))
394        .unwrap();
395        assert_eq!(plan.removals, vec![build_dir.path().join("dev")]);
396    }
397
398    #[test]
399    fn plan_packages_includes_each_existing_path() {
400        let tmp = TempDir::new().unwrap();
401        let build_dir = tmp.child("build");
402        populate_layout(&build_dir);
403        let plan = plan_clean(&req(
404            build_dir.path(),
405            tmp.path(),
406            CleanScope::Packages {
407                profiles: vec![profile("dev"), profile("release")],
408                packages: vec![package("hello")],
409            },
410        ))
411        .unwrap();
412        let expected = {
413            let mut v = vec![
414                build_dir.path().join("dev").join("packages").join("hello"),
415                build_dir
416                    .path()
417                    .join("release")
418                    .join("packages")
419                    .join("hello"),
420            ];
421            v.sort();
422            v
423        };
424        assert_eq!(plan.removals, expected);
425    }
426
427    #[test]
428    fn plan_skips_missing_candidates() {
429        let tmp = TempDir::new().unwrap();
430        let build_dir = tmp.child("build");
431        // build dir does not exist.
432        let plan = plan_clean(&req(build_dir.path(), tmp.path(), CleanScope::Whole)).unwrap();
433        assert!(plan.removals.is_empty());
434    }
435
436    #[test]
437    fn plan_is_deterministic_and_deduplicated() {
438        let tmp = TempDir::new().unwrap();
439        let build_dir = tmp.child("build");
440        populate_layout(&build_dir);
441        let plan = plan_clean(&req(
442            build_dir.path(),
443            tmp.path(),
444            CleanScope::Packages {
445                profiles: vec![profile("release"), profile("dev"), profile("dev")],
446                packages: vec![package("hello"), package("hello")],
447            },
448        ))
449        .unwrap();
450        let mut sorted = plan.removals.clone();
451        sorted.sort();
452        sorted.dedup();
453        assert_eq!(plan.removals, sorted);
454    }
455
456    #[test]
457    fn execute_removes_planned_paths() {
458        let tmp = TempDir::new().unwrap();
459        let build_dir = tmp.child("build");
460        populate_layout(&build_dir);
461        let plan = plan_clean(&req(build_dir.path(), tmp.path(), CleanScope::Whole)).unwrap();
462        let report = execute_clean(&plan).unwrap();
463        assert_eq!(report.removed, vec![build_dir.to_path_buf()]);
464        build_dir.assert(predicate::path::missing());
465    }
466
467    #[test]
468    fn execute_tolerates_concurrent_removal() {
469        let tmp = TempDir::new().unwrap();
470        let build_dir = tmp.child("build");
471        populate_layout(&build_dir);
472        let plan = plan_clean(&req(build_dir.path(), tmp.path(), CleanScope::Whole)).unwrap();
473        std::fs::remove_dir_all(build_dir.path()).unwrap();
474        let report = execute_clean(&plan).unwrap();
475        assert!(report.removed.is_empty());
476    }
477
478    #[test]
479    fn rejects_root_build_dir() {
480        let workspace = PathBuf::from("/tmp/x");
481        let err = plan_clean(&req(Path::new("/"), &workspace, CleanScope::Whole)).unwrap_err();
482        assert!(matches!(err, CleanError::RootBuildDir(_)));
483    }
484
485    #[test]
486    fn rejects_empty_build_dir() {
487        let workspace = PathBuf::from("/tmp/x");
488        let err = plan_clean(&req(Path::new(""), &workspace, CleanScope::Whole)).unwrap_err();
489        assert!(matches!(err, CleanError::EmptyBuildDir));
490    }
491
492    #[test]
493    fn rejects_workspace_root_build_dir() {
494        let tmp = TempDir::new().unwrap();
495        let err = plan_clean(&req(tmp.path(), tmp.path(), CleanScope::Whole)).unwrap_err();
496        assert!(matches!(err, CleanError::WorkspaceRootBuildDir(_)));
497    }
498
499    #[test]
500    fn rejects_package_root_build_dir() {
501        let tmp = TempDir::new().unwrap();
502        let pkg = tmp.child("pkg");
503        pkg.create_dir_all().unwrap();
504        let pkg_path = pkg.to_path_buf();
505        let request = CleanRequest {
506            build_dir: pkg.path(),
507            workspace_root: tmp.path(),
508            package_roots: std::slice::from_ref(&pkg_path),
509            protected_source_paths: &[],
510            scope: CleanScope::Whole,
511        };
512        let err = plan_clean(&request).unwrap_err();
513        assert!(matches!(err, CleanError::PackageRootBuildDir(_)));
514    }
515
516    #[test]
517    fn rejects_build_dir_that_contains_source_path() {
518        let tmp = TempDir::new().unwrap();
519        let build_dir = tmp.child("pkg/src");
520        let source = build_dir.child("main.cc");
521        source.write_str("int main(){return 0;}").unwrap();
522        let source_path = source.to_path_buf();
523        let request = CleanRequest {
524            build_dir: build_dir.path(),
525            workspace_root: tmp.path(),
526            package_roots: &[],
527            protected_source_paths: std::slice::from_ref(&source_path),
528            scope: CleanScope::Whole,
529        };
530        let err = plan_clean(&request).unwrap_err();
531        assert!(matches!(err, CleanError::SourcePathBuildDir { .. }));
532    }
533
534    #[test]
535    fn rejects_build_dir_overlapping_source_by_divergent_spelling() {
536        // A build dir whose *spelling* differs from the canonical source
537        // path - here through a `..` segment, standing in for the 8.3
538        // short-name vs long-name divergence seen on Windows (and the
539        // `/tmp` vs `/private/tmp` one on macOS) - must still be
540        // rejected.  The literal `starts_with` check misses it; the
541        // canonicalized fallback catches it, so `cabin clean` cannot
542        // delete a build dir that holds source files.
543        let tmp = TempDir::new().unwrap();
544        let source = tmp.child("pkg/src/main.cc");
545        source.write_str("int main(){return 0;}").unwrap();
546        tmp.child("pkg/extra").create_dir_all().unwrap();
547        let source_path = source.to_path_buf();
548        // `pkg/extra/../src` only equals `pkg/src` after canonicalization.
549        let build_dir = tmp.path().join("pkg").join("extra").join("..").join("src");
550        let request = CleanRequest {
551            build_dir: &build_dir,
552            workspace_root: tmp.path(),
553            package_roots: &[],
554            protected_source_paths: std::slice::from_ref(&source_path),
555            scope: CleanScope::Whole,
556        };
557        let err = plan_clean(&request).unwrap_err();
558        assert!(matches!(err, CleanError::SourcePathBuildDir { .. }));
559    }
560
561    #[cfg(unix)]
562    #[test]
563    fn rejects_symlink_build_dir() {
564        let tmp = TempDir::new().unwrap();
565        let target = tmp.child("real");
566        target.create_dir_all().unwrap();
567        let link = tmp.child("link");
568        std::os::unix::fs::symlink(target.path(), link.path()).unwrap();
569        let err = plan_clean(&req(link.path(), tmp.path(), CleanScope::Whole)).unwrap_err();
570        assert!(matches!(err, CleanError::SymlinkBuildDir(_)));
571    }
572}