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;
#[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_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) {
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))
}
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,
};
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());
}
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);
}
}