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::sync::atomic::{AtomicU64, Ordering};
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10use anyhow::{Context, Result, bail};
11use directories::BaseDirs;
12use fs2::FileExt;
13use serde::{Deserialize, Serialize};
14
15use crate::model::Category;
16
17const STORE_VERSION: u32 = 1;
18const QUARANTINE_PREFIX: &str = ".devclean-quarantine-";
19static ENTRY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
20
21/// One restorable artifact held beside its original location.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct QuarantineEntry {
24    /// Stable identifier used by list, restore, and purge commands.
25    pub id: String,
26    /// Original artifact location.
27    pub original_path: PathBuf,
28    /// Hidden adjacent location used during the safety hold.
29    pub quarantine_path: PathBuf,
30    /// Scanner category validated immediately before the move.
31    pub category: Category,
32    /// Scan-time allocated bytes retained by the hold.
33    pub bytes: u64,
34    /// Creation time as seconds since the Unix epoch.
35    pub created_at_unix: u64,
36    /// Time after which automatic purge is allowed.
37    pub expires_at_unix: u64,
38}
39
40/// Outcome of purging expired or explicitly selected safety holds.
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
42pub struct PurgeReport {
43    /// Holds deleted successfully.
44    pub purged: Vec<QuarantineEntry>,
45    /// Hold-specific failures. Other valid holds are still processed.
46    pub failures: Vec<String>,
47    /// Allocated bytes represented by successful purges.
48    pub purged_bytes: u64,
49}
50
51#[derive(Debug, Default, Serialize, Deserialize)]
52struct QuarantineStore {
53    version: u32,
54    entries: Vec<QuarantineEntry>,
55}
56
57/// Returns the platform-local registry used to track safety holds.
58///
59/// # Errors
60///
61/// Returns an error when the operating system data directory is unavailable.
62pub fn default_registry_path() -> Result<PathBuf> {
63    let base = BaseDirs::new().context("platform data directory is unavailable")?;
64    Ok(base.data_local_dir().join("devclean/quarantine.json"))
65}
66
67/// Moves a validated artifact into a hidden adjacent safety hold and records it.
68///
69/// The move does not reclaim disk space until the hold is purged.
70///
71/// # Errors
72///
73/// Returns an error if the path is not a real directory, cannot be moved atomically, or the
74/// registry cannot be updated. A failed registry update attempts to restore the original path.
75pub fn hold(
76    path: &Path,
77    category: Category,
78    bytes: u64,
79    retention: Duration,
80    registry_path: Option<&Path>,
81) -> Result<QuarantineEntry> {
82    if retention.is_zero() {
83        bail!("quarantine retention must be greater than zero");
84    }
85    let metadata = fs::symlink_metadata(path)
86        .with_context(|| format!("failed to inspect candidate {}", path.display()))?;
87    if !metadata.is_dir() || metadata.file_type().is_symlink() {
88        bail!("candidate is not a real directory");
89    }
90    let parent = path.parent().context("candidate has no parent directory")?;
91    let now = unix_time(SystemTime::now());
92    let id = next_id(now);
93    let quarantine_path = parent.join(format!("{QUARANTINE_PREFIX}{id}"));
94    if quarantine_path.exists() {
95        bail!("unique quarantine path unexpectedly exists");
96    }
97
98    fs::rename(path, &quarantine_path).with_context(|| {
99        format!(
100            "failed to move {} into an adjacent safety hold",
101            path.display()
102        )
103    })?;
104    let entry = QuarantineEntry {
105        id,
106        original_path: path.to_path_buf(),
107        quarantine_path: quarantine_path.clone(),
108        category,
109        bytes,
110        created_at_unix: now,
111        expires_at_unix: now.saturating_add(retention.as_secs()),
112    };
113
114    let registry =
115        registry_path.map_or_else(default_registry_path, |value| Ok(value.to_path_buf()));
116    if let Err(error) = registry.and_then(|registry| {
117        update_store(&registry, |store| {
118            store.entries.push(entry.clone());
119            Ok(())
120        })
121    }) {
122        let restored = fs::rename(&quarantine_path, path).is_ok();
123        bail!("failed to record safety hold: {error:#}; restored original path: {restored}");
124    }
125    Ok(entry)
126}
127
128/// Lists all recorded safety holds after pruning registry entries whose paths disappeared.
129///
130/// # Errors
131///
132/// Returns an error when the registry is unreadable or invalid.
133pub fn list(registry_path: Option<&Path>) -> Result<Vec<QuarantineEntry>> {
134    let registry =
135        registry_path.map_or_else(default_registry_path, |value| Ok(value.to_path_buf()))?;
136    let mut entries = Vec::new();
137    update_store(&registry, |store| {
138        store.entries.retain(|entry| entry.quarantine_path.exists());
139        entries.clone_from(&store.entries);
140        Ok(())
141    })?;
142    entries.sort_by_key(|entry| entry.expires_at_unix);
143    Ok(entries)
144}
145
146/// Restores one safety hold to its original location.
147///
148/// # Errors
149///
150/// Returns an error for an unknown identifier, an unsafe registry entry, an occupied original
151/// path, or a failed filesystem move.
152pub fn restore(id: &str, registry_path: Option<&Path>) -> Result<QuarantineEntry> {
153    let registry =
154        registry_path.map_or_else(default_registry_path, |value| Ok(value.to_path_buf()))?;
155    let mut restored = None;
156    update_store(&registry, |store| {
157        let index = store
158            .entries
159            .iter()
160            .position(|entry| entry.id == id)
161            .with_context(|| format!("unknown quarantine id `{id}`"))?;
162        let entry = store.entries[index].clone();
163        validate_entry(&entry)?;
164        if entry.original_path.exists() {
165            bail!(
166                "cannot restore because original path exists: {}",
167                entry.original_path.display()
168            );
169        }
170        fs::rename(&entry.quarantine_path, &entry.original_path)
171            .with_context(|| format!("failed to restore {}", entry.original_path.display()))?;
172        store.entries.remove(index);
173        restored = Some(entry);
174        Ok(())
175    })?;
176    restored.context("quarantine restore completed without an entry")
177}
178
179/// Purges holds whose expiration time is at or before `now_unix`.
180///
181/// # Errors
182///
183/// Returns an error when the registry cannot be loaded or saved. Individual deletion failures
184/// are returned in [`PurgeReport::failures`].
185pub fn purge_expired(now_unix: u64, registry_path: Option<&Path>) -> Result<PurgeReport> {
186    let registry =
187        registry_path.map_or_else(default_registry_path, |value| Ok(value.to_path_buf()))?;
188    let mut report = PurgeReport::default();
189    update_store(&registry, |store| {
190        let mut retained = Vec::new();
191        for entry in store.entries.drain(..) {
192            if entry.expires_at_unix > now_unix {
193                retained.push(entry);
194                continue;
195            }
196            if !entry.quarantine_path.exists() {
197                continue;
198            }
199            if let Err(error) = validate_entry(&entry)
200                .and_then(|()| fs::remove_dir_all(&entry.quarantine_path).map_err(Into::into))
201            {
202                report
203                    .failures
204                    .push(format!("{}: {error:#}", entry.quarantine_path.display()));
205                retained.push(entry);
206                continue;
207            }
208            report.purged_bytes = report.purged_bytes.saturating_add(entry.bytes);
209            report.purged.push(entry);
210        }
211        store.entries = retained;
212        Ok(())
213    })?;
214    Ok(report)
215}
216
217fn validate_entry(entry: &QuarantineEntry) -> Result<()> {
218    let expected_parent = entry
219        .original_path
220        .parent()
221        .context("recorded original path has no parent")?;
222    let expected_name = format!("{QUARANTINE_PREFIX}{}", entry.id);
223    if entry.quarantine_path.parent() != Some(expected_parent)
224        || entry.quarantine_path.file_name() != Some(OsStr::new(&expected_name))
225    {
226        bail!("registry entry does not point to an adjacent devclean quarantine");
227    }
228    let metadata = fs::symlink_metadata(&entry.quarantine_path)?;
229    if !metadata.is_dir() || metadata.file_type().is_symlink() {
230        bail!("quarantine path is not a real directory");
231    }
232    Ok(())
233}
234
235fn update_store(
236    registry_path: &Path,
237    operation: impl FnOnce(&mut QuarantineStore) -> Result<()>,
238) -> Result<()> {
239    let parent = registry_path
240        .parent()
241        .context("quarantine registry has no parent directory")?;
242    fs::create_dir_all(parent)?;
243    let lock_path = registry_path.with_extension("lock");
244    let lock = open_private(&lock_path)?;
245    lock.lock_exclusive()?;
246
247    let mut store = if registry_path.is_file() {
248        let content = fs::read(registry_path)?;
249        serde_json::from_slice(&content).context("invalid quarantine registry")?
250    } else {
251        QuarantineStore {
252            version: STORE_VERSION,
253            entries: Vec::new(),
254        }
255    };
256    if store.version != STORE_VERSION {
257        bail!("unsupported quarantine registry version {}", store.version);
258    }
259    operation(&mut store)?;
260    save_store(registry_path, &store)?;
261    FileExt::unlock(&lock)?;
262    Ok(())
263}
264
265fn save_store(path: &Path, store: &QuarantineStore) -> Result<()> {
266    let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
267    let mut options = OpenOptions::new();
268    options.create(true).write(true).truncate(true);
269    #[cfg(unix)]
270    {
271        use std::os::unix::fs::OpenOptionsExt as _;
272        options.mode(0o600);
273    }
274    let mut file = options
275        .open(&temporary)
276        .with_context(|| format!("failed to open {}", temporary.display()))?;
277    serde_json::to_writer_pretty(&mut file, store)?;
278    file.write_all(b"\n")?;
279    file.sync_all()?;
280    #[cfg(windows)]
281    if path.exists() {
282        fs::remove_file(path)?;
283    }
284    fs::rename(&temporary, path)?;
285    Ok(())
286}
287
288fn open_private(path: &Path) -> Result<File> {
289    let mut options = OpenOptions::new();
290    options.create(true).read(true).write(true).truncate(false);
291    #[cfg(unix)]
292    {
293        use std::os::unix::fs::OpenOptionsExt as _;
294        options.mode(0o600);
295    }
296    options
297        .open(path)
298        .with_context(|| format!("failed to open {}", path.display()))
299}
300
301fn next_id(now: u64) -> String {
302    let sequence = ENTRY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
303    format!("{now:x}-{:x}-{sequence:x}", std::process::id())
304}
305
306fn unix_time(value: SystemTime) -> u64 {
307    value
308        .duration_since(UNIX_EPOCH)
309        .map_or(0, |duration| duration.as_secs())
310}
311
312#[cfg(test)]
313mod tests {
314    use tempfile::tempdir;
315
316    use super::*;
317
318    #[test]
319    fn hold_and_restore_should_round_trip_directory() -> Result<()> {
320        let temporary = tempdir()?;
321        let original = temporary.path().join("node_modules");
322        fs::create_dir_all(&original)?;
323        let registry = temporary.path().join("state/quarantine.json");
324
325        let entry = hold(
326            &original,
327            Category::NodeModules,
328            42,
329            Duration::from_secs(60),
330            Some(&registry),
331        )?;
332        let restored = restore(&entry.id, Some(&registry))?;
333
334        assert_eq!(restored.original_path, original);
335        assert!(original.is_dir());
336        Ok(())
337    }
338
339    #[test]
340    fn purge_should_delete_expired_hold() -> Result<()> {
341        let temporary = tempdir()?;
342        let original = temporary.path().join("target");
343        fs::create_dir_all(&original)?;
344        let registry = temporary.path().join("state/quarantine.json");
345        let entry = hold(
346            &original,
347            Category::RustTarget,
348            99,
349            Duration::from_secs(1),
350            Some(&registry),
351        )?;
352
353        let report = purge_expired(u64::MAX, Some(&registry))?;
354
355        assert_eq!(report.purged_bytes, 99);
356        assert!(!entry.quarantine_path.exists());
357        Ok(())
358    }
359
360    #[test]
361    fn restore_should_refuse_occupied_original_path() -> Result<()> {
362        let temporary = tempdir()?;
363        let original = temporary.path().join("node_modules");
364        fs::create_dir_all(&original)?;
365        let registry = temporary.path().join("state/quarantine.json");
366        let entry = hold(
367            &original,
368            Category::NodeModules,
369            42,
370            Duration::from_secs(60),
371            Some(&registry),
372        )?;
373        fs::create_dir_all(&original)?;
374
375        let result = restore(&entry.id, Some(&registry));
376
377        assert!(result.is_err());
378        Ok(())
379    }
380}