Skip to main content

devclean/
quarantine.rs

1//! Persistent safety holds for artifacts that should remain restorable before deletion.
2
3use 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/// One restorable artifact held beside its original location.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct QuarantineEntry {
23    /// Stable identifier used by list, restore, and purge commands.
24    pub id: String,
25    /// Original artifact location.
26    pub original_path: PathBuf,
27    /// Hidden adjacent location used during the safety hold.
28    pub quarantine_path: PathBuf,
29    /// Scanner category validated immediately before the move.
30    pub category: Category,
31    /// Scan-time allocated bytes retained by the hold.
32    pub bytes: u64,
33    /// Creation time as seconds since the Unix epoch.
34    pub created_at_unix: u64,
35    /// Time after which automatic purge is allowed.
36    pub expires_at_unix: u64,
37}
38
39/// Outcome of purging expired or explicitly selected safety holds.
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41pub struct PurgeReport {
42    /// Holds deleted successfully.
43    pub purged: Vec<QuarantineEntry>,
44    /// Hold-specific failures. Other valid holds are still processed.
45    pub failures: Vec<String>,
46    /// Allocated bytes represented by successful purges.
47    pub purged_bytes: u64,
48}
49
50#[derive(Debug, Default, Serialize, Deserialize)]
51struct QuarantineStore {
52    version: u32,
53    entries: Vec<QuarantineEntry>,
54}
55
56/// Returns the platform-local registry used to track safety holds.
57///
58/// # Errors
59///
60/// Returns an error when the operating system data directory is unavailable.
61pub 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
66/// Moves a validated artifact into a hidden adjacent safety hold and records it.
67///
68/// The move does not reclaim disk space until the hold is purged.
69///
70/// # Errors
71///
72/// Returns an error if the path is not a real directory, cannot be moved atomically, or the
73/// registry cannot be updated. A failed registry update attempts to restore the original path.
74pub 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(&registry, |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
127/// Lists all recorded safety holds after pruning registry entries whose paths disappeared.
128///
129/// # Errors
130///
131/// Returns an error when the registry is unreadable or invalid.
132pub 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(&registry, |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
145/// Restores one safety hold to its original location.
146///
147/// # Errors
148///
149/// Returns an error for an unknown identifier, an unsafe registry entry, an occupied original
150/// path, or a failed filesystem move.
151pub 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(&registry, |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
178/// Purges holds whose expiration time is at or before `now_unix`.
179///
180/// # Errors
181///
182/// Returns an error when the registry cannot be loaded or saved. Individual deletion failures
183/// are returned in [`PurgeReport::failures`].
184pub 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(&registry, |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
216/// Permanently deletes one explicitly selected safety hold before or after expiry.
217///
218/// # Errors
219///
220/// Returns an error when the identifier is unknown or the registry cannot be loaded or saved.
221/// A validation or filesystem deletion failure is returned in [`PurgeReport::failures`].
222pub 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(&registry, |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(&registry),
366        )?;
367        let restored = restore(&entry.id, Some(&registry))?;
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(&registry),
386        )?;
387
388        let report = purge_expired(u64::MAX, Some(&registry))?;
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(&registry),
409        )?;
410        let second_entry = hold(
411            &second,
412            Category::RustTarget,
413            60,
414            Duration::from_secs(60),
415            Some(&registry),
416        )?;
417
418        let report = purge_selected(&first_entry.id, Some(&registry))?;
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(&registry))?.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(&registry),
439        )?;
440
441        let result = purge_selected("missing", Some(&registry));
442
443        assert!(result.is_err());
444        assert!(entry.quarantine_path.exists());
445        assert_eq!(list(Some(&registry))?.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(&registry),
461        )?;
462        fs::create_dir_all(&original)?;
463
464        let result = restore(&entry.id, Some(&registry));
465
466        assert!(result.is_err());
467        Ok(())
468    }
469}