use std::fs;
use std::path::{Path, PathBuf};
use auths_keri::{Said, UsageCap};
use serde::{Deserialize, Serialize};
use crate::domains::credentials::error::CredentialError;
const USAGE_LEDGER_DIR: &str = "usage-ledger";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UsageObservation {
pub calls_used: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsageDecision {
Admitted {
cap: UsageCap,
admitted_at: u64,
},
CapExceeded {
cap: UsageCap,
observed: u64,
},
RolledBack {
observed: u64,
high_water: u64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct UsageRecord {
said: String,
high_water: u64,
}
pub struct UsageLedger {
dir: PathBuf,
}
impl UsageLedger {
pub fn new(repo_path: &Path) -> Self {
Self {
dir: repo_path.join(USAGE_LEDGER_DIR),
}
}
fn record_path(&self, said: &Said) -> Result<PathBuf, CredentialError> {
let name = said.as_str();
let safe = !name.is_empty()
&& name != "."
&& name != ".."
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if !safe {
return Err(CredentialError::StaleOrUnresolvable {
reason: format!("usage ledger: refusing unsafe credential SAID '{name}'"),
});
}
Ok(self.dir.join(format!("{name}.json")))
}
fn read_high_water(&self, said: &Said) -> Result<Option<u64>, CredentialError> {
let path = self.record_path(said)?;
let bytes = match fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(CredentialError::StaleOrUnresolvable {
reason: format!("usage ledger read failed: {e}"),
});
}
};
let record: UsageRecord =
serde_json::from_slice(&bytes).map_err(|e| CredentialError::StaleOrUnresolvable {
reason: format!("usage ledger record parse failed: {e}"),
})?;
Ok(Some(record.high_water))
}
fn write_high_water(&self, said: &Said, high_water: u64) -> Result<(), CredentialError> {
fs::create_dir_all(&self.dir).map_err(|e| CredentialError::StaleOrUnresolvable {
reason: format!("usage ledger mkdir failed: {e}"),
})?;
let path = self.record_path(said)?;
let record = UsageRecord {
said: said.as_str().to_string(),
high_water,
};
let body = serde_json::to_vec_pretty(&record).map_err(|e| {
CredentialError::StaleOrUnresolvable {
reason: format!("usage ledger record encode failed: {e}"),
}
})?;
let tmp = self.dir.join(format!(".{}.tmp", said.as_str()));
fs::write(&tmp, &body).map_err(|e| CredentialError::StaleOrUnresolvable {
reason: format!("usage ledger temp write failed: {e}"),
})?;
fs::rename(&tmp, &path).map_err(|e| CredentialError::StaleOrUnresolvable {
reason: format!("usage ledger commit (rename) failed: {e}"),
})?;
Ok(())
}
pub fn enforce(
&self,
said: &Said,
cap: UsageCap,
observed: UsageObservation,
) -> Result<UsageDecision, CredentialError> {
let used = observed.calls_used;
if used >= cap.max_calls() {
return Ok(UsageDecision::CapExceeded {
cap,
observed: used,
});
}
let high_water = self.read_high_water(said)?;
if let Some(mark) = high_water
&& used < mark
{
return Ok(UsageDecision::RolledBack {
observed: used,
high_water: mark,
});
}
self.write_high_water(said, used)?;
Ok(UsageDecision::Admitted {
cap,
admitted_at: used,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use auths_keri::Capability;
fn said(s: &str) -> Said {
Said::new_unchecked(s.to_string())
}
fn cap3() -> UsageCap {
UsageCap::from_capability(&Capability::parse("calls:3").unwrap()).unwrap()
}
#[test]
fn within_cap_counts_are_admitted_in_order() {
let dir = tempfile::tempdir().unwrap();
let ledger = UsageLedger::new(dir.path());
let s = said("ECredOne");
for n in 0..3 {
let d = ledger
.enforce(&s, cap3(), UsageObservation { calls_used: n })
.unwrap();
assert!(
matches!(d, UsageDecision::Admitted { admitted_at, .. } if admitted_at == n),
"count {n} should be admitted, got {d:?}"
);
}
}
#[test]
fn the_nth_plus_one_use_is_cap_exceeded() {
let dir = tempfile::tempdir().unwrap();
let ledger = UsageLedger::new(dir.path());
let s = said("ECredTwo");
for n in 0..3 {
ledger
.enforce(&s, cap3(), UsageObservation { calls_used: n })
.unwrap();
}
let d = ledger
.enforce(&s, cap3(), UsageObservation { calls_used: 3 })
.unwrap();
assert!(
matches!(d, UsageDecision::CapExceeded { observed: 3, .. }),
"4th use must be cap-exceeded, got {d:?}"
);
}
#[test]
fn replayed_opening_counter_is_rejected_after_cap_spent() {
let dir = tempfile::tempdir().unwrap();
let ledger = UsageLedger::new(dir.path());
let s = said("ECredThree");
for n in 0..3 {
ledger
.enforce(&s, cap3(), UsageObservation { calls_used: n })
.unwrap();
}
let d = ledger
.enforce(&s, cap3(), UsageObservation { calls_used: 0 })
.unwrap();
assert!(
matches!(
d,
UsageDecision::RolledBack {
observed: 0,
high_water: 2
}
),
"replay of count=0 after spend must be rolled-back, got {d:?}"
);
}
#[test]
fn cap_exceeded_does_not_raise_high_water() {
let dir = tempfile::tempdir().unwrap();
let ledger = UsageLedger::new(dir.path());
let s = said("ECredFour");
ledger
.enforce(&s, cap3(), UsageObservation { calls_used: 0 })
.unwrap();
ledger
.enforce(&s, cap3(), UsageObservation { calls_used: 5 })
.unwrap();
let d = ledger
.enforce(&s, cap3(), UsageObservation { calls_used: 1 })
.unwrap();
assert!(matches!(d, UsageDecision::Admitted { admitted_at: 1, .. }));
}
#[test]
fn re_presenting_the_high_water_count_is_admitted_not_rolled_back() {
let dir = tempfile::tempdir().unwrap();
let ledger = UsageLedger::new(dir.path());
let s = said("ECredFive");
ledger
.enforce(&s, cap3(), UsageObservation { calls_used: 0 })
.unwrap();
ledger
.enforce(&s, cap3(), UsageObservation { calls_used: 1 })
.unwrap();
let d = ledger
.enforce(&s, cap3(), UsageObservation { calls_used: 1 })
.unwrap();
assert!(matches!(d, UsageDecision::Admitted { admitted_at: 1, .. }));
}
#[test]
fn unsafe_said_is_refused() {
let dir = tempfile::tempdir().unwrap();
let ledger = UsageLedger::new(dir.path());
let bad = said("../escape");
let r = ledger.enforce(&bad, cap3(), UsageObservation { calls_used: 0 });
assert!(r.is_err(), "path-traversal SAID must be refused");
}
}