1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct QuarantineEntry {
24 pub id: String,
26 pub original_path: PathBuf,
28 pub quarantine_path: PathBuf,
30 pub category: Category,
32 pub bytes: u64,
34 pub created_at_unix: u64,
36 pub expires_at_unix: u64,
38}
39
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
42pub struct PurgeReport {
43 pub purged: Vec<QuarantineEntry>,
45 pub failures: Vec<String>,
47 pub purged_bytes: u64,
49}
50
51#[derive(Debug, Default, Serialize, Deserialize)]
52struct QuarantineStore {
53 version: u32,
54 entries: Vec<QuarantineEntry>,
55}
56
57pub 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
67pub 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(®istry, |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
128pub 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(®istry, |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
146pub 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(®istry, |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
179pub 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(®istry, |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(®istry),
331 )?;
332 let restored = restore(&entry.id, Some(®istry))?;
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(®istry),
351 )?;
352
353 let report = purge_expired(u64::MAX, Some(®istry))?;
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(®istry),
372 )?;
373 fs::create_dir_all(&original)?;
374
375 let result = restore(&entry.id, Some(®istry));
376
377 assert!(result.is_err());
378 Ok(())
379 }
380}