Skip to main content

devclean/
cleaner.rs

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