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#[derive(Debug, Serialize)]
19pub struct CleanReport {
20 pub removed: Vec<Candidate>,
22 pub quarantined: Vec<QuarantineEntry>,
24 pub failures: Vec<String>,
26 pub removed_bytes: u64,
28 pub quarantined_bytes: u64,
30}
31
32#[derive(Debug, Clone, Default)]
34pub struct CleanOptions {
35 pub quarantine_for: Option<Duration>,
37 pub quarantine_registry: Option<PathBuf>,
39}
40
41#[must_use]
43pub fn clean(scan_report: &ScanReport) -> CleanReport {
44 clean_with_options(scan_report, &CleanOptions::default())
45}
46
47#[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}