use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::Serialize;
#[derive(Debug, Clone)]
pub struct StagingTree {
pub path: PathBuf,
pub size_bytes: u64,
pub age: Duration,
}
#[derive(Debug, Clone, Copy)]
pub struct StagingGcPolicy {
pub min_age: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GcAction {
Prune,
KeepRecent,
KeepUnsafePath,
}
#[derive(Debug, Clone, Serialize)]
pub struct StagingGcEntry {
pub path: String,
pub size_bytes: u64,
pub age_secs: u64,
pub action: GcAction,
pub reason: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct StagingGcPlan {
pub entries: Vec<StagingGcEntry>,
pub prunable_bytes: u64,
pub prunable_count: usize,
pub total_bytes: u64,
}
fn is_prunable_path(path: &str, base: &Path) -> bool {
let candidate = Path::new(path);
candidate.starts_with(base)
&& candidate != base
&& rch_common::stale_target_reap::is_safe_reap_path(path)
}
#[must_use]
pub fn plan_staging_gc(
trees: &[StagingTree],
policy: StagingGcPolicy,
base: &Path,
) -> StagingGcPlan {
let mut entries = Vec::with_capacity(trees.len());
let mut prunable_bytes = 0u64;
let mut prunable_count = 0usize;
let mut total_bytes = 0u64;
for tree in trees {
total_bytes = total_bytes.saturating_add(tree.size_bytes);
let path_str = tree.path.to_string_lossy().to_string();
let (action, reason) = if !is_prunable_path(&path_str, base) {
(
GcAction::KeepUnsafePath,
"path is not a safe reap path under the staging root".to_string(),
)
} else if tree.age < policy.min_age {
(
GcAction::KeepRecent,
format!(
"modified {}s ago (< {}s min-age); may be in-flight",
tree.age.as_secs(),
policy.min_age.as_secs()
),
)
} else {
prunable_bytes = prunable_bytes.saturating_add(tree.size_bytes);
prunable_count += 1;
(
GcAction::Prune,
format!("idle {}s (>= min-age)", tree.age.as_secs()),
)
};
entries.push(StagingGcEntry {
path: path_str,
size_bytes: tree.size_bytes,
age_secs: tree.age.as_secs(),
action,
reason,
});
}
StagingGcPlan {
entries,
prunable_bytes,
prunable_count,
total_bytes,
}
}
pub fn enumerate_staging_trees(
remote_base: &Path,
project: Option<&str>,
now: std::time::SystemTime,
) -> std::io::Result<Vec<StagingTree>> {
let mut trees = Vec::new();
if !remote_base.is_dir() {
return Ok(trees);
}
for project_entry in std::fs::read_dir(remote_base)? {
let project_entry = match project_entry {
Ok(e) => e,
Err(_) => continue,
};
if !project_entry
.file_type()
.map(|ft| ft.is_dir())
.unwrap_or(false)
{
continue;
}
if let Some(filter) = project
&& project_entry.file_name().to_string_lossy() != filter
{
continue;
}
let hash_dirs = match std::fs::read_dir(project_entry.path()) {
Ok(rd) => rd,
Err(_) => continue,
};
for hash_entry in hash_dirs {
let Ok(hash_entry) = hash_entry else { continue };
let tree_path = hash_entry.path();
if !hash_entry
.file_type()
.map(|ft| ft.is_dir())
.unwrap_or(false)
{
continue;
}
let (size_bytes, newest) = tree_size_and_newest_mtime(&tree_path);
let age = newest
.and_then(|m| now.duration_since(m).ok())
.unwrap_or(Duration::ZERO);
trees.push(StagingTree {
path: tree_path,
size_bytes,
age,
});
}
}
Ok(trees)
}
pub fn parse_human_duration(s: &str) -> Result<Duration, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty duration".to_string());
}
let (num, unit_secs): (&str, u64) = match s.chars().last() {
Some('s') => (&s[..s.len() - 1], 1),
Some('m') => (&s[..s.len() - 1], 60),
Some('h') => (&s[..s.len() - 1], 3600),
Some('d') => (&s[..s.len() - 1], 86_400),
Some(c) if c.is_ascii_digit() => (s, 1),
_ => return Err(format!("invalid duration unit in '{s}' (use s/m/h/d)")),
};
let value: u64 = num
.trim()
.parse()
.map_err(|_| format!("invalid duration number in '{s}'"))?;
Ok(Duration::from_secs(value.saturating_mul(unit_secs)))
}
#[must_use]
pub fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct StagingGcOutcome {
pub removed_count: usize,
pub removed_bytes: u64,
pub failed: Vec<String>,
}
pub fn execute_staging_gc(plan: &StagingGcPlan, base: &Path) -> StagingGcOutcome {
let mut outcome = StagingGcOutcome::default();
for entry in &plan.entries {
if entry.action != GcAction::Prune {
continue;
}
if !is_prunable_path(&entry.path, base) {
outcome.failed.push(entry.path.clone());
continue;
}
match std::fs::remove_dir_all(&entry.path) {
Ok(()) => {
outcome.removed_count += 1;
outcome.removed_bytes = outcome.removed_bytes.saturating_add(entry.size_bytes);
}
Err(_) => outcome.failed.push(entry.path.clone()),
}
}
outcome
}
fn tree_size_and_newest_mtime(root: &Path) -> (u64, Option<std::time::SystemTime>) {
let mut total = 0u64;
let mut newest: Option<std::time::SystemTime> = None;
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
let Ok(meta) = entry.metadata() else { continue };
if let Ok(mtime) = meta.modified() {
newest = Some(match newest {
Some(cur) if cur >= mtime => cur,
_ => mtime,
});
}
if file_type.is_dir() {
stack.push(entry.path());
} else {
total = total.saturating_add(meta.len());
}
}
}
(total, newest)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, SystemTime};
fn tree(path: &str, size_bytes: u64, age_secs: u64) -> StagingTree {
StagingTree {
path: PathBuf::from(path),
size_bytes,
age: Duration::from_secs(age_secs),
}
}
fn policy(min_age_secs: u64) -> StagingGcPolicy {
StagingGcPolicy {
min_age: Duration::from_secs(min_age_secs),
}
}
fn base() -> &'static Path {
Path::new("/tmp/rch")
}
#[test]
fn prunes_old_trees_and_keeps_recent_ones() {
let trees = vec![
tree("/tmp/rch/proj/oldhash", 80 * 1024 * 1024 * 1024, 6 * 3600),
tree("/tmp/rch/proj/freshhash", 1024, 60),
];
let plan = plan_staging_gc(&trees, policy(3600), base());
assert_eq!(plan.prunable_count, 1);
assert_eq!(plan.prunable_bytes, 80 * 1024 * 1024 * 1024);
assert_eq!(plan.total_bytes, 80 * 1024 * 1024 * 1024 + 1024);
let old = plan
.entries
.iter()
.find(|e| e.path.contains("oldhash"))
.unwrap();
assert_eq!(old.action, GcAction::Prune);
let fresh = plan
.entries
.iter()
.find(|e| e.path.contains("freshhash"))
.unwrap();
assert_eq!(fresh.action, GcAction::KeepRecent);
}
#[test]
fn at_exactly_min_age_is_prunable() {
let plan = plan_staging_gc(&[tree("/tmp/rch/p/h", 10, 3600)], policy(3600), base());
assert_eq!(plan.entries[0].action, GcAction::Prune);
}
#[test]
fn refuses_paths_outside_base_or_unsafe() {
for bad in ["/", "/tmp", "/etc/passwd", "/tmp/rch", "relative/path"] {
let plan = plan_staging_gc(&[tree(bad, 999, 999_999)], policy(60), base());
assert_eq!(
plan.entries[0].action,
GcAction::KeepUnsafePath,
"must refuse non-staging path {bad}"
);
assert_eq!(
plan.prunable_count, 0,
"non-staging path must not be prunable"
);
}
}
#[test]
fn empty_pool_is_a_noop_plan() {
let plan = plan_staging_gc(&[], policy(60), base());
assert_eq!(plan.prunable_count, 0);
assert_eq!(plan.prunable_bytes, 0);
assert_eq!(plan.total_bytes, 0);
assert!(plan.entries.is_empty());
}
#[test]
fn enumerate_reads_two_level_layout_and_sizes_trees() {
let base = tempfile::tempdir().expect("tempdir");
let make = |rel: &str, bytes: usize| {
let dir = base.path().join(rel);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("src.rs"), vec![b'x'; bytes]).unwrap();
};
make("proj-a/hash1", 100);
make("proj-a/hash2", 50);
make("proj-b/hash3", 10);
let all = enumerate_staging_trees(base.path(), None, SystemTime::now()).expect("enumerate");
assert_eq!(all.len(), 3, "three <project>/<hash> trees");
assert!(all.iter().any(|t| t.size_bytes >= 100));
let only_a =
enumerate_staging_trees(base.path(), Some("proj-a"), SystemTime::now()).unwrap();
assert_eq!(only_a.len(), 2);
let missing =
enumerate_staging_trees(&base.path().join("does-not-exist"), None, SystemTime::now())
.unwrap();
assert!(missing.is_empty());
}
#[cfg(unix)]
#[test]
fn enumerate_and_sizing_do_not_follow_symlinks() {
use std::os::unix::fs::symlink;
let base = tempfile::tempdir().expect("tempdir");
let external = tempfile::tempdir().expect("external tempdir");
std::fs::write(external.path().join("big.bin"), vec![b'x'; 10_000]).unwrap();
let tree = base.path().join("proj-a/hash1");
std::fs::create_dir_all(&tree).unwrap();
std::fs::write(tree.join("src.rs"), vec![b'x'; 100]).unwrap();
symlink(external.path(), tree.join("ext")).unwrap();
symlink(external.path(), base.path().join("proj-a/hashsym")).unwrap();
symlink(external.path(), base.path().join("projsym")).unwrap();
let trees =
enumerate_staging_trees(base.path(), None, SystemTime::now()).expect("enumerate");
assert_eq!(
trees.len(),
1,
"symlinked project/hash entries must not be enumerated"
);
assert!(trees[0].path.ends_with("proj-a/hash1"));
assert!(
trees[0].size_bytes < 10_000,
"symlink target bytes ({}) must not be counted via descent",
trees[0].size_bytes
);
}
#[test]
fn parse_human_duration_units() {
assert_eq!(
parse_human_duration("90s").unwrap(),
Duration::from_secs(90)
);
assert_eq!(
parse_human_duration("30m").unwrap(),
Duration::from_secs(1800)
);
assert_eq!(
parse_human_duration("24h").unwrap(),
Duration::from_secs(86_400)
);
assert_eq!(
parse_human_duration("7d").unwrap(),
Duration::from_secs(604_800)
);
assert_eq!(
parse_human_duration("120").unwrap(),
Duration::from_secs(120)
);
assert!(parse_human_duration("").is_err());
assert!(parse_human_duration("12x").is_err());
assert!(parse_human_duration("abch").is_err());
}
#[test]
fn execute_only_removes_prune_entries_and_revalidates() {
let base = tempfile::tempdir().expect("tempdir");
let tree_dir = base.path().join("proj/hash");
std::fs::create_dir_all(&tree_dir).unwrap();
std::fs::write(tree_dir.join("f"), b"data").unwrap();
let plan = StagingGcPlan {
entries: vec![
StagingGcEntry {
path: tree_dir.to_string_lossy().to_string(),
size_bytes: 4,
age_secs: 9999,
action: GcAction::Prune,
reason: "test".to_string(),
},
StagingGcEntry {
path: "/".to_string(),
size_bytes: 0,
age_secs: 9999,
action: GcAction::Prune,
reason: "adversarial".to_string(),
},
],
prunable_bytes: 4,
prunable_count: 2,
total_bytes: 4,
};
if rch_common::stale_target_reap::is_safe_reap_path(&plan.entries[0].path) {
let outcome = execute_staging_gc(&plan, base.path());
assert_eq!(outcome.removed_count, 1, "only the safe tree removed");
assert!(!tree_dir.exists(), "safe tree was removed");
assert!(
outcome.failed.iter().any(|p| p == "/"),
"unsafe '/' refused by re-validation"
);
}
}
#[test]
fn plan_serializes_for_dry_run_output() {
let plan = plan_staging_gc(&[tree("/tmp/rch/p/h", 2048, 7200)], policy(3600), base());
let json = serde_json::to_string(&plan).expect("serialize");
assert!(json.contains("\"action\":\"prune\""));
assert!(json.contains("prunable_bytes"));
}
}