use crate::error::{FsError, Result};
use crate::tree::{remove_tree, RemoveOptions};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct Candidate {
pub name: String,
pub bytes: u64,
pub modified_ms: u64,
}
type OrderKey = Box<dyn Fn(&Candidate) -> i128 + Send + Sync>;
pub struct Retention {
dir: PathBuf,
max_entries: Option<usize>,
max_bytes: Option<u64>,
protected: Vec<String>,
order: OrderKey,
}
impl std::fmt::Debug for Retention {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Retention")
.field("dir", &self.dir)
.field("max_entries", &self.max_entries)
.field("max_bytes", &self.max_bytes)
.field("protected", &self.protected)
.finish_non_exhaustive()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct RetentionReport {
pub retired: Vec<String>,
pub kept: usize,
pub kept_bytes: u64,
pub leftovers: Vec<PathBuf>,
}
impl Retention {
pub fn new(dir: &Path) -> Self {
Self {
dir: dir.to_path_buf(),
max_entries: None,
max_bytes: None,
protected: Vec::new(),
order: Box::new(|c| i128::from(c.modified_ms)),
}
}
#[must_use]
pub fn keep_newest(mut self, count: usize) -> Self {
self.max_entries = Some(count);
self
}
#[must_use]
pub fn max_bytes(mut self, bytes: u64) -> Self {
self.max_bytes = Some(bytes);
self
}
#[must_use]
pub fn protect(mut self, name: &str) -> Self {
self.protected.push(name.to_string());
self
}
#[must_use]
pub fn order_by(mut self, key: impl Fn(&Candidate) -> i128 + Send + Sync + 'static) -> Self {
self.order = Box::new(key);
self
}
pub fn commit(&self, added: &str) -> Result<RetentionReport> {
validate_child_name(added)?;
if !is_managed(added) || self.dir.join(added).symlink_metadata().is_err() {
return Err(FsError::BadName(added.to_string()));
}
let leftovers_before = sweep_dead_hidden(&self.dir);
let mut candidates = self.list()?;
candidates.sort_by(|a, b| {
(self.order)(a)
.cmp(&(self.order)(b))
.then_with(|| a.name.cmp(&b.name))
});
let mut count = candidates.len();
let mut bytes: u64 = candidates.iter().map(|c| c.bytes).sum();
let mut victims: Vec<&Candidate> = Vec::new();
for candidate in &candidates {
let over_count = self.max_entries.is_some_and(|max| count > max);
let over_bytes = self.max_bytes.is_some_and(|max| bytes > max);
if !over_count && !over_bytes {
break;
}
if candidate.name == added || self.protected.contains(&candidate.name) {
continue;
}
count -= 1;
bytes -= candidate.bytes;
victims.push(candidate);
}
let mut moved: Vec<(PathBuf, PathBuf)> = Vec::new();
for victim in &victims {
let from = self.dir.join(&victim.name);
let to = hidden_name(&self.dir, &victim.name);
if let Err(error) = fs::rename(&from, &to) {
for (original, hidden) in moved.iter().rev() {
let _ = fs::rename(hidden, original);
}
let _ = remove_entry(&self.dir.join(added));
return Err(FsError::io(&from, error));
}
moved.push((from, to));
}
let mut leftovers = leftovers_before;
for (_, hidden) in &moved {
match remove_entry(hidden) {
Ok(true) => {}
Ok(false) | Err(_) => leftovers.push(hidden.clone()),
}
}
Ok(RetentionReport {
retired: victims.iter().map(|v| v.name.clone()).collect(),
kept: count,
kept_bytes: bytes,
leftovers,
})
}
fn list(&self) -> Result<Vec<Candidate>> {
let mut out = Vec::new();
for entry in fs::read_dir(&self.dir).map_err(|e| FsError::io(&self.dir, e))? {
let entry = entry.map_err(|e| FsError::io(&self.dir, e))?;
let name = entry.file_name().to_string_lossy().into_owned();
if !is_managed(&name) {
continue;
}
let path = entry.path();
let meta = fs::symlink_metadata(&path).map_err(|e| FsError::io(&path, e))?;
let modified_ms = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map_or(0, |d| d.as_millis() as u64);
out.push(Candidate {
name,
bytes: tree_bytes(&path, &meta),
modified_ms,
});
}
Ok(out)
}
}
pub fn quarantine(path: &Path) -> Result<PathBuf> {
quarantine_as(path, false)
}
pub fn quarantine_hidden(path: &Path) -> Result<PathBuf> {
quarantine_as(path, true)
}
fn quarantine_as(path: &Path, hidden: bool) -> Result<PathBuf> {
let name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| FsError::BadName(path.display().to_string()))?;
let dir = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => Path::new("."),
};
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis());
let prefix = if hidden { "." } else { "" };
for attempt in 0..1000u32 {
let suffix = if attempt == 0 {
String::new()
} else {
format!("-{attempt}")
};
let target = dir.join(format!("{prefix}{name}.recovered-{ms}{suffix}"));
match crate::atomic::rename_exclusive(path, &target) {
Ok(()) => return Ok(target),
Err(FsError::AlreadyExists(_)) => continue,
Err(e) => return Err(e),
}
}
Err(FsError::AlreadyExists(path.to_path_buf()))
}
fn validate_child_name(name: &str) -> Result<()> {
if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']) {
return Err(FsError::BadName(name.to_string()));
}
Ok(())
}
fn is_managed(name: &str) -> bool {
!name.starts_with('.')
}
fn hidden_name(dir: &Path, name: &str) -> PathBuf {
let short: String = name.chars().take(100).collect();
dir.join(format!(
".{short}.rkretire-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
))
}
fn remove_entry(path: &Path) -> Result<bool> {
let meta = fs::symlink_metadata(path).map_err(|e| FsError::io(path, e))?;
if meta.is_dir() && !meta.file_type().is_symlink() {
let report = remove_tree(path, &RemoveOptions::new())?;
return Ok(report.retained == 0);
}
match fs::remove_file(path) {
Ok(()) => Ok(true),
Err(e) if cfg!(windows) && e.kind() == io::ErrorKind::PermissionDenied => {
fs::remove_dir(path)
.map(|()| true)
.map_err(|e| FsError::io(path, e))
}
Err(e) => Err(FsError::io(path, e)),
}
}
fn tree_bytes(path: &Path, meta: &fs::Metadata) -> u64 {
if meta.file_type().is_symlink() {
return 0;
}
if meta.is_file() {
return meta.len();
}
if !meta.is_dir() {
return 0;
}
let Ok(entries) = fs::read_dir(path) else {
return 0;
};
entries
.flatten()
.map(|entry| {
let child = entry.path();
fs::symlink_metadata(&child).map_or(0, |m| tree_bytes(&child, &m))
})
.sum()
}
fn sweep_dead_hidden(dir: &Path) -> Vec<PathBuf> {
let mut remaining = Vec::new();
let Ok(entries) = fs::read_dir(dir) else {
return remaining;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let Some((_, rest)) = name.split_once(".rkretire-") else {
continue;
};
if !name.starts_with('.') {
continue;
}
let Some(pid) = rest.split('-').next().and_then(|p| p.parse::<u32>().ok()) else {
continue;
};
if pid != std::process::id()
&& crate::process_liveness(pid) == crate::ProcessLiveness::Dead
&& !matches!(remove_entry(&entry.path()), Ok(true))
{
remaining.push(entry.path());
}
}
remaining
}