use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use chrono::Utc;
use thiserror::Error;
use crate::manifest::append::append_event;
use crate::manifest::event::Event;
#[derive(Debug, Clone)]
pub struct QuarantineConfig {
pub trash_root: PathBuf,
pub audit_log: PathBuf,
}
#[derive(Debug, Clone)]
pub struct QuarantineResult {
pub snapshot_path: PathBuf,
pub timestamp: String,
}
#[derive(Debug, Error)]
pub enum QuarantineError {
#[error("quarantine: failed to fsync audit-log entry: {0}")]
AuditCommit(#[source] io::Error),
#[error("quarantine: recursive snapshot to {trash} failed: {source}")]
Snapshot {
trash: PathBuf,
#[source]
source: io::Error,
},
#[error("quarantine: snapshot ok but unlink of {dest} failed: {source}")]
Unlink {
dest: PathBuf,
#[source]
source: io::Error,
},
}
const TIMESTAMP_COLLISION_RETRY_CAP: usize = 16;
fn iso8601_utc_now() -> String {
Utc::now().format("%Y-%m-%dT%H-%M-%S%.3fZ").to_string()
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
let meta = fs::symlink_metadata(src)?;
let ft = meta.file_type();
if ft.is_symlink() {
copy_symlink(src, dst)?;
return Ok(());
}
if ft.is_dir() {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let child_src = entry.path();
let child_dst = dst.join(entry.file_name());
copy_dir_recursive(&child_src, &child_dst)?;
}
return Ok(());
}
if ft.is_file() {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(src, dst)?;
return Ok(());
}
tracing::warn!(
path = %src.display(),
"quarantine: skipping non-regular, non-symlink, non-directory entry"
);
Ok(())
}
fn copy_symlink(src: &Path, dst: &Path) -> io::Result<()> {
let target = fs::read_link(src)?;
#[cfg(unix)]
{
std::os::unix::fs::symlink(&target, dst)
}
#[cfg(windows)]
{
let target_meta = fs::metadata(src);
match target_meta {
Ok(m) if m.is_dir() => std::os::windows::fs::symlink_dir(&target, dst),
_ => std::os::windows::fs::symlink_file(&target, dst),
}
}
#[cfg(not(any(unix, windows)))]
{
Err(io::Error::new(
io::ErrorKind::Unsupported,
"quarantine: symlink replication not supported on this platform",
))
}
}
fn resolve_unique_slot(
trash_root: &Path,
ts_base: &str,
basename: &Path,
) -> io::Result<(PathBuf, String)> {
let bare = trash_root.join(ts_base).join(basename);
if !bare.try_exists()? {
return Ok((bare, ts_base.to_string()));
}
for n in 1..=TIMESTAMP_COLLISION_RETRY_CAP {
let ts_suffixed = format!("{ts_base}-{n}");
let candidate = trash_root.join(&ts_suffixed).join(basename);
if !candidate.try_exists()? {
return Ok((candidate, ts_suffixed));
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"quarantine: failed to find a unique trash slot beneath {} after {TIMESTAMP_COLLISION_RETRY_CAP} retries",
trash_root.display()
),
))
}
fn summarise_err(e: &io::Error) -> String {
let s = e.to_string();
if s.len() <= crate::manifest::ACTION_ERROR_SUMMARY_MAX {
s
} else {
let mut t = s;
t.truncate(crate::manifest::ACTION_ERROR_SUMMARY_MAX);
t
}
}
fn append_failure_event(cfg: &QuarantineConfig, src: &Path, trash: &Path, err_summary: String) {
let event = Event::QuarantineFailed {
ts: Utc::now(),
src: src.display().to_string(),
trash: trash.display().to_string(),
error: err_summary,
};
if let Err(e) = append_event(&cfg.audit_log, &event) {
tracing::warn!(
audit_log = %cfg.audit_log.display(),
error = %e,
"failed to append QuarantineFailed event; original failure still surfaced",
);
}
}
fn append_complete_event(cfg: &QuarantineConfig, src: &Path, trash: &Path) {
let event = Event::QuarantineComplete {
ts: Utc::now(),
src: src.display().to_string(),
trash: trash.display().to_string(),
};
if let Err(e) = append_event(&cfg.audit_log, &event) {
tracing::warn!(
audit_log = %cfg.audit_log.display(),
error = %e,
"failed to append QuarantineComplete event; prune already succeeded",
);
}
}
pub fn snapshot_then_rm(
dest: &Path,
cfg: &QuarantineConfig,
) -> Result<QuarantineResult, QuarantineError> {
let basename: PathBuf =
dest.file_name().map(PathBuf::from).ok_or_else(|| QuarantineError::Unlink {
dest: dest.to_path_buf(),
source: io::Error::new(
io::ErrorKind::InvalidInput,
"quarantine: dest has no file_name component",
),
})?;
let ts_base = iso8601_utc_now();
if let Err(e) = fs::create_dir_all(&cfg.trash_root) {
return Err(QuarantineError::Snapshot { trash: cfg.trash_root.clone(), source: e });
}
let (snapshot_path, timestamp) = match resolve_unique_slot(&cfg.trash_root, &ts_base, &basename)
{
Ok(pair) => pair,
Err(e) => {
return Err(QuarantineError::Snapshot { trash: cfg.trash_root.clone(), source: e });
}
};
let start_event = Event::QuarantineStart {
ts: Utc::now(),
src: dest.display().to_string(),
trash: snapshot_path.display().to_string(),
};
if let Err(e) = append_event(&cfg.audit_log, &start_event) {
let io_err = match e {
crate::manifest::ManifestError::Io(io) => io,
other => io::Error::other(other.to_string()),
};
return Err(QuarantineError::AuditCommit(io_err));
}
if let Some(parent) = snapshot_path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
append_failure_event(cfg, dest, &snapshot_path, summarise_err(&e));
return Err(QuarantineError::Snapshot { trash: snapshot_path, source: e });
}
}
if let Err(e) = copy_dir_recursive(dest, &snapshot_path) {
append_failure_event(cfg, dest, &snapshot_path, summarise_err(&e));
return Err(QuarantineError::Snapshot { trash: snapshot_path, source: e });
}
if let Err(e) = fs::remove_dir_all(dest) {
append_failure_event(cfg, dest, &snapshot_path, summarise_err(&e));
return Err(QuarantineError::Unlink { dest: dest.to_path_buf(), source: e });
}
append_complete_event(cfg, dest, &snapshot_path);
Ok(QuarantineResult { snapshot_path, timestamp })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::append::read_all;
use std::fs;
use tempfile::tempdir;
fn setup_meta_and_cfg(tmp: &Path) -> (PathBuf, QuarantineConfig) {
let meta = tmp.join("meta");
fs::create_dir_all(meta.join(".grex")).unwrap();
let cfg = QuarantineConfig {
trash_root: meta.join(".grex").join("trash"),
audit_log: meta.join(".grex").join("events.jsonl"),
};
(meta, cfg)
}
fn populate_three_file_dest(meta: &Path, name: &str) -> PathBuf {
let dest = meta.join(name);
fs::create_dir_all(&dest).unwrap();
fs::write(dest.join("a.txt"), b"alpha").unwrap();
fs::write(dest.join("b.txt"), b"beta").unwrap();
fs::write(dest.join("c.bin"), [0u8, 1, 2, 3, 255]).unwrap();
dest
}
#[test]
fn iso8601_utc_now_uses_path_safe_format() {
let s = iso8601_utc_now();
assert!(s.ends_with('Z'), "ts must end in Z: {s}");
assert!(!s.contains(':'), "ts must not contain `:`: {s}");
assert_eq!(s.len(), 24, "ts must be exactly 24 chars: {s} (len={})", s.len());
}
#[test]
fn snapshot_then_rm_creates_snapshot_then_unlinks_dest() {
let tmp = tempdir().unwrap();
let (meta, cfg) = setup_meta_and_cfg(tmp.path());
let dest = populate_three_file_dest(&meta, "victim");
let result = snapshot_then_rm(&dest, &cfg).expect("quarantine pipeline succeeds");
assert!(!dest.exists(), "dest must be unlinked after successful pipeline");
assert!(result.snapshot_path.exists(), "snapshot path must exist");
assert_eq!(fs::read(result.snapshot_path.join("a.txt")).unwrap(), b"alpha",);
assert_eq!(fs::read(result.snapshot_path.join("b.txt")).unwrap(), b"beta");
assert_eq!(fs::read(result.snapshot_path.join("c.bin")).unwrap(), vec![0u8, 1, 2, 3, 255],);
assert!(result.snapshot_path.starts_with(&cfg.trash_root));
assert_eq!(result.snapshot_path.file_name().unwrap(), std::ffi::OsStr::new("victim"),);
}
#[test]
fn snapshot_then_rm_writes_start_and_complete_events_in_order() {
let tmp = tempdir().unwrap();
let (meta, cfg) = setup_meta_and_cfg(tmp.path());
let dest = populate_three_file_dest(&meta, "audit-target");
let result = snapshot_then_rm(&dest, &cfg).expect("quarantine pipeline succeeds");
let events = read_all(&cfg.audit_log).expect("audit log readable");
assert_eq!(events.len(), 2, "Start + Complete only on success: {events:?}");
match &events[0] {
Event::QuarantineStart { src, trash, .. } => {
assert_eq!(src, &dest.display().to_string());
assert_eq!(trash, &result.snapshot_path.display().to_string());
}
other => panic!("expected QuarantineStart first, got {other:?}"),
}
match &events[1] {
Event::QuarantineComplete { src, trash, .. } => {
assert_eq!(src, &dest.display().to_string());
assert_eq!(trash, &result.snapshot_path.display().to_string());
}
other => panic!("expected QuarantineComplete second, got {other:?}"),
}
}
#[test]
fn snapshot_then_rm_recursive_preserves_nested_subtree() {
let tmp = tempdir().unwrap();
let (meta, cfg) = setup_meta_and_cfg(tmp.path());
let dest = meta.join("nested");
fs::create_dir_all(dest.join("a/b/c")).unwrap();
fs::write(dest.join("top.txt"), b"top").unwrap();
fs::write(dest.join("a/level-1.txt"), b"l1").unwrap();
fs::write(dest.join("a/b/level-2.txt"), b"l2").unwrap();
fs::write(dest.join("a/b/c/leaf.txt"), b"leaf").unwrap();
let result = snapshot_then_rm(&dest, &cfg).expect("quarantine succeeds");
assert!(!dest.exists());
assert_eq!(fs::read(result.snapshot_path.join("top.txt")).unwrap(), b"top");
assert_eq!(fs::read(result.snapshot_path.join("a/level-1.txt")).unwrap(), b"l1");
assert_eq!(fs::read(result.snapshot_path.join("a/b/level-2.txt")).unwrap(), b"l2");
assert_eq!(fs::read(result.snapshot_path.join("a/b/c/leaf.txt")).unwrap(), b"leaf");
}
#[test]
fn snapshot_then_rm_preserves_symlinks_as_symlinks() {
let tmp = tempdir().unwrap();
let (meta, cfg) = setup_meta_and_cfg(tmp.path());
let dest = meta.join("with-symlink");
fs::create_dir_all(&dest).unwrap();
let real_target = meta.join("real-file.txt");
fs::write(&real_target, b"real").unwrap();
let link = dest.join("link-to-real");
#[cfg(unix)]
let link_result = std::os::unix::fs::symlink(&real_target, &link);
#[cfg(windows)]
let link_result = std::os::windows::fs::symlink_file(&real_target, &link);
if link_result.is_err() {
return;
}
let result = snapshot_then_rm(&dest, &cfg).expect("quarantine succeeds");
let snapshot_link = result.snapshot_path.join("link-to-real");
let meta_link = fs::symlink_metadata(&snapshot_link)
.expect("snapshot link must exist as a symlink-or-file");
assert!(
meta_link.file_type().is_symlink(),
"snapshot must preserve symlink (not deref to file)"
);
}
#[test]
fn snapshot_failure_aborts_unlink_and_leaves_dest_intact() {
let tmp = tempdir().unwrap();
let (meta, cfg) = setup_meta_and_cfg(tmp.path());
let dest = populate_three_file_dest(&meta, "intact-victim");
let trash_root_as_file = cfg.trash_root.clone();
fs::create_dir_all(trash_root_as_file.parent().unwrap()).unwrap();
fs::write(&trash_root_as_file, b"i am a file, not a directory").unwrap();
let res = snapshot_then_rm(&dest, &cfg);
assert!(matches!(res, Err(QuarantineError::Snapshot { .. })), "got {res:?}");
assert!(dest.exists(), "dest MUST remain after snapshot failure");
assert_eq!(fs::read(dest.join("a.txt")).unwrap(), b"alpha");
let events = read_all(&cfg.audit_log).unwrap_or_default();
assert!(
!events.iter().any(|e| matches!(e, Event::QuarantineComplete { .. })),
"no QuarantineComplete may appear when snapshot failed: {events:?}"
);
}
#[test]
fn resolve_unique_slot_handles_timestamp_collision_with_suffix() {
let tmp = tempdir().unwrap();
let trash_root = tmp.path().join("trash");
fs::create_dir_all(&trash_root).unwrap();
let basename = PathBuf::from("collide");
let ts = "2026-04-30T14-23-45.123Z";
let (path_a, ts_a) = resolve_unique_slot(&trash_root, ts, &basename).unwrap();
assert_eq!(ts_a, ts);
fs::create_dir_all(&path_a).unwrap();
let (path_b, ts_b) = resolve_unique_slot(&trash_root, ts, &basename).unwrap();
assert_ne!(path_a, path_b, "collision must yield distinct path");
assert!(ts_b.starts_with(ts), "suffixed ts retains base: {ts_b}");
assert!(ts_b.ends_with("-1"), "first suffix is -1: {ts_b}");
}
#[test]
fn snapshot_then_rm_creates_audit_log_on_first_use() {
let tmp = tempdir().unwrap();
let (meta, cfg) = setup_meta_and_cfg(tmp.path());
let dest = populate_three_file_dest(&meta, "first-use");
assert!(!cfg.audit_log.exists());
let _result = snapshot_then_rm(&dest, &cfg).expect("quarantine succeeds");
assert!(cfg.audit_log.exists(), "audit log materialised by append_event");
let events = read_all(&cfg.audit_log).expect("audit log readable");
assert!(matches!(events.first(), Some(Event::QuarantineStart { .. })));
}
}