use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use chrono::{DateTime, NaiveDateTime, Utc};
use thiserror::Error;
use crate::manifest::append::append_event;
use crate::manifest::event::Event;
pub const DEFAULT_RETAIN_DAYS: u32 = 90;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetentionConfig {
pub retain_days: u32,
}
impl Default for RetentionConfig {
fn default() -> Self {
Self { retain_days: DEFAULT_RETAIN_DAYS }
}
}
#[derive(Debug, Default, Clone)]
pub struct PruneReport {
pub pruned: Vec<PathBuf>,
pub retained: Vec<PathBuf>,
pub failed: Vec<(PathBuf, String)>,
}
#[derive(Debug, Clone)]
pub struct RestoreReport {
pub dest: PathBuf,
}
#[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)]
#[non_exhaustive]
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,
},
#[error("quarantine: snapshot {ts} not found under trash bucket")]
SnapshotNotFound {
ts: String,
},
#[error("quarantine: restore ambiguous; {count} entries under <ts>/, specify basename")]
AmbiguousRestore {
count: usize,
},
#[error("quarantine: dest {dest} already exists; pass --force to replace")]
DestExists {
dest: PathBuf,
},
#[error("quarantine: restore move from {src} to {dest} failed: {source}")]
RestoreFailed {
src: PathBuf,
dest: PathBuf,
#[source]
source: io::Error,
},
#[error("quarantine: GC sweep failed to enumerate {trash}: {source}")]
GcFailed {
trash: PathBuf,
#[source]
source: io::Error,
},
#[error("quarantine: failed to parse ISO8601 timestamp from {name}")]
TimestampParseFailed {
name: String,
},
}
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 safe_remove_dir_all(path: &Path) -> io::Result<()> {
let meta = match fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
let ft = meta.file_type();
if ft.is_symlink() {
match fs::remove_file(path) {
Ok(()) => return Ok(()),
Err(_) => return fs::remove_dir(path),
}
}
if ft.is_dir() {
for entry in fs::read_dir(path)? {
let entry = entry?;
let child = entry.path();
safe_remove_dir_all(&child)?;
}
return fs::remove_dir(path);
}
fs::remove_file(path)
}
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 })
}
pub fn parse_iso8601_quarantine(name: &str) -> Option<SystemTime> {
let body = name.strip_suffix(|c: char| c.is_ascii_digit()).unwrap_or(name);
let candidate = if let Some(idx) = body.rfind('Z') { &body[..=idx] } else { name };
let parsed = NaiveDateTime::parse_from_str(candidate, "%Y-%m-%dT%H-%M-%S%.3fZ").ok()?;
let dt = DateTime::<Utc>::from_naive_utc_and_offset(parsed, Utc);
Some(SystemTime::from(dt))
}
#[allow(clippy::too_many_lines)]
pub fn prune_quarantine(
meta_dir: &Path,
retain: RetentionConfig,
audit_log: Option<&Path>,
) -> Result<PruneReport, QuarantineError> {
if retain.retain_days == 0 {
return Ok(PruneReport::default());
}
let trash_root = meta_dir.join(".grex").join("trash");
if !trash_root.is_dir() {
return Ok(PruneReport::default());
}
let cutoff = SystemTime::now()
.checked_sub(Duration::from_secs(u64::from(retain.retain_days) * 86_400))
.unwrap_or(SystemTime::UNIX_EPOCH);
let entries = std::fs::read_dir(&trash_root)
.map_err(|source| QuarantineError::GcFailed { trash: trash_root.clone(), source })?;
let mut report = PruneReport::default();
for entry in entries {
let Ok(entry) = entry else { continue };
if entry.file_type().map(|t| !t.is_dir()).unwrap_or(true) {
continue;
}
let path = entry.path();
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
tracing::warn!(?name, "quarantine GC: non-UTF8 entry name; skipping");
continue;
};
let Some(ts) = parse_iso8601_quarantine(name_str) else {
tracing::warn!(name = name_str, "quarantine GC: non-quarantine entry; skipping");
continue;
};
if ts < cutoff {
match std::fs::remove_dir_all(&path) {
Ok(()) => {
let age_days = SystemTime::now()
.duration_since(ts)
.map(|d| d.as_secs() / 86_400)
.unwrap_or(0);
if let Some(log) = audit_log {
let event = Event::QuarantineGcSwept {
ts: Utc::now(),
entry: path.display().to_string(),
age_days,
};
if let Err(e) = append_event(log, &event) {
tracing::warn!(
audit_log = %log.display(),
error = %e,
"failed to append QuarantineGcSwept event; sweep already succeeded",
);
}
}
report.pruned.push(path);
}
Err(e) => {
tracing::warn!(?path, error = %e, "quarantine GC: prune failed");
report.failed.push((path, e.to_string()));
}
}
} else {
report.retained.push(path);
}
}
Ok(report)
}
#[allow(clippy::too_many_lines)]
pub fn restore_quarantine(
meta_dir: &Path,
ts: &str,
basename: Option<&str>,
force: bool,
audit_log: Option<&Path>,
) -> Result<RestoreReport, QuarantineError> {
let trash_dir = meta_dir.join(".grex").join("trash").join(ts);
if !trash_dir.is_dir() {
return Err(QuarantineError::SnapshotNotFound { ts: ts.to_owned() });
}
let basename_owned = match basename {
Some(b) => b.to_owned(),
None => {
let entries: Vec<_> = std::fs::read_dir(&trash_dir)
.map_err(|source| QuarantineError::GcFailed { trash: trash_dir.clone(), source })?
.filter_map(Result::ok)
.collect();
if entries.len() != 1 {
return Err(QuarantineError::AmbiguousRestore { count: entries.len() });
}
entries[0].file_name().to_string_lossy().into_owned()
}
};
let src = trash_dir.join(&basename_owned);
let dest = meta_dir.join(&basename_owned);
if dest.exists() {
if !force {
return Err(QuarantineError::DestExists { dest });
}
if let Err(e) = safe_remove_dir_all(&dest) {
return Err(QuarantineError::RestoreFailed {
src: src.clone(),
dest: dest.clone(),
source: e,
});
}
}
if let Err(rename_err) = std::fs::rename(&src, &dest) {
if let Err(copy_err) = copy_dir_recursive(&src, &dest) {
return Err(QuarantineError::RestoreFailed {
src: src.clone(),
dest: dest.clone(),
source: copy_err,
});
}
if let Err(unlink_err) = safe_remove_dir_all(&src) {
tracing::warn!(
?src,
rename_error = %rename_err,
unlink_error = %unlink_err,
"quarantine restore: copy succeeded but snapshot unlink failed",
);
}
}
if let Some(log) = audit_log {
let event = Event::QuarantineRestored {
ts: Utc::now(),
src: src.display().to_string(),
dest: dest.display().to_string(),
};
if let Err(e) = append_event(log, &event) {
tracing::warn!(
audit_log = %log.display(),
error = %e,
"failed to append QuarantineRestored event; restore already succeeded",
);
}
}
Ok(RestoreReport { dest })
}
#[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 { .. })));
}
}