1use std::ffi::OsStr;
4use std::fs::{self, File, OpenOptions};
5use std::io::Write as _;
6use std::path::{Path, PathBuf};
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9use anyhow::{Context, Result, bail};
10use directories::BaseDirs;
11use fs4::FileExt;
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15use crate::model::Category;
16
17const STORE_VERSION: u32 = 1;
18const QUARANTINE_PREFIX: &str = ".devclean-quarantine-";
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct QuarantineEntry {
23 pub id: String,
25 pub original_path: PathBuf,
27 pub quarantine_path: PathBuf,
29 pub category: Category,
31 pub bytes: u64,
33 pub created_at_unix: u64,
35 pub expires_at_unix: u64,
37}
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41pub struct PurgeReport {
42 pub purged: Vec<QuarantineEntry>,
44 pub failures: Vec<String>,
46 pub purged_bytes: u64,
48}
49
50#[derive(Debug, Default, Serialize, Deserialize)]
51struct QuarantineStore {
52 version: u32,
53 entries: Vec<QuarantineEntry>,
54}
55
56pub fn default_registry_path() -> Result<PathBuf> {
62 let base = BaseDirs::new().context("platform data directory is unavailable")?;
63 Ok(base.data_local_dir().join("devclean/quarantine.json"))
64}
65
66pub fn hold(
75 path: &Path,
76 category: Category,
77 bytes: u64,
78 retention: Duration,
79 registry_path: Option<&Path>,
80) -> Result<QuarantineEntry> {
81 if retention.is_zero() {
82 bail!("quarantine retention must be greater than zero");
83 }
84 let metadata = fs::symlink_metadata(path)
85 .with_context(|| format!("failed to inspect candidate {}", path.display()))?;
86 if !metadata.is_dir() || metadata.file_type().is_symlink() {
87 bail!("candidate is not a real directory");
88 }
89 let parent = path.parent().context("candidate has no parent directory")?;
90 let now = unix_time(SystemTime::now());
91 let id = next_id();
92 let quarantine_path = parent.join(format!("{QUARANTINE_PREFIX}{id}"));
93 if quarantine_path.exists() {
94 bail!("unique quarantine path unexpectedly exists");
95 }
96
97 fs::rename(path, &quarantine_path).with_context(|| {
98 format!(
99 "failed to move {} into an adjacent safety hold",
100 path.display()
101 )
102 })?;
103 let entry = QuarantineEntry {
104 id,
105 original_path: path.to_path_buf(),
106 quarantine_path: quarantine_path.clone(),
107 category,
108 bytes,
109 created_at_unix: now,
110 expires_at_unix: now.saturating_add(retention.as_secs()),
111 };
112
113 let registry =
114 registry_path.map_or_else(default_registry_path, |value| Ok(value.to_path_buf()));
115 if let Err(error) = registry.and_then(|registry| {
116 update_store(®istry, |store| {
117 store.entries.push(entry.clone());
118 Ok(())
119 })
120 }) {
121 let restored = fs::rename(&quarantine_path, path).is_ok();
122 bail!("failed to record safety hold: {error:#}; restored original path: {restored}");
123 }
124 Ok(entry)
125}
126
127pub fn list(registry_path: Option<&Path>) -> Result<Vec<QuarantineEntry>> {
133 let registry =
134 registry_path.map_or_else(default_registry_path, |value| Ok(value.to_path_buf()))?;
135 let mut entries = Vec::new();
136 update_store(®istry, |store| {
137 store.entries.retain(|entry| entry.quarantine_path.exists());
138 entries.clone_from(&store.entries);
139 Ok(())
140 })?;
141 entries.sort_by_key(|entry| entry.expires_at_unix);
142 Ok(entries)
143}
144
145pub fn restore(id: &str, registry_path: Option<&Path>) -> Result<QuarantineEntry> {
152 let registry =
153 registry_path.map_or_else(default_registry_path, |value| Ok(value.to_path_buf()))?;
154 let mut restored = None;
155 update_store(®istry, |store| {
156 let index = store
157 .entries
158 .iter()
159 .position(|entry| entry.id == id)
160 .with_context(|| format!("unknown quarantine id `{id}`"))?;
161 let entry = store.entries[index].clone();
162 validate_entry(&entry)?;
163 if entry.original_path.exists() {
164 bail!(
165 "cannot restore because original path exists: {}",
166 entry.original_path.display()
167 );
168 }
169 fs::rename(&entry.quarantine_path, &entry.original_path)
170 .with_context(|| format!("failed to restore {}", entry.original_path.display()))?;
171 store.entries.remove(index);
172 restored = Some(entry);
173 Ok(())
174 })?;
175 restored.context("quarantine restore completed without an entry")
176}
177
178pub fn purge_expired(now_unix: u64, registry_path: Option<&Path>) -> Result<PurgeReport> {
185 let registry =
186 registry_path.map_or_else(default_registry_path, |value| Ok(value.to_path_buf()))?;
187 let mut report = PurgeReport::default();
188 update_store(®istry, |store| {
189 let mut retained = Vec::new();
190 for entry in store.entries.drain(..) {
191 if entry.expires_at_unix > now_unix {
192 retained.push(entry);
193 continue;
194 }
195 if !entry.quarantine_path.exists() {
196 continue;
197 }
198 if let Err(error) = validate_entry(&entry)
199 .and_then(|()| fs::remove_dir_all(&entry.quarantine_path).map_err(Into::into))
200 {
201 report
202 .failures
203 .push(format!("{}: {error:#}", entry.quarantine_path.display()));
204 retained.push(entry);
205 continue;
206 }
207 report.purged_bytes = report.purged_bytes.saturating_add(entry.bytes);
208 report.purged.push(entry);
209 }
210 store.entries = retained;
211 Ok(())
212 })?;
213 Ok(report)
214}
215
216pub fn purge_selected(id: &str, registry_path: Option<&Path>) -> Result<PurgeReport> {
223 let registry =
224 registry_path.map_or_else(default_registry_path, |value| Ok(value.to_path_buf()))?;
225 let mut report = PurgeReport::default();
226 update_store(®istry, |store| {
227 let index = store
228 .entries
229 .iter()
230 .position(|entry| entry.id == id)
231 .with_context(|| format!("unknown quarantine id `{id}`"))?;
232 let entry = store.entries[index].clone();
233 if !entry.quarantine_path.exists() {
234 store.entries.remove(index);
235 return Ok(());
236 }
237 if let Err(error) = validate_entry(&entry)
238 .and_then(|()| fs::remove_dir_all(&entry.quarantine_path).map_err(Into::into))
239 {
240 report
241 .failures
242 .push(format!("{}: {error:#}", entry.quarantine_path.display()));
243 return Ok(());
244 }
245 store.entries.remove(index);
246 report.purged_bytes = entry.bytes;
247 report.purged.push(entry);
248 Ok(())
249 })?;
250 Ok(report)
251}
252
253fn validate_entry(entry: &QuarantineEntry) -> Result<()> {
254 let expected_parent = entry
255 .original_path
256 .parent()
257 .context("recorded original path has no parent")?;
258 let expected_name = format!("{QUARANTINE_PREFIX}{}", entry.id);
259 if entry.quarantine_path.parent() != Some(expected_parent)
260 || entry.quarantine_path.file_name() != Some(OsStr::new(&expected_name))
261 {
262 bail!("registry entry does not point to an adjacent devclean quarantine");
263 }
264 let metadata = fs::symlink_metadata(&entry.quarantine_path)?;
265 if !metadata.is_dir() || metadata.file_type().is_symlink() {
266 bail!("quarantine path is not a real directory");
267 }
268 Ok(())
269}
270
271fn update_store(
272 registry_path: &Path,
273 operation: impl FnOnce(&mut QuarantineStore) -> Result<()>,
274) -> Result<()> {
275 let parent = registry_path
276 .parent()
277 .context("quarantine registry has no parent directory")?;
278 fs::create_dir_all(parent)?;
279 let lock_path = registry_path.with_extension("lock");
280 let lock = open_private(&lock_path)?;
281 FileExt::lock(&lock)?;
282
283 let mut store = if registry_path.is_file() {
284 let content = fs::read(registry_path)?;
285 serde_json::from_slice(&content).context("invalid quarantine registry")?
286 } else {
287 QuarantineStore {
288 version: STORE_VERSION,
289 entries: Vec::new(),
290 }
291 };
292 if store.version != STORE_VERSION {
293 bail!("unsupported quarantine registry version {}", store.version);
294 }
295 operation(&mut store)?;
296 save_store(registry_path, &store)?;
297 FileExt::unlock(&lock)?;
298 Ok(())
299}
300
301fn save_store(path: &Path, store: &QuarantineStore) -> Result<()> {
302 let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
303 let mut options = OpenOptions::new();
304 options.create(true).write(true).truncate(true);
305 #[cfg(unix)]
306 {
307 use std::os::unix::fs::OpenOptionsExt as _;
308 options.mode(0o600);
309 }
310 let mut file = options
311 .open(&temporary)
312 .with_context(|| format!("failed to open {}", temporary.display()))?;
313 serde_json::to_writer_pretty(&mut file, store)?;
314 file.write_all(b"\n")?;
315 file.sync_all()?;
316 #[cfg(windows)]
317 if path.exists() {
318 fs::remove_file(path)?;
319 }
320 fs::rename(&temporary, path)?;
321 Ok(())
322}
323
324fn open_private(path: &Path) -> Result<File> {
325 let mut options = OpenOptions::new();
326 options.create(true).read(true).write(true).truncate(false);
327 #[cfg(unix)]
328 {
329 use std::os::unix::fs::OpenOptionsExt as _;
330 options.mode(0o600);
331 }
332 options
333 .open(path)
334 .with_context(|| format!("failed to open {}", path.display()))
335}
336
337fn next_id() -> String {
338 Uuid::new_v4().to_string()
339}
340
341fn unix_time(value: SystemTime) -> u64 {
342 value
343 .duration_since(UNIX_EPOCH)
344 .map_or(0, |duration| duration.as_secs())
345}
346
347#[cfg(test)]
348mod tests {
349 use tempfile::tempdir;
350
351 use super::*;
352
353 #[test]
354 fn hold_and_restore_should_round_trip_directory() -> Result<()> {
355 let temporary = tempdir()?;
356 let original = temporary.path().join("node_modules");
357 fs::create_dir_all(&original)?;
358 let registry = temporary.path().join("state/quarantine.json");
359
360 let entry = hold(
361 &original,
362 Category::NodeModules,
363 42,
364 Duration::from_secs(60),
365 Some(®istry),
366 )?;
367 let restored = restore(&entry.id, Some(®istry))?;
368
369 assert_eq!(restored.original_path, original);
370 assert!(original.is_dir());
371 Ok(())
372 }
373
374 #[test]
375 fn purge_should_delete_expired_hold() -> Result<()> {
376 let temporary = tempdir()?;
377 let original = temporary.path().join("target");
378 fs::create_dir_all(&original)?;
379 let registry = temporary.path().join("state/quarantine.json");
380 let entry = hold(
381 &original,
382 Category::RustTarget,
383 99,
384 Duration::from_secs(1),
385 Some(®istry),
386 )?;
387
388 let report = purge_expired(u64::MAX, Some(®istry))?;
389
390 assert_eq!(report.purged_bytes, 99);
391 assert!(!entry.quarantine_path.exists());
392 Ok(())
393 }
394
395 #[test]
396 fn purge_selected_should_delete_only_requested_hold() -> Result<()> {
397 let temporary = tempdir()?;
398 let first = temporary.path().join("first/target");
399 let second = temporary.path().join("second/target");
400 fs::create_dir_all(&first)?;
401 fs::create_dir_all(&second)?;
402 let registry = temporary.path().join("state/quarantine.json");
403 let first_entry = hold(
404 &first,
405 Category::RustTarget,
406 40,
407 Duration::from_secs(60),
408 Some(®istry),
409 )?;
410 let second_entry = hold(
411 &second,
412 Category::RustTarget,
413 60,
414 Duration::from_secs(60),
415 Some(®istry),
416 )?;
417
418 let report = purge_selected(&first_entry.id, Some(®istry))?;
419
420 assert_eq!(report.purged_bytes, 40);
421 assert!(!first_entry.quarantine_path.exists());
422 assert!(second_entry.quarantine_path.exists());
423 assert_eq!(list(Some(®istry))?.len(), 1);
424 Ok(())
425 }
426
427 #[test]
428 fn purge_selected_should_refuse_unknown_id_without_deleting_holds() -> Result<()> {
429 let temporary = tempdir()?;
430 let original = temporary.path().join("target");
431 fs::create_dir_all(&original)?;
432 let registry = temporary.path().join("state/quarantine.json");
433 let entry = hold(
434 &original,
435 Category::RustTarget,
436 99,
437 Duration::from_secs(60),
438 Some(®istry),
439 )?;
440
441 let result = purge_selected("missing", Some(®istry));
442
443 assert!(result.is_err());
444 assert!(entry.quarantine_path.exists());
445 assert_eq!(list(Some(®istry))?.len(), 1);
446 Ok(())
447 }
448
449 #[test]
450 fn restore_should_refuse_occupied_original_path() -> Result<()> {
451 let temporary = tempdir()?;
452 let original = temporary.path().join("node_modules");
453 fs::create_dir_all(&original)?;
454 let registry = temporary.path().join("state/quarantine.json");
455 let entry = hold(
456 &original,
457 Category::NodeModules,
458 42,
459 Duration::from_secs(60),
460 Some(®istry),
461 )?;
462 fs::create_dir_all(&original)?;
463
464 let result = restore(&entry.id, Some(®istry));
465
466 assert!(result.is_err());
467 Ok(())
468 }
469}