use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::{
check_safe_to_delete_guard, copy_tree, expand_tilde, human_bytes, load_system_policy,
unique_backup_path, walk_stats, walk_stats_strict, QuarantineCommands,
};
const MANIFEST_NAME: &str = ".quarantine.json";
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct QuarantineManifest {
pub(crate) name: String,
pub(crate) origin: String,
pub(crate) moved_at_unix: u64,
pub(crate) bytes: u64,
pub(crate) files: u64,
#[serde(default)]
pub(crate) auto: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct QuarantineEntry {
pub(crate) name: String,
pub(crate) origin: String,
pub(crate) moved_at_unix: Option<u64>,
pub(crate) age_days: Option<u64>,
pub(crate) bytes: u64,
pub(crate) expired: bool,
pub(crate) pinned: bool,
pub(crate) pin_reason: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct QuarantineList {
pub(crate) root: String,
pub(crate) ttl_days: u64,
pub(crate) entries: Vec<QuarantineEntry>,
pub(crate) total_bytes: u64,
pub(crate) expired_bytes: u64,
pub(crate) pinned: Vec<String>,
pub(crate) pinned_bytes: u64,
}
pub(crate) fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(crate) fn quarantine_first_remove(
origin: &Path,
root: &Path,
user_protected: &[String],
) -> Result<(QuarantineManifest, u64)> {
let origin_dev = fs::metadata(origin).ok().map(|m| {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
m.dev()
}
#[cfg(not(unix))]
{
let _ = m;
0
}
});
let manifest = quarantine_move_inner(origin, root, user_protected, true)?;
let replaced = replace_older_auto_generations(root, &manifest);
for name in &replaced {
eprintln!(
"quarantine: replaced older auto generation {name} (origin {})",
manifest.origin
);
}
#[cfg(unix)]
let same_dev = {
use std::os::unix::fs::MetadataExt;
fs::metadata(root)
.ok()
.is_some_and(|m| Some(m.dev()) == origin_dev)
};
#[cfg(not(unix))]
let same_dev = false;
let freed = if same_dev || origin_dev.is_none() {
0
} else {
manifest.bytes
};
Ok((manifest, freed))
}
pub(crate) fn quarantine_root(guard: &crate::GuardPolicy) -> PathBuf {
let raw = guard.quarantine_dir.trim();
if raw.is_empty() {
expand_tilde(&crate::default_quarantine_dir())
} else {
expand_tilde(raw)
}
}
fn read_manifest(entry_dir: &Path) -> Option<QuarantineManifest> {
let text = fs::read_to_string(entry_dir.join(MANIFEST_NAME)).ok()?;
serde_json::from_str(&text).ok()
}
fn entry_dir_for(root: &Path, origin: &Path) -> PathBuf {
let base = origin
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "entry".to_string());
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
unique_backup_path(root, &format!("{base}.{ts}"))
}
pub(crate) fn quarantine_move(
origin: &Path,
root: &Path,
user_protected: &[String],
) -> Result<QuarantineManifest> {
quarantine_move_inner(origin, root, user_protected, false)
}
fn quarantine_move_inner(
origin: &Path,
root: &Path,
user_protected: &[String],
auto: bool,
) -> Result<QuarantineManifest> {
let meta = fs::symlink_metadata(origin)
.map_err(|e| anyhow::anyhow!("cannot inspect {}: {}", origin.display(), e))?;
if meta.file_type().is_symlink() {
anyhow::bail!("refusing to quarantine symlink {}", origin.display());
}
if !meta.is_dir() {
anyhow::bail!("quarantine supports directories only: {}", origin.display());
}
let canon_origin = check_safe_to_delete_guard(origin, user_protected)?;
match fs::symlink_metadata(canon_origin.join(MANIFEST_NAME)) {
Ok(_) => anyhow::bail!(
"refusing to quarantine {}: reserved metadata name {} already exists; source untouched",
canon_origin.display(),
MANIFEST_NAME
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
fs::create_dir_all(root)?;
let canon_root = root.canonicalize()?;
if canon_origin.starts_with(&canon_root) || canon_root.starts_with(&canon_origin) {
anyhow::bail!("origin and quarantine root nest — refusing");
}
let (files, bytes) = walk_stats_strict(&canon_origin)?;
let entry_dir = entry_dir_for(&canon_root, &canon_origin);
let name = entry_dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
match fs::rename(&canon_origin, &entry_dir) {
Ok(()) => {}
Err(_) => {
let skipped = copy_tree(&canon_origin, &entry_dir).inspect_err(|_| {
let _ = fs::remove_dir_all(&entry_dir);
})?;
if skipped > 0 {
eprintln!(
"quarantine move: skipped {} special files (sockets, fifos) — not preserved",
skipped
);
}
let (got_files, got_bytes) = match walk_stats_strict(&entry_dir) {
Ok(stats) => stats,
Err(verify_err) => {
let _ = fs::remove_dir_all(&entry_dir);
anyhow::bail!(
"copy verification could not read the copied entry ({verify_err:#}) — \
entry directory removed, origin untouched"
);
}
};
if got_files != files || got_bytes != bytes {
let _ = fs::remove_dir_all(&entry_dir);
anyhow::bail!("copy verification failed — origin untouched");
}
fs::remove_dir_all(&canon_origin)?;
}
}
let manifest = QuarantineManifest {
name,
origin: canon_origin.display().to_string(),
moved_at_unix: now_unix(),
bytes,
files,
auto,
};
let manifest_text = serde_json::to_string_pretty(&manifest)?;
let publish_manifest = || -> Result<()> {
use std::io::Write;
let mut staged = tempfile::NamedTempFile::new_in(&canon_root)?;
staged.write_all(manifest_text.as_bytes())?;
staged.as_file().sync_all()?;
staged.persist_noclobber(entry_dir.join(MANIFEST_NAME))?;
Ok(())
};
publish_manifest().map_err(|e| {
if fs::rename(&entry_dir, &canon_origin).is_ok() {
anyhow::anyhow!("quarantine manifest write failed — moved back to origin: {e}")
} else {
anyhow::anyhow!(
"quarantine manifest write failed AND move-back failed: data is at {} with no manifest, origin {} is gone",
entry_dir.display(),
canon_origin.display()
)
}
})?;
Ok(manifest)
}
pub(crate) fn replace_older_auto_generations(root: &Path, new: &QuarantineManifest) -> Vec<String> {
let mut replaced = Vec::new();
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(e) => {
eprintln!(
"quarantine: cannot scan {} for older generations: {e} — keeping everything",
root.display()
);
return replaced;
}
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name == new.name {
continue;
}
let entry_dir = entry.path();
let Some(manifest) = read_manifest(&entry_dir) else {
continue;
};
if !manifest.auto || manifest.origin != new.origin {
continue;
}
let dir = match resolve_entry_dir(root, &name) {
Ok(dir) => dir,
Err(e) => {
eprintln!("quarantine: skipping unresolvable older generation {name}: {e:#}");
continue;
}
};
match fs::remove_dir_all(&dir) {
Ok(()) => replaced.push(name),
Err(e) => eprintln!(
"quarantine: could not remove older auto generation {name}: {e} — TTL expiry remains the backstop"
),
}
}
replaced.sort();
replaced
}
pub(crate) const PIN_REASON: &str =
"manifest unreadable — cannot age out or restore; clear with `quarantine purge <name> --apply`";
pub(crate) fn quarantine_list(root: &Path, ttl_days: u64) -> Result<QuarantineList> {
let mut entries = Vec::new();
let mut total_bytes = 0u64;
let mut expired_bytes = 0u64;
let mut pinned = Vec::new();
let mut pinned_bytes = 0u64;
if fs::symlink_metadata(root).is_ok() {
let canon_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
for dir in fs::read_dir(&canon_root)?.flatten() {
let path = dir.path();
if !path.is_dir() {
continue;
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let (_, bytes) = walk_stats(&path);
let manifest = read_manifest(&path);
let now = now_unix();
let (origin, moved_at, age_days, expired) = match &manifest {
Some(m) => {
let age = now.saturating_sub(m.moved_at_unix) / 86_400;
let exp = ttl_days > 0 && age > ttl_days;
(m.origin.clone(), Some(m.moved_at_unix), Some(age), exp)
}
None => ("unknown".to_string(), None, None, false),
};
let is_pinned = manifest.is_none();
total_bytes += bytes;
if expired {
expired_bytes += bytes;
}
if is_pinned {
pinned.push(name.clone());
pinned_bytes += bytes;
}
entries.push(QuarantineEntry {
name,
origin,
moved_at_unix: moved_at,
age_days,
bytes,
expired,
pinned: is_pinned,
pin_reason: is_pinned.then(|| PIN_REASON.to_string()),
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
pinned.sort();
}
Ok(QuarantineList {
root: root.display().to_string(),
ttl_days,
entries,
total_bytes,
expired_bytes,
pinned,
pinned_bytes,
})
}
pub(crate) fn resolve_entry_dir(root: &Path, name: &str) -> Result<PathBuf> {
if name.is_empty()
|| name == "."
|| name == ".."
|| name.contains('/')
|| name.contains('\\')
|| name.contains('\0')
{
anyhow::bail!("invalid quarantine entry name: {name}");
}
let canon_root = root.canonicalize().map_err(|e| {
anyhow::anyhow!(
"cannot canonicalize quarantine root {}: {}",
root.display(),
e
)
})?;
let entry_dir = canon_root.join(name);
let meta = fs::symlink_metadata(&entry_dir)
.map_err(|_| anyhow::anyhow!("no such quarantine entry: {name}"))?;
if !meta.file_type().is_dir() {
if meta.file_type().is_symlink() {
anyhow::bail!("refusing to operate on symlink entry {name}");
}
anyhow::bail!("no such quarantine entry: {name}");
}
Ok(entry_dir)
}
pub(crate) fn quarantine_purge(
root: &Path,
name: &str,
apply: bool,
) -> Result<(String, u64, bool)> {
let entry_dir = resolve_entry_dir(root, name)?;
let (files, bytes) = walk_stats(&entry_dir);
let pinned = read_manifest(&entry_dir).is_none();
if apply {
fs::remove_dir_all(&entry_dir)?;
}
Ok((
format!("{files} files, {}", human_bytes(bytes)),
bytes,
pinned,
))
}
pub(crate) fn quarantine_restore(root: &Path, name: &str) -> Result<PathBuf> {
let entry_dir = resolve_entry_dir(root, name)?;
let manifest = read_manifest(&entry_dir)
.ok_or_else(|| anyhow::anyhow!("entry {name} has no manifest — refusing blind restore"))?;
let origin = PathBuf::from(&manifest.origin);
if fs::symlink_metadata(&origin).is_ok() {
anyhow::bail!("origin {} already exists — refusing", origin.display());
}
if let Some(parent) = origin.parent() {
fs::create_dir_all(parent)?;
}
match fs::rename(&entry_dir, &origin) {
Ok(()) => {
let _ = fs::remove_file(origin.join(MANIFEST_NAME));
Ok(origin)
}
Err(_) => {
copy_tree(&entry_dir, &origin).inspect_err(|_| {
let _ = fs::remove_dir_all(&origin);
})?;
let _ = fs::remove_file(origin.join(MANIFEST_NAME));
let (got_files, got_bytes) = walk_stats_strict(&origin)?;
if got_files != manifest.files || got_bytes != manifest.bytes {
let _ = fs::remove_dir_all(&origin);
anyhow::bail!("restore verification failed — quarantine entry kept");
}
fs::remove_dir_all(&entry_dir)?;
Ok(origin)
}
}
}
#[derive(Debug)]
pub(crate) struct ExpiredEntry {
pub(crate) name: String,
pub(crate) origin: String,
pub(crate) bytes: u64,
}
pub(crate) struct ExpireOutcome {
pub(crate) removed: Vec<ExpiredEntry>,
pub(crate) pinned: Vec<String>,
pub(crate) pinned_bytes: u64,
}
pub(crate) fn quarantine_expire_detailed(
root: &Path,
ttl_days: u64,
apply: bool,
) -> Result<ExpireOutcome> {
let list = quarantine_list(root, ttl_days)?;
let canon_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let mut removed = Vec::new();
for entry in list.entries.iter().filter(|e| e.expired) {
let dir = canon_root.join(&entry.name);
let Ok(canon) = dir.canonicalize() else {
eprintln!("quarantine expire: entry {} vanished, skipping", entry.name);
continue;
};
if canon.parent() != Some(canon_root.as_path()) {
eprintln!(
"quarantine expire: entry {} escaped quarantine root — refusing it, continuing batch",
entry.name
);
continue;
}
if apply {
fs::remove_dir_all(&canon)?;
}
removed.push(ExpiredEntry {
name: entry.name.clone(),
origin: entry.origin.clone(),
bytes: entry.bytes,
});
}
Ok(ExpireOutcome {
removed,
pinned: list.pinned,
pinned_bytes: list.pinned_bytes,
})
}
pub(crate) fn quarantine_expire(root: &Path, ttl_days: u64, apply: bool) -> Result<Vec<String>> {
Ok(quarantine_expire_detailed(root, ttl_days, apply)?
.removed
.into_iter()
.map(|e| e.name)
.collect())
}
pub(crate) fn cmd_quarantine(cmd: QuarantineCommands) -> Result<()> {
use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, ContentArrangement, Table};
let (_, policy) = load_system_policy()?;
let root = quarantine_root(&policy.guard);
let ttl = policy.guard.quarantine_ttl_days;
match cmd {
QuarantineCommands::Move { path, apply, json } => {
if !apply {
let meta = fs::symlink_metadata(&path)
.map_err(|e| anyhow::anyhow!("cannot inspect {}: {}", path.display(), e))?;
if !meta.is_dir() || meta.file_type().is_symlink() {
anyhow::bail!("quarantine move supports real directories only");
}
let (files, bytes) = walk_stats(&path);
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"origin": path.display().to_string(),
"root": root.display().to_string(),
"files": files,
"bytes": bytes,
}))?
);
} else {
println!(
"Would quarantine {} ({} in {} files) → {}",
path.display(),
human_bytes(bytes),
files,
root.display()
);
println!("Dry-run: pass --apply to move.");
}
return Ok(());
}
let manifest = quarantine_move(&path, &root, &policy.guard.protected_paths)?;
if json {
println!("{}", serde_json::to_string_pretty(&manifest)?);
} else {
println!(
"✅ Quarantined {} ({} in {} files) as {}",
manifest.origin,
human_bytes(manifest.bytes),
manifest.files,
manifest.name
);
}
}
QuarantineCommands::List { json } => {
let list = quarantine_list(&root, ttl)?;
if json {
println!("{}", serde_json::to_string_pretty(&list)?);
} else if list.entries.is_empty() {
println!("Quarantine {} is empty", root.display());
} else {
let mut table = Table::new();
table
.load_preset(UTF8_FULL_CONDENSED)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
Cell::new("ENTRY"),
Cell::new("ORIGIN"),
Cell::new("AGE"),
Cell::new("SIZE"),
Cell::new("EXPIRED"),
Cell::new("NOTE"),
]);
for e in &list.entries {
table.add_row(vec![
Cell::new(&e.name),
Cell::new(&e.origin),
Cell::new(
e.age_days
.map(|d| format!("{d}d"))
.unwrap_or_else(|| "?".to_string()),
),
Cell::new(human_bytes(e.bytes)),
Cell::new(if e.expired { "yes" } else { "" }),
Cell::new(if e.pinned { "PINNED" } else { "" }),
]);
}
println!("{table}");
println!(
"{} entries, {} total ({} expired, TTL {}d)",
list.entries.len(),
human_bytes(list.total_bytes),
human_bytes(list.expired_bytes),
ttl
);
if !list.pinned.is_empty() {
println!(
"⚠ {} entr{} pinned by the fail-safe ({}): {PIN_REASON}",
list.pinned.len(),
if list.pinned.len() == 1 {
"y is"
} else {
"ies are"
},
human_bytes(list.pinned_bytes)
);
println!(" {}", list.pinned.join(", "));
}
}
}
QuarantineCommands::Restore { name, json } => {
let origin = quarantine_restore(&root, &name)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"entry": name,
"restored_to": origin.display().to_string(),
}))?
);
} else {
println!("✅ Restored {name} → {}", origin.display());
}
}
QuarantineCommands::Expire { apply, json } => {
let pinned = quarantine_list(&root, ttl)?.pinned;
let expired = quarantine_expire(&root, ttl, apply)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"ttl_days": ttl,
"apply": apply,
"expired": expired,
"pinned": pinned,
}))?
);
} else if expired.is_empty() {
println!("No expired entries (TTL {}d)", ttl);
} else if apply {
println!(
"🗑 Expired {} entries: {}",
expired.len(),
expired.join(", ")
);
} else {
println!(
"Would expire {} entries: {}",
expired.len(),
expired.join(", ")
);
println!("Dry-run: pass --apply to delete.");
}
if !pinned.is_empty() {
if json {
eprintln!(
"⚠ {} entr{} held by the fail-safe (unreadable manifest) and NOT expired: {}",
pinned.len(),
if pinned.len() == 1 { "y is" } else { "ies are" },
pinned.join(", ")
);
} else {
println!(
"⚠ {} entr{} held by the fail-safe (unreadable manifest) and NOT expired: {}",
pinned.len(),
if pinned.len() == 1 { "y is" } else { "ies are" },
pinned.join(", ")
);
println!(" clear one with: quarantine purge <name> --apply");
}
}
}
QuarantineCommands::Purge { name, apply, json } => {
let (contents, bytes, was_pinned) = quarantine_purge(&root, &name, apply)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"entry": name,
"contents": contents,
"bytes": bytes,
"pinned_by_fail_safe": was_pinned,
"applied": apply,
}))?
);
} else if apply {
println!("🗑 Purged {name} ({contents})");
if was_pinned {
println!(
" This entry had an unreadable manifest, so it could not be \
restored. It is now gone for good."
);
}
} else {
println!("Would purge {name} ({contents})");
if was_pinned {
println!(
" Its manifest is unreadable, so it cannot be restored — \
purging discards it permanently."
);
}
println!("Dry-run: pass --apply to delete.");
}
}
}
Ok(())
}