use std::collections::{BTreeSet, HashSet};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use freenet_stdlib::prelude::SecretsId;
use super::secrets_store::{create_owner_only, ensure_owner_only_dir};
pub const SNAPSHOTS_DIR: &str = ".snapshots";
pub const SNAPSHOT_NAME_WIDTH: usize = 20;
const MAX_SNAPSHOT_COLLISION_SUFFIX: u32 = 1024;
pub const DEFAULT_MAX_SNAPSHOT_BYTES_PER_SECRET: u64 = 3 * 1024 * 1024;
const MIN_SNAPSHOTS_KEPT_UNDER_BUDGET: usize = 3;
pub const SNAPSHOT_BUDGET_ENV: &str = "FREENET_SECRET_SNAPSHOT_BYTES_PER_SECRET";
#[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>,
pub max_total_bytes: Option<u64>,
}
pub fn parse_snapshot_budget(raw: Option<&str>) -> Option<u64> {
let Some(raw) = raw else {
return Some(DEFAULT_MAX_SNAPSHOT_BYTES_PER_SECRET);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Some(DEFAULT_MAX_SNAPSHOT_BYTES_PER_SECRET);
}
match trimmed.parse::<u64>() {
Ok(0) => None,
Ok(bytes) => Some(bytes),
Err(err) => {
tracing::warn!(
env = SNAPSHOT_BUDGET_ENV,
value = %trimmed,
error = %err,
"unparseable snapshot byte budget; falling back to the default"
);
Some(DEFAULT_MAX_SNAPSHOT_BYTES_PER_SECRET)
}
}
}
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)),
max_total_bytes: Some(DEFAULT_MAX_SNAPSHOT_BYTES_PER_SECRET),
}
}
}
impl RetentionPolicy {
pub fn with_budget_from(raw: Option<&str>) -> Self {
Self {
max_total_bytes: parse_snapshot_budget(raw),
..Self::default()
}
}
pub fn from_env() -> Self {
Self::with_budget_from(std::env::var(SNAPSHOT_BUDGET_ENV).ok().as_deref())
}
pub fn without_byte_budget(&self) -> Self {
Self {
max_total_bytes: None,
..self.clone()
}
}
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 select_keep_within_budget(
&self,
now: SystemTime,
entries: &[(SystemTime, u64)],
) -> BTreeSet<usize> {
let timestamps: Vec<SystemTime> = entries.iter().map(|(ts, _)| *ts).collect();
let mut keep = self.select_keep(now, ×tamps);
let Some(budget) = self.max_total_bytes else {
return keep;
};
let mut total = keep
.iter()
.fold(0u64, |acc, &i| acc.saturating_add(entries[i].1));
let oldest_first: Vec<usize> = keep.iter().copied().collect();
for i in oldest_first {
if total <= budget || keep.len() <= MIN_SNAPSHOTS_KEPT_UNDER_BUDGET {
break;
}
keep.remove(&i);
total = total.saturating_sub(entries[i].1);
}
keep
}
}
pub fn snapshot_dir_for_encoded(delegate_path: &Path, secret_encoded: &str) -> PathBuf {
delegate_path.join(SNAPSHOTS_DIR).join(secret_encoded)
}
pub fn snapshot_dir_for(delegate_path: &Path, key: &SecretsId) -> PathBuf {
snapshot_dir_for_encoded(delegate_path, &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) {
struct Candidate {
timestamp: SystemTime,
suffix_key: (u8, u32),
size_bytes: u64,
path: PathBuf,
}
let mut entries: Vec<Candidate> = 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, suffix) = parse_snapshot_name(&path)?;
let size_bytes = match entry.metadata() {
Ok(md) => md.len(),
Err(err) => {
tracing::debug!(
"failed to stat snapshot {path:?}: {err}; charging 0 bytes"
);
0
}
};
Some(Candidate {
timestamp: UNIX_EPOCH + Duration::from_millis(stamp),
suffix_key: match suffix {
None => (0, 0),
Some(n) => (1, n),
},
size_bytes,
path,
})
})
.collect(),
Err(err) => {
tracing::warn!("failed to read snapshot dir {snap_dir:?}: {err}");
return;
}
};
entries.sort_by(|a, b| {
a.timestamp
.cmp(&b.timestamp)
.then_with(|| a.suffix_key.cmp(&b.suffix_key))
});
let sized: Vec<(SystemTime, u64)> = entries
.iter()
.map(|c| (c.timestamp, c.size_bytes))
.collect();
let keep = policy.select_keep_within_budget(now, &sized);
for (i, candidate) in entries.iter().enumerate() {
if !keep.contains(&i) {
if let Err(err) = fs::remove_file(&candidate.path) {
tracing::warn!("failed to thin snapshot {:?}: {err}", candidate.path);
}
}
}
}
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_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))
}
pub fn snapshot_active_value(
delegate_dir: &Path,
secret_encoded: &str,
active_path: &Path,
) -> std::io::Result<()> {
let snap_dir = snapshot_dir_for_encoded(delegate_dir, secret_encoded);
fs::create_dir_all(&snap_dir)?;
let snap_parent = delegate_dir.join(SNAPSHOTS_DIR);
if let Err(e) = ensure_owner_only_dir(&snap_parent) {
tracing::warn!(path = %snap_parent.display(), error = %e, "chmod snapshots parent dir failed");
}
if let Err(e) = ensure_owner_only_dir(&snap_dir) {
tracing::warn!(path = %snap_dir.display(), error = %e, "chmod snapshot dir failed");
}
let snap_path = next_snapshot_path(&snap_dir)?;
match fs::hard_link(active_path, &snap_path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(_) => {
fs::copy(active_path, &snap_path).map(|_| ())
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum RestoreError {
#[error("no snapshot at timestamp_ms {0}")]
NotFound(u64),
#[error(transparent)]
Io(#[from] std::io::Error),
}
pub fn restore_snapshot_file(
delegate_dir: &Path,
secret_encoded: &str,
timestamp_ms: u64,
suffix: Option<u32>,
snapshots_enabled: bool,
) -> Result<(), RestoreError> {
let snap_dir = snapshot_dir_for_encoded(delegate_dir, secret_encoded);
let secret_file_path = delegate_dir.join(secret_encoded);
let entries = list_snapshots(&snap_dir)?;
let chosen = match suffix {
Some(want) => entries
.iter()
.find(|m| m.timestamp_ms == timestamp_ms && m.suffix == Some(want)),
None => entries
.iter()
.filter(|m| m.timestamp_ms == timestamp_ms)
.min_by_key(|m| match m.suffix {
None => (0u32, 0u32),
Some(s) => (1, s),
}),
}
.ok_or(RestoreError::NotFound(timestamp_ms))?;
let chosen_path = chosen.path.clone();
if snapshots_enabled
&& secret_file_path.exists()
&& let Err(e) = snapshot_active_value(delegate_dir, secret_encoded, &secret_file_path)
{
tracing::warn!("failed to snapshot active value before restore for {secret_encoded}: {e}");
}
let ciphertext = fs::read(&chosen_path)?;
fs::create_dir_all(delegate_dir)?;
if let Err(e) = ensure_owner_only_dir(delegate_dir) {
tracing::warn!(path = %delegate_dir.display(), error = %e, "chmod delegate dir failed");
}
let tmp_path = secret_file_path.with_extension("tmp");
{
let mut file = create_owner_only(&tmp_path)?;
file.write_all(&ciphertext)?;
file.sync_all()?;
}
if let Err(err) = fs::rename(&tmp_path, &secret_file_path) {
if let Err(rm_err) = fs::remove_file(&tmp_path) {
tracing::debug!(
"failed to clean up tmp file {tmp_path:?} after rename failure: {rm_err}"
);
}
return Err(err.into());
}
Ok(())
}
#[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,
max_total_bytes: 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,
max_total_bytes: 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,
max_total_bytes: 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,
max_total_bytes: 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)),
max_total_bytes: None,
};
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)),
max_total_bytes: None,
};
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)),
max_total_bytes: None,
};
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"
);
}
fn budget_only(max_total_bytes: Option<u64>) -> RetentionPolicy {
RetentionPolicy {
keep_last: usize::MAX,
buckets: vec![],
max_age: None,
max_total_bytes,
}
}
#[test]
fn byte_budget_evicts_oldest_first() {
let now = SystemTime::now();
let entries: Vec<(SystemTime, u64)> =
(0..8).map(|i| (t(now, 800 - i * 100), 100u64)).collect();
let keep = budget_only(Some(450)).select_keep_within_budget(now, &entries);
assert_eq!(
keep.into_iter().collect::<Vec<_>>(),
vec![4, 5, 6, 7],
"budget must drop the OLDEST entries and keep the newest"
);
}
#[test]
fn byte_budget_floor_keeps_three_over_budget() {
let now = SystemTime::now();
let entries: Vec<(SystemTime, u64)> = (0..10)
.map(|i| (t(now, (10 - i) * 100), 1024 * 1024u64))
.collect();
let keep = budget_only(Some(1)).select_keep_within_budget(now, &entries);
assert_eq!(
keep.iter().copied().collect::<Vec<_>>(),
vec![7, 8, 9],
"floor must retain the three NEWEST entries, not just any three"
);
assert_eq!(keep.len(), MIN_SNAPSHOTS_KEPT_UNDER_BUDGET);
}
#[test]
fn byte_budget_none_keeps_everything() {
let now = SystemTime::now();
let entries: Vec<(SystemTime, u64)> = (0..4)
.map(|i| (t(now, 400 - i * 100), 10_000_000u64))
.collect();
let keep = budget_only(None).select_keep_within_budget(now, &entries);
assert_eq!(keep.len(), 4, "no budget => count tiers alone decide");
}
#[test]
fn byte_budget_never_empties_the_history() {
let now = SystemTime::now();
let entries = vec![(t(now, 10), 50_000_000u64)];
let keep = budget_only(Some(1024)).select_keep_within_budget(now, &entries);
assert_eq!(keep.into_iter().collect::<Vec<_>>(), vec![0]);
}
#[test]
fn byte_budget_zero_still_keeps_the_floor() {
let now = SystemTime::now();
let entries: Vec<(SystemTime, u64)> =
(0..5).map(|i| (t(now, 500 - i * 100), 100u64)).collect();
let keep = budget_only(Some(0)).select_keep_within_budget(now, &entries);
assert_eq!(keep.into_iter().collect::<Vec<_>>(), vec![2, 3, 4]);
}
#[test]
fn byte_budget_exactly_at_budget_keeps_all() {
let now = SystemTime::now();
let entries: Vec<(SystemTime, u64)> =
(0..5).map(|i| (t(now, 500 - i * 100), 100u64)).collect();
let keep = budget_only(Some(500)).select_keep_within_budget(now, &entries);
assert_eq!(keep.len(), 5);
let keep = budget_only(Some(499)).select_keep_within_budget(now, &entries);
assert_eq!(keep.into_iter().collect::<Vec<_>>(), vec![1, 2, 3, 4]);
}
#[test]
fn byte_budget_empty_input_keeps_nothing() {
let now = SystemTime::now();
assert!(
budget_only(Some(1024))
.select_keep_within_budget(now, &[])
.is_empty()
);
}
#[test]
fn byte_budget_only_subtracts_never_resurrects() {
let p = RetentionPolicy {
keep_last: 10,
buckets: vec![],
max_age: Some(Duration::from_secs(120)),
max_total_bytes: Some(u64::MAX),
};
let now = SystemTime::now();
let entries = vec![
(t(now, 1000), 1u64), (t(now, 500), 1), (t(now, 30), 1), ];
let keep = p.select_keep_within_budget(now, &entries);
assert_eq!(keep.into_iter().collect::<Vec<_>>(), vec![2]);
}
#[test]
fn byte_budget_counts_only_entries_the_tiers_kept() {
let p = RetentionPolicy {
keep_last: 2,
buckets: vec![],
max_age: None,
max_total_bytes: Some(250),
};
let now = SystemTime::now();
let entries: Vec<(SystemTime, u64)> =
(0..10).map(|i| (t(now, 1000 - i * 10), 100u64)).collect();
let keep = p.select_keep_within_budget(now, &entries);
assert_eq!(keep.into_iter().collect::<Vec<_>>(), vec![8, 9]);
}
#[test]
fn byte_budget_saturates_on_overflowing_sizes() {
let now = SystemTime::now();
let entries: Vec<(SystemTime, u64)> =
(0..5).map(|i| (t(now, 500 - i * 100), u64::MAX)).collect();
let keep = budget_only(Some(1024)).select_keep_within_budget(now, &entries);
assert!(
keep.len() >= MIN_SNAPSHOTS_KEPT_UNDER_BUDGET,
"saturation must never evict below the floor; kept {}",
keep.len()
);
assert!(
keep.contains(&4),
"the newest entry must survive regardless"
);
}
#[test]
fn budget_env_absent_or_blank_uses_the_default() {
for raw in [None, Some(""), Some(" "), Some("\t\n")] {
assert_eq!(
parse_snapshot_budget(raw),
Some(DEFAULT_MAX_SNAPSHOT_BYTES_PER_SECRET),
"raw={raw:?} must fall back to the default budget"
);
}
}
#[test]
fn budget_env_zero_disables_the_budget() {
assert_eq!(parse_snapshot_budget(Some("0")), None);
assert_eq!(parse_snapshot_budget(Some(" 0 ")), None);
}
#[test]
fn budget_env_accepts_explicit_byte_counts() {
assert_eq!(parse_snapshot_budget(Some("1")), Some(1));
assert_eq!(parse_snapshot_budget(Some("12345678")), Some(12_345_678));
assert_eq!(parse_snapshot_budget(Some(" 4096 ")), Some(4096));
assert_eq!(
parse_snapshot_budget(Some(&u64::MAX.to_string())),
Some(u64::MAX)
);
assert_eq!(parse_snapshot_budget(Some("+5")), Some(5));
}
#[test]
fn budget_env_garbage_degrades_to_the_default() {
for raw in [
"abc",
"-1",
"3.5",
"3MiB",
"1_000",
"0x10",
"99999999999999999999999", "5 bytes",
"",
" 3 MiB",
] {
assert_eq!(
parse_snapshot_budget(Some(raw)),
Some(DEFAULT_MAX_SNAPSHOT_BYTES_PER_SECRET),
"raw={raw:?} must degrade to the default budget"
);
}
}
#[test]
fn with_budget_from_applies_the_override() {
assert_eq!(
RetentionPolicy::with_budget_from(None).max_total_bytes,
Some(DEFAULT_MAX_SNAPSHOT_BYTES_PER_SECRET)
);
assert_eq!(
RetentionPolicy::with_budget_from(Some("7340032")).max_total_bytes,
Some(7 * 1024 * 1024)
);
assert_eq!(
RetentionPolicy::with_budget_from(Some("0")).max_total_bytes,
None
);
assert_eq!(
RetentionPolicy::with_budget_from(Some("three megabytes")).max_total_bytes,
Some(DEFAULT_MAX_SNAPSHOT_BYTES_PER_SECRET)
);
}
#[test]
fn with_budget_from_touches_only_the_budget() {
let d = RetentionPolicy::default();
let overridden = RetentionPolicy::with_budget_from(Some("4096"));
assert_eq!(overridden.max_total_bytes, Some(4096));
assert_eq!(overridden.keep_last, d.keep_last);
assert_eq!(overridden.max_age, d.max_age);
assert_eq!(overridden.buckets, d.buckets);
}
#[test]
fn default_policy_is_pure() {
assert_eq!(
RetentionPolicy::default().max_total_bytes,
Some(DEFAULT_MAX_SNAPSHOT_BYTES_PER_SECRET),
"default() must be the built-in constant, independent of the environment"
);
}
#[test]
fn env_read_lives_only_in_from_env() {
let src = include_str!("secret_snapshots.rs");
let slice_from = |key: &str, end: &str| -> String {
let start = src
.find(key)
.unwrap_or_else(|| panic!("source must still contain {key:?}"));
let rest = &src[start..];
let len = rest
.find(end)
.unwrap_or_else(|| panic!("no terminator {end:?} after {key:?}"));
rest[..len].to_string()
};
let from_env_body = slice_from(concat!("pub fn from_env()", " -> Self {"), "\n }\n");
assert!(
from_env_body.contains(concat!("std::env::", "var(SNAPSHOT_BUDGET_ENV)")),
"from_env must read SNAPSHOT_BUDGET_ENV, otherwise the operator \
override is dead code; body was:\n{from_env_body}"
);
let default_impl = slice_from(concat!("impl Default for ", "RetentionPolicy"), "\n}\n");
assert!(
!default_impl.contains(concat!("env::", "var")),
"Default for RetentionPolicy must stay pure — no environment read. \
Put the override in from_env instead; impl was:\n{default_impl}"
);
}
#[test]
fn without_byte_budget_drops_only_the_budget() {
let p = RetentionPolicy::default();
let stripped = p.without_byte_budget();
assert_eq!(stripped.max_total_bytes, None);
assert_eq!(stripped.keep_last, p.keep_last);
assert_eq!(stripped.max_age, p.max_age);
assert_eq!(stripped.buckets, p.buckets);
let now = SystemTime::now();
let entries: Vec<(SystemTime, u64)> = (0..5)
.map(|i| (t(now, 500 - i * 100), 10 * 1024 * 1024u64))
.collect();
assert_eq!(
stripped.select_keep_within_budget(now, &entries).len(),
5,
"50 MiB of history must survive when the budget is stripped"
);
assert!(
p.select_keep_within_budget(now, &entries).len() < 5,
"test is vacuous unless the un-stripped policy would have evicted"
);
}
#[test]
fn default_policy_bounds_bytes_per_secret() {
let p = RetentionPolicy::default();
let budget = p
.max_total_bytes
.expect("default policy must carry a byte budget");
let now = SystemTime::now();
let size = 1024 * 1024u64;
let entries: Vec<(SystemTime, u64)> =
(0..40).map(|i| (t(now, (39 - i) * 60), size)).collect();
let keep = p.select_keep_within_budget(now, &entries);
let total: u64 = keep.iter().map(|&i| entries[i].1).sum();
assert!(
total <= budget,
"default policy must bound per-secret snapshot bytes; kept {} entries = {total} bytes",
keep.len()
);
assert!(
!keep.is_empty(),
"default policy must still retain recoverable history"
);
let timestamps: Vec<SystemTime> = entries.iter().map(|(ts, _)| *ts).collect();
assert!(
p.select_keep(now, ×tamps).len() > keep.len(),
"test is vacuous unless the byte budget is what trimmed the set"
);
}
fn fs_budget_only(max_total_bytes: Option<u64>) -> RetentionPolicy {
RetentionPolicy {
keep_last: usize::MAX,
buckets: vec![],
max_age: None,
max_total_bytes,
}
}
#[test]
fn thin_snapshots_enforces_byte_budget() {
let dir = tempfile::tempdir().expect("tempdir");
let now_ms = recent_ms();
for i in 0..8u64 {
write_snapshot(dir.path(), now_ms - (8 - i) * 1000, &vec![b'x'; 1000]);
}
thin_snapshots(dir.path(), &fs_budget_only(Some(4500)), SystemTime::now());
let remaining = list_snapshots(dir.path()).expect("list");
assert_eq!(
remaining.len(),
4,
"4500 bytes fits exactly four 1000B files"
);
assert_eq!(
remaining.iter().map(|m| m.timestamp_ms).collect::<Vec<_>>(),
vec![now_ms - 4000, now_ms - 3000, now_ms - 2000, now_ms - 1000],
"the NEWEST four survive"
);
}
#[test]
fn thin_snapshots_byte_budget_stops_at_the_floor() {
let dir = tempfile::tempdir().expect("tempdir");
let now_ms = recent_ms();
for i in 0..5u64 {
write_snapshot(
dir.path(),
now_ms - (5 - i) * 1000,
&vec![b'a' + i as u8; 5000],
);
}
thin_snapshots(dir.path(), &fs_budget_only(Some(100)), SystemTime::now());
let remaining = list_snapshots(dir.path()).expect("list");
assert_eq!(
remaining.len(),
MIN_SNAPSHOTS_KEPT_UNDER_BUDGET,
"the floor keeps its minimum even when that busts the budget"
);
let newest = remaining.last().expect("floor keeps at least one");
assert_eq!(newest.timestamp_ms, now_ms - 1000, "newest survives");
assert_eq!(fs::read(&newest.path).unwrap(), vec![b'a' + 4; 5000]);
}
#[test]
fn thin_snapshots_byte_budget_keeps_a_lone_oversized_snapshot() {
let dir = tempfile::tempdir().expect("tempdir");
let now_ms = recent_ms();
write_snapshot(dir.path(), now_ms - 1000, &vec![b'b'; 50_000]);
thin_snapshots(dir.path(), &fs_budget_only(Some(100)), SystemTime::now());
let remaining = list_snapshots(dir.path()).expect("list");
assert_eq!(remaining.len(), 1, "history must never be emptied");
assert_eq!(fs::read(&remaining[0].path).unwrap(), vec![b'b'; 50_000]);
}
#[test]
fn thin_snapshots_orders_collision_suffixes_numerically() {
let dir = tempfile::tempdir().expect("tempdir");
let stamp = recent_ms();
let base = format!("{stamp:0width$}", width = SNAPSHOT_NAME_WIDTH);
fs::write(dir.path().join(&base), b"unsuffixed").unwrap();
for n in 0..=10u32 {
fs::write(dir.path().join(format!("{base}.{n}")), format!("body-{n}")).unwrap();
}
thin_snapshots(dir.path(), &fs_budget_only(Some(0)), SystemTime::now());
let remaining = list_snapshots(dir.path()).expect("list");
assert_eq!(
remaining.iter().map(|m| m.suffix).collect::<Vec<_>>(),
vec![Some(8), Some(9), Some(10)],
"lexicographic ordering would have kept .1/.10 and dropped .9"
);
}
fn parse_snapshot_stamp(path: &Path) -> Option<u64> {
parse_snapshot_name(path).map(|(ts, _)| ts)
}
#[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,
max_total_bytes: 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());
}
fn write_snapshot(snap_dir: &Path, stamp: u64, body: &[u8]) {
fs::create_dir_all(snap_dir).unwrap();
fs::write(
snap_dir.join(format!("{stamp:0width$}", width = SNAPSHOT_NAME_WIDTH)),
body,
)
.unwrap();
}
fn recent_ms() -> u64 {
(SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64)
- 60_000
}
#[test]
fn restore_snapshot_file_replaces_active_with_snapshot() {
let dir = tempfile::tempdir().expect("tempdir");
let delegate_dir = dir.path().join("delegateA");
let secret = "secretX";
write_snapshot(
&snapshot_dir_for_encoded(&delegate_dir, secret),
1000,
b"old-value",
);
fs::create_dir_all(&delegate_dir).unwrap();
fs::write(delegate_dir.join(secret), b"current-value").unwrap();
restore_snapshot_file(&delegate_dir, secret, 1000, None, true)
.expect("restore must succeed");
assert_eq!(fs::read(delegate_dir.join(secret)).unwrap(), b"old-value");
}
#[test]
fn restore_snapshot_file_is_reversible() {
let dir = tempfile::tempdir().expect("tempdir");
let delegate_dir = dir.path().join("delegateA");
let secret = "secretX";
let snap_dir = snapshot_dir_for_encoded(&delegate_dir, secret);
let stamp = recent_ms();
write_snapshot(&snap_dir, stamp, b"old-value");
fs::create_dir_all(&delegate_dir).unwrap();
fs::write(delegate_dir.join(secret), b"current-value").unwrap();
restore_snapshot_file(&delegate_dir, secret, stamp, None, true)
.expect("restore must succeed");
let snaps = list_snapshots(&snap_dir).expect("list");
assert!(
snaps.len() >= 2,
"reversibility snapshot missing; got {}",
snaps.len()
);
let bodies: Vec<Vec<u8>> = snaps.iter().map(|m| fs::read(&m.path).unwrap()).collect();
assert!(
bodies.iter().any(|b| b.as_slice() == b"current-value"),
"prior active value was not snapshotted"
);
}
#[test]
fn restore_snapshot_file_unknown_timestamp_is_not_found() {
let dir = tempfile::tempdir().expect("tempdir");
let delegate_dir = dir.path().join("delegateA");
let secret = "secretX";
write_snapshot(
&snapshot_dir_for_encoded(&delegate_dir, secret),
1000,
b"old",
);
fs::create_dir_all(&delegate_dir).unwrap();
fs::write(delegate_dir.join(secret), b"current").unwrap();
let err = restore_snapshot_file(&delegate_dir, secret, 999, None, true)
.expect_err("unknown timestamp must error");
assert!(matches!(err, RestoreError::NotFound(999)));
assert_eq!(fs::read(delegate_dir.join(secret)).unwrap(), b"current");
}
#[test]
fn restore_snapshot_file_missing_snapshot_dir_is_not_found() {
let dir = tempfile::tempdir().expect("tempdir");
let delegate_dir = dir.path().join("delegateA");
let err = restore_snapshot_file(&delegate_dir, "neversnapshotted", 1, None, true)
.expect_err("missing history must error");
assert!(matches!(err, RestoreError::NotFound(1)));
}
#[test]
fn restore_snapshot_file_prefers_unsuffixed_collision() {
let dir = tempfile::tempdir().expect("tempdir");
let delegate_dir = dir.path().join("delegateA");
let secret = "secretX";
let snap_dir = snapshot_dir_for_encoded(&delegate_dir, secret);
fs::create_dir_all(&snap_dir).unwrap();
let base = format!(
"{stamp:0width$}",
stamp = 50u64,
width = SNAPSHOT_NAME_WIDTH
);
fs::write(snap_dir.join(&base), b"unsuffixed").unwrap();
fs::write(snap_dir.join(format!("{base}.0")), b"suffix-zero").unwrap();
fs::create_dir_all(&delegate_dir).unwrap();
fs::write(delegate_dir.join(secret), b"current").unwrap();
restore_snapshot_file(&delegate_dir, secret, 50, None, false)
.expect("restore must succeed");
assert_eq!(fs::read(delegate_dir.join(secret)).unwrap(), b"unsuffixed");
}
#[test]
fn restore_snapshot_file_targets_explicit_suffix() {
let dir = tempfile::tempdir().expect("tempdir");
let delegate_dir = dir.path().join("delegateA");
let secret = "secretX";
let snap_dir = snapshot_dir_for_encoded(&delegate_dir, secret);
fs::create_dir_all(&snap_dir).unwrap();
let base = format!(
"{stamp:0width$}",
stamp = 50u64,
width = SNAPSHOT_NAME_WIDTH
);
fs::write(snap_dir.join(&base), b"unsuffixed").unwrap();
fs::write(snap_dir.join(format!("{base}.0")), b"suffix-zero").unwrap();
fs::write(snap_dir.join(format!("{base}.1")), b"suffix-one").unwrap();
fs::create_dir_all(&delegate_dir).unwrap();
fs::write(delegate_dir.join(secret), b"current").unwrap();
restore_snapshot_file(&delegate_dir, secret, 50, Some(1), false)
.expect("restore must succeed");
assert_eq!(fs::read(delegate_dir.join(secret)).unwrap(), b"suffix-one");
let err = restore_snapshot_file(&delegate_dir, secret, 50, Some(9), false)
.expect_err("missing suffix must error");
assert!(matches!(err, RestoreError::NotFound(50)));
assert_eq!(fs::read(delegate_dir.join(secret)).unwrap(), b"suffix-one");
}
#[test]
fn snapshot_active_value_missing_active_is_noop() {
let dir = tempfile::tempdir().expect("tempdir");
let delegate_dir = dir.path().join("delegateA");
let secret = "secretX";
snapshot_active_value(&delegate_dir, secret, &delegate_dir.join(secret))
.expect("missing active is not an error");
let snaps = list_snapshots(&snapshot_dir_for_encoded(&delegate_dir, secret)).expect("list");
assert!(
snaps.is_empty(),
"missing active must not produce a snapshot"
);
}
#[cfg(unix)]
#[test]
fn restore_snapshot_file_writes_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let delegate_dir = dir.path().join("delegateA");
let secret = "secretX";
let snap_dir = snapshot_dir_for_encoded(&delegate_dir, secret);
let stamp = recent_ms();
write_snapshot(&snap_dir, stamp, b"old-value");
fs::create_dir_all(&delegate_dir).unwrap();
fs::write(delegate_dir.join(secret), b"current-value").unwrap();
fs::set_permissions(delegate_dir.join(secret), fs::Permissions::from_mode(0o600)).unwrap();
restore_snapshot_file(&delegate_dir, secret, stamp, None, true)
.expect("restore must succeed");
let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode(&delegate_dir.join(secret)),
0o600,
"restored active secret must be owner-only"
);
assert_eq!(
mode(&delegate_dir.join(SNAPSHOTS_DIR)),
0o700,
".snapshots umbrella must be owner-only"
);
assert_eq!(
mode(&snap_dir),
0o700,
"per-secret snapshot dir must be owner-only"
);
}
#[test]
fn restore_snapshot_file_disabled_skips_reversibility_snapshot() {
let dir = tempfile::tempdir().expect("tempdir");
let delegate_dir = dir.path().join("delegateA");
let secret = "secretX";
let snap_dir = snapshot_dir_for_encoded(&delegate_dir, secret);
write_snapshot(&snap_dir, 1000, b"v1000");
fs::create_dir_all(&delegate_dir).unwrap();
fs::write(delegate_dir.join(secret), b"current").unwrap();
let before = list_snapshots(&snap_dir).unwrap().len();
restore_snapshot_file(&delegate_dir, secret, 1000, None, false)
.expect("restore must succeed");
assert_eq!(fs::read(delegate_dir.join(secret)).unwrap(), b"v1000");
assert_eq!(
list_snapshots(&snap_dir).unwrap().len(),
before,
"disabled restore must not add a reversibility snapshot"
);
fs::write(delegate_dir.join(secret), b"current2").unwrap();
restore_snapshot_file(&delegate_dir, secret, 1000, None, true)
.expect("restore must succeed");
assert_eq!(list_snapshots(&snap_dir).unwrap().len(), before + 1);
}
}