use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::fs::{self, OpenOptions};
use std::io::{self, Write as _};
use crate::atomic::{sync_parent_dir, write_atomic};
use crate::hash::{self, Hash};
use crate::layout::RepoLayout;
pub const RECOVERY_LOG: &str = "recovery-log";
pub const DEFAULT_GRACE_SECS: u64 = 90 * 24 * 60 * 60;
pub const DEFAULT_KEEP_LAST: usize = 50;
#[derive(Debug, thiserror::Error)]
pub enum RecoveryError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("malformed object id in recovery log: {0}")]
BadHash(#[from] hash::FromHexError),
#[error("malformed recovery-log line: {0}")]
Malformed(String),
#[error("recovery-log field {0} may not contain a tab or newline")]
InvalidField(&'static str),
}
type Result<T> = std::result::Result<T, RecoveryError>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecoveryEntry {
pub timestamp: u64,
pub op: String,
pub superseded: Hash,
pub branch: String,
}
#[derive(Debug, Clone, Copy)]
pub struct RetentionPolicy {
pub grace_secs: u64,
pub keep_last: usize,
}
impl Default for RetentionPolicy {
fn default() -> Self {
Self {
grace_secs: DEFAULT_GRACE_SECS,
keep_last: DEFAULT_KEEP_LAST,
}
}
}
pub fn record(layout: &RepoLayout, entry: &RecoveryEntry) -> Result<()> {
if entry.op.contains(['\t', '\n']) {
return Err(RecoveryError::InvalidField("op"));
}
if entry.branch.contains(['\t', '\n']) {
return Err(RecoveryError::InvalidField("branch"));
}
if entry.superseded == hash::ZERO {
return Ok(());
}
fs::create_dir_all(layout.common_dir())?;
let line = format!(
"{}\t{}\t{}\t{}\n",
entry.timestamp,
entry.op,
hash::to_hex(&entry.superseded),
entry.branch,
);
let path = layout.recovery_log_file();
let mut f = OpenOptions::new().create(true).append(true).open(&path)?;
f.write_all(line.as_bytes())?;
f.sync_all()?;
sync_parent_dir(layout.common_dir())?;
Ok(())
}
pub fn read_all(layout: &RepoLayout) -> Result<Vec<RecoveryEntry>> {
let raw = match fs::read_to_string(layout.recovery_log_file()) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
raw.lines()
.filter(|l| !l.is_empty())
.map(parse_line)
.collect()
}
pub fn roots(layout: &RepoLayout) -> Result<BTreeSet<Hash>> {
Ok(read_all(layout)?
.into_iter()
.map(|e| e.superseded)
.filter(|h| *h != hash::ZERO)
.collect())
}
fn is_retained(
i: usize,
e: &RecoveryEntry,
total: usize,
now: u64,
policy: &RetentionPolicy,
) -> bool {
let fresh = now.saturating_sub(e.timestamp) <= policy.grace_secs;
let keep_floor = total.saturating_sub(policy.keep_last);
fresh || i >= keep_floor
}
pub fn would_expire(layout: &RepoLayout, now: u64, policy: &RetentionPolicy) -> Result<usize> {
let all = read_all(layout)?;
let total = all.len();
Ok(all
.iter()
.enumerate()
.filter(|(i, e)| !is_retained(*i, e, total, now, policy))
.count())
}
pub fn expire(layout: &RepoLayout, now: u64, policy: &RetentionPolicy) -> Result<usize> {
let all = read_all(layout)?;
let total = all.len();
if total == 0 {
return Ok(0);
}
let kept: Vec<RecoveryEntry> = all
.into_iter()
.enumerate()
.filter(|(i, e)| is_retained(*i, e, total, now, policy))
.map(|(_, e)| e)
.collect();
let pruned = total - kept.len();
if pruned == 0 {
return Ok(0);
}
let mut buf = String::new();
for e in &kept {
let _ = writeln!(
buf,
"{}\t{}\t{}\t{}",
e.timestamp,
e.op,
hash::to_hex(&e.superseded),
e.branch
);
}
write_atomic(&layout.recovery_log_file(), buf.as_bytes(), true)?;
Ok(pruned)
}
fn parse_line(line: &str) -> Result<RecoveryEntry> {
let mut it = line.splitn(4, '\t');
let ts = it.next().ok_or_else(|| malformed(line))?;
let op = it.next().ok_or_else(|| malformed(line))?;
let hex = it.next().ok_or_else(|| malformed(line))?;
let branch = it.next().ok_or_else(|| malformed(line))?;
let timestamp = ts.parse::<u64>().map_err(|_| malformed(line))?;
let superseded = hash::from_hex(hex)?;
Ok(RecoveryEntry {
timestamp,
op: op.to_owned(),
superseded,
branch: branch.to_owned(),
})
}
fn malformed(line: &str) -> RecoveryError {
RecoveryError::Malformed(line.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn md() -> (TempDir, RepoLayout) {
let d = TempDir::new().unwrap();
let md = RepoLayout::single(d.path());
fs::create_dir_all(md.common_dir()).unwrap();
(d, md)
}
fn h(byte: u8) -> Hash {
[byte; 32]
}
fn entry(ts: u64, byte: u8) -> RecoveryEntry {
RecoveryEntry {
timestamp: ts,
op: "amend".into(),
superseded: h(byte),
branch: "main".into(),
}
}
#[test]
fn record_then_read_roundtrips_in_order() {
let (_d, md) = md();
record(&md, &entry(100, 1)).unwrap();
record(&md, &entry(200, 2)).unwrap();
let all = read_all(&md).unwrap();
assert_eq!(all, vec![entry(100, 1), entry(200, 2)]);
assert_eq!(roots(&md).unwrap(), BTreeSet::from([h(1), h(2)]));
}
#[test]
fn absent_log_is_empty_not_error() {
let (_d, md) = md();
assert!(read_all(&md).unwrap().is_empty());
assert!(roots(&md).unwrap().is_empty());
}
#[test]
fn record_rejects_tab_in_fields_and_skips_zero_hash() {
let (_d, md) = md();
let mut e = entry(1, 1);
e.branch = "a\tb".into();
assert!(matches!(
record(&md, &e),
Err(RecoveryError::InvalidField("branch"))
));
record(&md, &entry(1, 0)).unwrap();
assert!(roots(&md).unwrap().is_empty());
}
#[test]
fn read_fails_closed_on_corrupt_line() {
let (_d, md) = md();
fs::write(md.recovery_log_file(), "not-a-valid-line\n").unwrap();
assert!(read_all(&md).is_err(), "corrupt log must fail closed");
}
#[test]
fn expire_drops_old_entries_outside_keep_last() {
let (_d, md) = md();
record(&md, &entry(10, 1)).unwrap();
record(&md, &entry(850, 2)).unwrap();
record(&md, &entry(900, 3)).unwrap();
let pruned = expire(
&md,
1000,
&RetentionPolicy {
grace_secs: 200,
keep_last: 1,
},
)
.unwrap();
assert_eq!(pruned, 1, "the ts=10 entry is old and not in last-1");
assert_eq!(roots(&md).unwrap(), BTreeSet::from([h(2), h(3)]));
}
#[test]
fn would_expire_counts_without_mutating() {
let (_d, md) = md();
record(&md, &entry(10, 1)).unwrap();
record(&md, &entry(850, 2)).unwrap();
record(&md, &entry(900, 3)).unwrap();
let policy = RetentionPolicy {
grace_secs: 200,
keep_last: 1,
};
let before = read_all(&md).unwrap();
let n = would_expire(&md, 1000, &policy).unwrap();
assert_eq!(n, 1, "ts=10 is the only expirable entry");
assert_eq!(
read_all(&md).unwrap(),
before,
"would_expire must not mutate"
);
}
#[test]
fn expire_keep_last_retains_old_recent_entries() {
let (_d, md) = md();
record(&md, &entry(1, 1)).unwrap();
record(&md, &entry(2, 2)).unwrap();
let pruned = expire(
&md,
1_000_000,
&RetentionPolicy {
grace_secs: 0,
keep_last: 5,
},
)
.unwrap();
assert_eq!(pruned, 0);
assert_eq!(roots(&md).unwrap(), BTreeSet::from([h(1), h(2)]));
}
}