use std::collections::{BTreeSet, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use freenet_stdlib::prelude::SecretsId;
pub const SNAPSHOTS_DIR: &str = ".snapshots";
pub const SNAPSHOT_NAME_WIDTH: usize = 20;
const MAX_SNAPSHOT_COLLISION_SUFFIX: u32 = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetentionBucket {
pub interval: Duration,
pub max_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotMetadata {
pub timestamp_ms: u64,
pub suffix: Option<u32>,
pub path: PathBuf,
pub size_bytes: u64,
}
#[derive(Debug, Clone)]
pub struct RetentionPolicy {
pub keep_last: usize,
pub buckets: Vec<RetentionBucket>,
pub max_age: Option<Duration>,
}
impl Default for RetentionPolicy {
fn default() -> Self {
const MIN: u64 = 60;
const HOUR: u64 = 60 * MIN;
const DAY: u64 = 24 * HOUR;
const WEEK: u64 = 7 * DAY;
const MONTH: u64 = 30 * DAY;
const YEAR: u64 = 365 * DAY;
Self {
keep_last: 5,
buckets: vec![
RetentionBucket {
interval: Duration::from_secs(MIN),
max_count: 10,
},
RetentionBucket {
interval: Duration::from_secs(HOUR),
max_count: 24,
},
RetentionBucket {
interval: Duration::from_secs(DAY),
max_count: 7,
},
RetentionBucket {
interval: Duration::from_secs(WEEK),
max_count: 4,
},
RetentionBucket {
interval: Duration::from_secs(MONTH),
max_count: 12,
},
],
max_age: Some(Duration::from_secs(2 * YEAR)),
}
}
}
impl RetentionPolicy {
pub fn select_keep(&self, now: SystemTime, timestamps: &[SystemTime]) -> BTreeSet<usize> {
let mut keep = BTreeSet::new();
let n = timestamps.len();
for i in n.saturating_sub(self.keep_last)..n {
keep.insert(i);
}
for bucket in &self.buckets {
if bucket.max_count == 0 {
continue;
}
let secs = bucket.interval.as_secs().max(1);
let mut slots_seen: HashSet<u64> = HashSet::new();
for (i, ts) in timestamps.iter().enumerate().rev() {
let age = now.duration_since(*ts).unwrap_or_default().as_secs();
let slot = age / secs;
if slots_seen.insert(slot) {
keep.insert(i);
if slots_seen.len() >= bucket.max_count {
break;
}
}
}
}
if let Some(max_age) = self.max_age {
keep.retain(|&i| {
now.duration_since(timestamps[i])
.map(|age| age <= max_age)
.unwrap_or(true)
});
}
keep
}
}
pub fn snapshot_dir_for(delegate_path: &Path, key: &SecretsId) -> PathBuf {
delegate_path.join(SNAPSHOTS_DIR).join(key.encode())
}
pub fn next_snapshot_path(snap_dir: &Path) -> std::io::Result<PathBuf> {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let unsuffixed = snap_dir.join(format!("{stamp:0width$}", width = SNAPSHOT_NAME_WIDTH));
if !unsuffixed.exists() {
return Ok(unsuffixed);
}
for suffix in 0u32..MAX_SNAPSHOT_COLLISION_SUFFIX {
let candidate = snap_dir.join(format!(
"{stamp:0width$}.{suffix}",
width = SNAPSHOT_NAME_WIDTH
));
if !candidate.exists() {
return Ok(candidate);
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"snapshot path collision exhausted: {} already has {MAX_SNAPSHOT_COLLISION_SUFFIX} entries with stamp {stamp}",
snap_dir.display()
),
))
}
pub fn thin_snapshots(snap_dir: &Path, policy: &RetentionPolicy, now: SystemTime) {
let mut entries: Vec<(SystemTime, PathBuf)> = match fs::read_dir(snap_dir) {
Ok(rd) => rd
.filter_map(|res| match res {
Ok(entry) => Some(entry),
Err(err) => {
tracing::debug!("snapshot dir entry error in {snap_dir:?}: {err}");
None
}
})
.filter_map(|entry| {
let is_file = entry.file_type().map(|ft| ft.is_file()).unwrap_or(false);
if !is_file {
return None;
}
let path = entry.path();
let stamp = parse_snapshot_stamp(&path)?;
Some((UNIX_EPOCH + Duration::from_millis(stamp), path))
})
.collect(),
Err(err) => {
tracing::warn!("failed to read snapshot dir {snap_dir:?}: {err}");
return;
}
};
entries.sort_by_key(|(ts, _)| *ts);
let timestamps: Vec<SystemTime> = entries.iter().map(|(t, _)| *t).collect();
let keep = policy.select_keep(now, ×tamps);
for (i, (_, path)) in entries.iter().enumerate() {
if !keep.contains(&i) {
if let Err(err) = fs::remove_file(path) {
tracing::warn!("failed to thin snapshot {path:?}: {err}");
}
}
}
}
pub fn list_snapshots(snap_dir: &Path) -> std::io::Result<Vec<SnapshotMetadata>> {
let read_dir = match fs::read_dir(snap_dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut out: Vec<SnapshotMetadata> = Vec::new();
for entry in read_dir {
let entry = entry?;
let file_type = entry.file_type()?;
if !file_type.is_file() {
continue;
}
let path = entry.path();
let Some((timestamp_ms, suffix)) = parse_snapshot_name(&path) else {
continue;
};
let size_bytes = entry.metadata()?.len();
out.push(SnapshotMetadata {
timestamp_ms,
suffix,
path,
size_bytes,
});
}
out.sort_by_key(|m| {
(
m.timestamp_ms,
match m.suffix {
None => (0u8, 0u32),
Some(s) => (1, s),
},
)
});
Ok(out)
}
pub(crate) fn parse_snapshot_stamp(path: &Path) -> Option<u64> {
parse_snapshot_name(path).map(|(ts, _)| ts)
}
pub(crate) fn parse_snapshot_name(path: &Path) -> Option<(u64, Option<u32>)> {
let name = path.file_name()?.to_str()?;
let (stamp_part, suffix_part) = match name.split_once('.') {
Some((s, t)) => (s, Some(t)),
None => (name, None),
};
if stamp_part.is_empty() || !stamp_part.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let suffix = match suffix_part {
Some(s) => {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some(s.parse().ok()?)
}
None => None,
};
Some((stamp_part.parse().ok()?, suffix))
}
#[cfg(test)]
mod tests {
use super::*;
fn t(now: SystemTime, secs_ago: u64) -> SystemTime {
now - Duration::from_secs(secs_ago)
}
#[test]
fn empty_input_keeps_nothing() {
let p = RetentionPolicy::default();
let now = SystemTime::now();
assert!(p.select_keep(now, &[]).is_empty());
}
#[test]
fn keeps_last_n_unconditionally() {
let p = RetentionPolicy {
keep_last: 3,
buckets: vec![],
max_age: None,
};
let now = SystemTime::now();
let ts: Vec<_> = (0..5).map(|i| t(now, 1_000_000 - i)).collect();
let keep = p.select_keep(now, &ts);
assert_eq!(keep.into_iter().collect::<Vec<_>>(), vec![2, 3, 4]);
}
#[test]
fn minute_bucket_thins_dense_history() {
let p = RetentionPolicy {
keep_last: 0,
buckets: vec![RetentionBucket {
interval: Duration::from_secs(60),
max_count: 10,
}],
max_age: None,
};
let now = SystemTime::now();
let ts: Vec<_> = (0..600).map(|i| t(now, 599 - i)).collect();
let keep = p.select_keep(now, &ts);
assert_eq!(keep.len(), 10, "expected one snapshot per minute slot");
}
#[test]
fn burst_in_single_slot_collapses_to_one() {
let p = RetentionPolicy {
keep_last: 0,
buckets: vec![RetentionBucket {
interval: Duration::from_secs(60),
max_count: 10,
}],
max_age: None,
};
let now = SystemTime::now();
let ts: Vec<_> = (0..1000).map(|_| t(now, 5)).collect();
let keep = p.select_keep(now, &ts);
assert_eq!(keep.len(), 1);
}
#[test]
fn default_policy_caps_steady_state() {
let p = RetentionPolicy::default();
let now = SystemTime::now();
let ts: Vec<_> = (0..525_600).map(|i| t(now, (525_599 - i) * 60)).collect();
let keep = p.select_keep(now, &ts);
assert!(
keep.len() <= 70,
"default policy should bound steady-state retention; got {}",
keep.len()
);
assert!(
keep.len() >= 30,
"but should still preserve coverage across all tiers; got {}",
keep.len()
);
}
#[test]
fn future_timestamps_treated_as_age_zero() {
let p = RetentionPolicy {
keep_last: 0,
buckets: vec![RetentionBucket {
interval: Duration::from_secs(60),
max_count: 5,
}],
max_age: None,
};
let now = SystemTime::now();
let ts = vec![now + Duration::from_secs(120), t(now, 30)];
let keep = p.select_keep(now, &ts);
assert_eq!(keep.len(), 1);
}
#[test]
fn max_age_overrides_keep_last() {
let p = RetentionPolicy {
keep_last: 5,
buckets: vec![],
max_age: Some(Duration::from_secs(60)),
};
let now = SystemTime::now();
let ts: Vec<_> = (0..5).map(|i| t(now, 3600 - i)).collect();
let keep = p.select_keep(now, &ts);
assert!(
keep.is_empty(),
"max_age must trim stale entries even from keep_last"
);
}
#[test]
fn max_age_preserves_future_timestamps() {
let p = RetentionPolicy {
keep_last: 1,
buckets: vec![],
max_age: Some(Duration::from_secs(60)),
};
let now = SystemTime::now();
let ts = vec![now + Duration::from_secs(120)];
let keep = p.select_keep(now, &ts);
assert_eq!(keep.len(), 1, "future-dated snapshot must survive max_age");
}
#[test]
fn max_age_drops_only_stale_entries() {
let p = RetentionPolicy {
keep_last: 10,
buckets: vec![],
max_age: Some(Duration::from_secs(120)),
};
let now = SystemTime::now();
let ts = vec![
t(now, 1000), t(now, 500), t(now, 200), t(now, 60), t(now, 30), t(now, 5), ];
let keep = p.select_keep(now, &ts);
assert_eq!(
keep.into_iter().collect::<Vec<_>>(),
vec![3, 4, 5],
"only fresh entries should remain"
);
}
#[test]
fn parse_snapshot_stamp_accepts_valid_shapes() {
use std::path::PathBuf;
let pure = PathBuf::from("/tmp/snap/00000000000001234567");
assert_eq!(parse_snapshot_stamp(&pure), Some(1_234_567));
let suffixed = PathBuf::from("/tmp/snap/00000000000001234567.42");
assert_eq!(parse_snapshot_stamp(&suffixed), Some(1_234_567));
}
#[test]
fn parse_snapshot_stamp_rejects_garbage() {
use std::path::PathBuf;
assert_eq!(parse_snapshot_stamp(&PathBuf::from("foo")), None);
assert_eq!(parse_snapshot_stamp(&PathBuf::from("123.tmp")), None);
assert_eq!(parse_snapshot_stamp(&PathBuf::from("123.4.5")), None);
assert_eq!(parse_snapshot_stamp(&PathBuf::from(".42")), None);
assert_eq!(parse_snapshot_stamp(&PathBuf::from("123.")), None);
assert_eq!(parse_snapshot_stamp(&PathBuf::from("...")), None);
}
#[test]
fn next_snapshot_path_uses_unsuffixed_when_free() {
let dir = tempfile::tempdir().expect("tempdir");
let p = next_snapshot_path(dir.path()).expect("path");
assert!(
p.file_name()
.unwrap()
.to_str()
.unwrap()
.chars()
.all(|c| c.is_ascii_digit())
);
}
#[test]
fn next_snapshot_path_falls_back_to_suffix_on_collision() {
let dir = tempfile::tempdir().expect("tempdir");
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
for offset in 0..3u64 {
let p = dir
.path()
.join(format!("{:0width$}", now + offset, width = 20));
std::fs::write(&p, b"").unwrap();
}
let p = next_snapshot_path(dir.path()).expect("path");
assert!(!p.exists());
let name = p.file_name().unwrap().to_str().unwrap();
assert!(parse_snapshot_stamp(&p).is_some(), "name={name}");
}
#[test]
fn zero_max_count_bucket_is_inert() {
let p = RetentionPolicy {
keep_last: 0,
buckets: vec![RetentionBucket {
interval: Duration::from_secs(60),
max_count: 0,
}],
max_age: None,
};
let now = SystemTime::now();
let ts: Vec<_> = (0..10).map(|i| t(now, i)).collect();
assert!(p.select_keep(now, &ts).is_empty());
}
#[test]
fn parse_snapshot_name_returns_suffix() {
use std::path::PathBuf;
assert_eq!(
parse_snapshot_name(&PathBuf::from("00000000000001234567")),
Some((1_234_567, None))
);
assert_eq!(
parse_snapshot_name(&PathBuf::from("00000000000001234567.42")),
Some((1_234_567, Some(42)))
);
assert_eq!(parse_snapshot_name(&PathBuf::from("foo")), None);
assert_eq!(parse_snapshot_name(&PathBuf::from("123.tmp")), None);
assert_eq!(parse_snapshot_name(&PathBuf::from("123.4.5")), None);
assert_eq!(
parse_snapshot_name(&PathBuf::from("123.999999999999")),
None
);
}
#[test]
fn list_snapshots_returns_sorted_metadata() {
let dir = tempfile::tempdir().expect("tempdir");
for (stamp, body) in [
(20u64, &b"newest"[..]),
(5, &b"older"[..]),
(10, &b"mid"[..]),
] {
std::fs::write(
dir.path()
.join(format!("{stamp:0width$}", width = SNAPSHOT_NAME_WIDTH)),
body,
)
.unwrap();
}
std::fs::write(dir.path().join("README"), b"not a snapshot").unwrap();
std::fs::write(dir.path().join("123.tmp"), b"not a snapshot").unwrap();
let entries = list_snapshots(dir.path()).expect("list");
let stamps: Vec<u64> = entries.iter().map(|m| m.timestamp_ms).collect();
assert_eq!(stamps, vec![5, 10, 20], "must be sorted oldest-first");
assert_eq!(entries[0].size_bytes, 5, "size_bytes wired up");
assert!(entries.iter().all(|m| m.suffix.is_none()));
}
#[test]
fn list_snapshots_orders_collision_suffixes() {
let dir = tempfile::tempdir().expect("tempdir");
let stamp = 42u64;
let base = format!("{stamp:0width$}", width = SNAPSHOT_NAME_WIDTH);
std::fs::write(dir.path().join(&base), b"a").unwrap();
std::fs::write(dir.path().join(format!("{base}.1")), b"bb").unwrap();
std::fs::write(dir.path().join(format!("{base}.0")), b"ccc").unwrap();
let entries = list_snapshots(dir.path()).expect("list");
let suffixes: Vec<Option<u32>> = entries.iter().map(|m| m.suffix).collect();
assert_eq!(suffixes, vec![None, Some(0), Some(1)]);
}
#[test]
fn list_snapshots_missing_dir_is_empty() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("never-existed");
let entries = list_snapshots(&missing).expect("missing dir is not an error");
assert!(entries.is_empty());
}
}