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#[derive(Debug, Serialize)]
18pub struct CleanReport {
19 pub removed: Vec<Candidate>,
21 pub failures: Vec<String>,
23 pub removed_bytes: u64,
25}
26
27#[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}