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