Skip to main content

devclean/
cleaner.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use anyhow::{Result, anyhow, bail};
7use directories::BaseDirs;
8use serde::Serialize;
9
10use crate::model::{Candidate, Category, ScanReport};
11use crate::policy::contains_git_tracked_files;
12use crate::scanner::{classify, expensive_global_cache_paths, global_cache_paths};
13
14static QUARANTINE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
15
16/// Outcome of deleting a validated scan report.
17#[derive(Debug, Serialize)]
18pub struct CleanReport {
19    /// Candidates removed successfully.
20    pub removed: Vec<Candidate>,
21    /// Candidate-specific failures. Cleanup continues after a failure.
22    pub failures: Vec<String>,
23    /// Scan-time allocated bytes represented by successful removals.
24    pub removed_bytes: u64,
25}
26
27/// Removes only candidates that still satisfy the scan-time safety policy.
28#[must_use]
29pub fn clean(scan_report: &ScanReport) -> CleanReport {
30    let mut removed = Vec::new();
31    let mut failures = Vec::new();
32    let allowed_global = BaseDirs::new().map_or_else(Vec::new, |base| {
33        let mut paths = global_cache_paths(base.home_dir());
34        paths.extend(expensive_global_cache_paths(base.home_dir()));
35        paths
36    });
37
38    for candidate in &scan_report.candidates {
39        if let Err(error) = validate_candidate(
40            candidate,
41            &scan_report.roots,
42            &allowed_global,
43            scan_report.protect_git_tracked,
44        ) {
45            failures.push(format!("{}: {error}", candidate.path.display()));
46            continue;
47        }
48        match quarantine_and_remove(&candidate.path) {
49            Ok(()) => removed.push(candidate.clone()),
50            Err(error) => failures.push(format!("{}: {error:#}", candidate.path.display())),
51        }
52    }
53
54    let removed_bytes = removed.iter().map(|candidate| candidate.bytes).sum();
55    CleanReport {
56        removed,
57        failures,
58        removed_bytes,
59    }
60}
61
62fn validate_candidate(
63    candidate: &Candidate,
64    roots: &[PathBuf],
65    allowed_global: &[PathBuf],
66    protect_git_tracked: bool,
67) -> Result<(), String> {
68    let metadata = fs::symlink_metadata(&candidate.path).map_err(|error| error.to_string())?;
69    if !metadata.is_dir() || metadata.file_type().is_symlink() {
70        return Err("candidate is no longer a real directory".to_owned());
71    }
72
73    if matches!(
74        candidate.category,
75        Category::GlobalCache | Category::ExpensiveGlobalCache
76    ) {
77        if allowed_global.iter().any(|path| path == &candidate.path) {
78            return Ok(());
79        }
80        return Err("global cache is not on the exact allowlist".to_owned());
81    }
82
83    let canonical = candidate
84        .path
85        .canonicalize()
86        .map_err(|error| error.to_string())?;
87    if !roots.iter().any(|root| canonical.starts_with(root)) {
88        return Err("candidate escaped the configured roots".to_owned());
89    }
90
91    let Some((current_category, _)) = classify(&candidate.path) else {
92        return Err("candidate no longer matches a rebuildable artifact".to_owned());
93    };
94    if current_category != candidate.category {
95        return Err("candidate category changed after scanning".to_owned());
96    }
97    if protect_git_tracked {
98        match contains_git_tracked_files(&candidate.path) {
99            Ok(true) => return Err("candidate now contains Git-tracked files".to_owned()),
100            Ok(false) => {}
101            Err(error) => return Err(format!("Git tracked-file guard failed: {error:#}")),
102        }
103    }
104    Ok(())
105}
106
107fn quarantine_and_remove(path: &Path) -> Result<()> {
108    let parent = path
109        .parent()
110        .ok_or_else(|| anyhow!("candidate has no parent directory"))?;
111    let sequence = QUARANTINE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
112    let timestamp = SystemTime::now()
113        .duration_since(UNIX_EPOCH)
114        .map_or(0, |duration| duration.as_nanos());
115    let quarantine = parent.join(format!(
116        ".devclean-quarantine-{}-{timestamp}-{sequence}",
117        std::process::id()
118    ));
119    if quarantine.exists() {
120        bail!("unique quarantine path unexpectedly exists");
121    }
122    fs::rename(path, &quarantine)?;
123
124    let metadata = fs::symlink_metadata(&quarantine)?;
125    if !metadata.is_dir() || metadata.file_type().is_symlink() {
126        let _ = fs::rename(&quarantine, path);
127        bail!("candidate changed type during atomic quarantine");
128    }
129    if let Err(error) = fs::remove_dir_all(&quarantine) {
130        let restored = fs::rename(&quarantine, path).is_ok();
131        bail!(
132            "failed to remove quarantined directory: {error}; restored original path: {restored}"
133        );
134    }
135    Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140    use std::collections::HashSet;
141    use std::fs;
142
143    use tempfile::tempdir;
144
145    use super::*;
146    use crate::scanner::{ScanOptions, scan};
147
148    fn options(root: &Path, category: Category) -> ScanOptions {
149        ScanOptions {
150            roots: vec![root.to_path_buf()],
151            categories: HashSet::from([category]),
152            include_global_caches: false,
153            include_expensive_caches: false,
154            max_depth: 8,
155            excludes: Vec::new(),
156            older_than: None,
157            min_size: 0,
158            protect_git_tracked: false,
159        }
160    }
161
162    #[test]
163    fn clean_should_remove_scanned_node_modules() -> Result<()> {
164        let temporary = tempdir()?;
165        let modules = temporary.path().join("node_modules");
166        fs::create_dir_all(&modules)?;
167        fs::write(modules.join("dependency.js"), "content")?;
168        let report = scan(&options(temporary.path(), Category::NodeModules))?;
169
170        let clean_report = clean(&report);
171
172        assert!(!modules.exists());
173        assert!(clean_report.failures.is_empty());
174        Ok(())
175    }
176
177    #[test]
178    fn clean_should_reject_candidate_that_changed_category() -> Result<()> {
179        let temporary = tempdir()?;
180        let target = temporary.path().join("target");
181        fs::create_dir_all(target.join("debug"))?;
182        let report = scan(&options(temporary.path(), Category::RustTarget))?;
183        fs::remove_dir_all(target.join("debug"))?;
184
185        let clean_report = clean(&report);
186
187        assert_eq!(clean_report.failures.len(), 1);
188        Ok(())
189    }
190
191    #[test]
192    fn clean_should_leave_no_quarantine_after_success() -> Result<()> {
193        let temporary = tempdir()?;
194        let modules = temporary.path().join("node_modules");
195        fs::create_dir_all(&modules)?;
196        let report = scan(&options(temporary.path(), Category::NodeModules))?;
197
198        let _ = clean(&report);
199        let leftovers = temporary
200            .path()
201            .read_dir()?
202            .filter_map(Result::ok)
203            .filter(|entry| {
204                entry
205                    .file_name()
206                    .to_string_lossy()
207                    .starts_with(".devclean-quarantine")
208            })
209            .count();
210
211        assert_eq!(leftovers, 0);
212        Ok(())
213    }
214}