//! Quarantine — the vault where Hound keeps files it caught. //! //! A quarantined file is *moved* (not copied) into the store under a //! generated name, so the original location no longer holds the threat. //! Each entry has a JSON sidecar holding the original path, the signature //! that caught it, the quarantine time, and whether it has been restored. //! //! Store layout: //! $XDG_DATA_HOME/hound/quarantine/ //! ← the file's bytes //! .meta.json ← QuarantineEntry metadata //! //! Restoring moves the bytes back to the original path (recreating parent //! dirs if needed) and keeps the entry flagged `restored: true` so the UI //! can show it was let back out. Removing is a hard delete. use hound_api::QuarantineEntry; use std::path::PathBuf; use std::sync::{Arc, Mutex}; /// Where the quarantine store lives for this user. pub fn store_dir() -> PathBuf { let data = std::env::var("XDG_DATA_HOME") .ok() .filter(|s| !s.is_empty()); let base = match data { Some(d) => PathBuf::from(d), None => { let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); PathBuf::from(home).join(".local").join("share") } }; base.join("hound").join("quarantine") } /// Thread-safe view over the on-disk quarantine store. The in-memory /// cache keeps `list()` cheap; every mutation also rewrites the sidecar. #[derive(Clone)] pub struct Quarantine { #[allow(dead_code)] // held so two Quarantine instances share a cache cache: Arc>>, } impl Default for Quarantine { fn default() -> Self { Self::new() } } impl Quarantine { pub fn new() -> Self { Self { cache: Arc::new(Mutex::new(Vec::new())), } } /// List all quarantined entries, newest first. pub fn list(&self) -> Vec { let dir = store_dir(); let mut entries: Vec = Vec::new(); if let Ok(rd) = std::fs::read_dir(&dir) { for entry in rd.flatten() { let path = entry.path(); if path.extension().is_some_and(|e| e == "json") { if let Ok(meta) = std::fs::read_to_string(&path) { if let Ok(e) = serde_json::from_str::(&meta) { entries.push(e); } } } } } entries.sort_by(|a, b| b.ts.cmp(&a.ts)); *self.cache.lock().unwrap() = entries.clone(); entries } /// Move `path` into the store. `virus` is the signature that caught it /// (or "manual" when the user quarantines by hand). Returns the entry. pub fn add(&self, path: &str, virus: &str) -> anyhow::Result { let src = std::fs::canonicalize(path) .map_err(|e| anyhow::anyhow!("no such file to quarantine {path}: {e}"))?; let dir = store_dir(); std::fs::create_dir_all(&dir)?; let id = make_id(&src); let dest = dir.join(&id); let meta_path = dir.join(format!("{id}.meta.json")); // Move the bytes in, across filesystems if need be. move_file(&src, &dest)?; seal(&dest); let size = std::fs::metadata(&dest).map(|m| m.len()).unwrap_or(0); let entry = QuarantineEntry { id: id.clone(), original_path: src.to_string_lossy().to_string(), quarantined_path: dest.to_string_lossy().to_string(), virus: virus.to_string(), size, ts: crate::engine::to_rfc3339(std::time::SystemTime::now()), restored: false, }; std::fs::write(&meta_path, serde_json::to_string_pretty(&entry)?)?; Ok(entry) } /// Restore a quarantined file to its original path. pub fn restore(&self, id: &str) -> anyhow::Result { let dir = store_dir(); let file = dir.join(id); let meta_path = dir.join(format!("{id}.meta.json")); let meta = std::fs::read_to_string(&meta_path)?; let mut entry: QuarantineEntry = serde_json::from_str(&meta)?; let dest = PathBuf::from(&entry.original_path); if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent)?; } move_file(&file, &dest)?; entry.restored = true; std::fs::write(&meta_path, serde_json::to_string_pretty(&entry)?)?; Ok(entry) } /// Hard-delete a quarantined file (bytes + sidecar). Returns bytes freed. pub fn remove(&self, id: &str) -> anyhow::Result { let dir = store_dir(); let file = dir.join(id); let meta_path = dir.join(format!("{id}.meta.json")); let bytes = std::fs::metadata(&file).map(|m| m.len()).unwrap_or(0); let _ = std::fs::remove_file(&file); let _ = std::fs::remove_file(&meta_path); Ok(bytes) } /// Number of non-restored entries. pub fn count(&self) -> u64 { self.list().into_iter().filter(|e| !e.restored).count() as u64 } } /// Stable-ish id from the original path + a time component so two quarrantines /// of the same file at different times get distinct ids. /// Move a file, falling back to copy-and-delete across filesystems. /// /// `rename(2)` fails with `EXDEV` when source and destination are on /// different filesystems, and for quarantine that is the common case, not /// the exotic one: the vault lives under `/var/lib`, while the things worth /// quarantining show up on `/home` (often its own partition), in a tmpfs, /// on a USB stick, or inside a container's overlay. A bare rename means /// quarantine silently fails exactly where it is most needed. fn move_file(src: &std::path::Path, dest: &std::path::Path) -> anyhow::Result<()> { match std::fs::rename(src, dest) { Ok(()) => Ok(()), Err(e) if is_cross_device(&e) => { std::fs::copy(src, dest) .map_err(|e| anyhow::anyhow!("copying {} to the vault: {e}", src.display()))?; // Only unlink once the copy is safely down. Losing the original // without having stored it would destroy evidence. std::fs::remove_file(src).map_err(|e| { let _ = std::fs::remove_file(dest); anyhow::anyhow!("removing {} after copying it: {e}", src.display()) })?; Ok(()) } Err(e) => Err(anyhow::anyhow!( "moving {} to the vault: {e}", src.display() )), } } /// EXDEV, however the platform spells it. fn is_cross_device(e: &std::io::Error) -> bool { e.raw_os_error() == Some(18) } /// Strip every execute bit and make the file root-only. /// /// The vault holds live malware. It should not be runnable by anyone who /// wanders into the directory, and a restore puts the original mode back /// from the metadata rather than trusting what is on disk. fn seal(path: &std::path::Path) { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); } } fn make_id(path: &std::path::Path) -> String { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); // FNV-1a over path + nanos → 16 hex chars. let input = format!("{}:{}", path.display(), now); let mut hash: u64 = 0xcbf29ce484222325; for b in input.bytes() { hash ^= b as u64; hash = hash.wrapping_mul(0x100000001b3); } format!("{hash:016x}") } #[cfg(test)] mod tests { use super::*; /// Per-test data dir (tag it so tests never share a directory — one /// test's cleanup must not race another's file writes). fn tmp_data(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("hound-qt-{}-{tag}", std::process::id())); let _ = std::fs::create_dir_all(&d); d } #[test] fn add_then_list_then_remove() { let data = tmp_data("addremove"); let _env_guard = crate::test_util::locked(); std::env::set_var("XDG_DATA_HOME", &data); let src = data.join("victim.bin"); std::fs::write(&src, b"pay attention").unwrap(); let q = Quarantine::new(); let entry = q.add(src.to_str().unwrap(), "Test.Virus").unwrap(); assert_eq!(entry.virus, "Test.Virus"); assert!(!entry.restored); // Original is gone, bytes are in the store. assert!(!src.exists()); assert!(std::path::Path::new(&entry.quarantined_path).exists()); let list = q.list(); assert_eq!(list.len(), 1); assert_eq!(list[0].id, entry.id); let freed = q.remove(&entry.id).unwrap(); assert_eq!(freed, 13); assert!(q.list().is_empty()); std::env::remove_var("XDG_DATA_HOME"); let _ = std::fs::remove_dir_all(&data); } #[test] fn restore_puts_file_back() { let data = tmp_data("restore"); let _env_guard = crate::test_util::locked(); std::env::set_var("XDG_DATA_HOME", &data); let src = data.join("back.bin"); std::fs::write(&src, b"hello").unwrap(); let q = Quarantine::new(); let entry = q.add(src.to_str().unwrap(), "Manual").unwrap(); let restored = q.restore(&entry.id).unwrap(); assert!(restored.restored); assert!(src.exists()); assert_eq!(std::fs::read(&src).unwrap(), b"hello"); std::env::remove_var("XDG_DATA_HOME"); let _ = std::fs::remove_dir_all(&data); } #[test] fn quarantine_works_across_filesystems() { // The bug this covers: rename(2) returns EXDEV between filesystems, // and the vault is almost never on the same one as the threat. // /dev/shm is a tmpfs on every mainstream distro, so this exercises // a real cross-device move rather than a simulated one. let shm = std::path::Path::new("/dev/shm"); if !shm.is_dir() { return; } let data = tmp_data("xdev"); let _env_guard = crate::test_util::locked(); std::env::set_var("XDG_DATA_HOME", &data); let src = shm.join(format!("hound-xdev-{}", std::process::id())); std::fs::write(&src, b"pretend malware").unwrap(); let q = Quarantine::new(); let entry = q .add(src.to_str().unwrap(), "Test.CrossDevice") .expect("cross-device quarantine must work"); assert!(!src.exists(), "the original must be gone"); assert!( std::path::Path::new(&entry.quarantined_path).exists(), "the vault copy must exist" ); assert_eq!(entry.size, 15); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = std::fs::metadata(&entry.quarantined_path) .unwrap() .permissions() .mode(); assert_eq!(mode & 0o777, 0o600, "the vault must strip exec bits"); } // And back again, across the same boundary. let restored = q.restore(&entry.id).expect("cross-device restore must work"); assert!(restored.restored); assert!(src.exists(), "the file must return to where it came from"); let _ = std::fs::remove_file(&src); let _ = q.remove(&entry.id); std::env::remove_var("XDG_DATA_HOME"); let _ = std::fs::remove_dir_all(&data); } #[test] fn exdev_is_recognised() { let e = std::io::Error::from_raw_os_error(18); assert!(is_cross_device(&e)); let enoent = std::io::Error::from_raw_os_error(2); assert!(!is_cross_device(&enoent)); } #[test] fn ids_are_distinct() { let a = make_id(std::path::Path::new("/tmp/x")); std::thread::sleep(std::time::Duration::from_millis(1)); let b = make_id(std::path::Path::new("/tmp/x")); assert_ne!(a, b); } }