Skip to main content

devclean/
cleaner.rs

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