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#[derive(Debug, Serialize)]
20pub struct CleanReport {
21 pub removed: Vec<Candidate>,
23 pub quarantined: Vec<QuarantineEntry>,
25 pub failures: Vec<String>,
27 pub removed_bytes: u64,
29 pub quarantined_bytes: u64,
31}
32
33#[derive(Debug, Clone, Default)]
35pub struct CleanOptions {
36 pub quarantine_for: Option<Duration>,
38 pub quarantine_registry: Option<PathBuf>,
40}
41
42#[must_use]
44pub fn clean(scan_report: &ScanReport) -> CleanReport {
45 clean_with_options(scan_report, &CleanOptions::default())
46}
47
48#[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}