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