use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LeaseState {
Current,
Warning { exp: Option<i64> },
Expired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum LeaseError {
#[error("lease refresh_after {refresh_after} is outside (iat {iat}, midpoint of exp {exp}]")]
RefreshOutsideWindow { iat: i64, refresh_after: i64, exp: i64 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lease {
refresh_after: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
exp: Option<i64>,
}
impl Lease {
pub fn new(iat: i64, refresh_after: i64, exp: Option<i64>) -> Result<Self, LeaseError> {
let lease = Self { refresh_after, exp };
lease.validate(iat)?;
Ok(lease)
}
pub fn validate(&self, iat: i64) -> Result<(), LeaseError> {
if let Some(exp) = self.exp {
let midpoint = iat.saturating_add(exp.saturating_sub(iat) / 2);
if !(iat < self.refresh_after && self.refresh_after <= midpoint) {
return Err(LeaseError::RefreshOutsideWindow { iat, refresh_after: self.refresh_after, exp });
}
}
Ok(())
}
pub fn refresh_after(&self) -> i64 {
self.refresh_after
}
pub fn exp(&self) -> Option<i64> {
self.exp
}
pub fn state_at(&self, now: i64) -> LeaseState {
if self.exp.is_some_and(|exp| now >= exp) {
LeaseState::Expired
} else if now >= self.refresh_after {
LeaseState::Warning { exp: self.exp }
} else {
LeaseState::Current
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_without_exp_never_expires() {
let l = Lease::new(1_000, 2_000, None).unwrap();
assert_eq!(l.state_at(1_999), LeaseState::Current);
assert_eq!(l.state_at(2_000), LeaseState::Warning { exp: None });
assert_eq!(l.state_at(i64::MAX), LeaseState::Warning { exp: None });
}
#[test]
fn state_at_each_boundary_with_exp() {
let l = Lease::new(1_000, 1_500, Some(2_000)).unwrap();
assert_eq!(l.state_at(1_499), LeaseState::Current);
assert_eq!(l.state_at(1_500), LeaseState::Warning { exp: Some(2_000) });
assert_eq!(l.state_at(1_999), LeaseState::Warning { exp: Some(2_000) });
assert_eq!(l.state_at(2_000), LeaseState::Expired);
}
#[test]
fn refresh_after_is_bounded_by_the_midpoint() {
assert!(Lease::new(1_000, 1_500, Some(2_000)).is_ok());
assert!(matches!(Lease::new(1_000, 1_501, Some(2_000)), Err(LeaseError::RefreshOutsideWindow { .. })));
assert!(Lease::new(1_000, 1_000, Some(2_000)).is_err());
assert!(Lease::new(1_000, 999, Some(2_000)).is_err());
assert!(Lease::new(1_000, 999, None).is_ok());
}
#[test]
fn wire_omits_exp_when_none() {
let bare = serde_json::to_string(&Lease::new(1, 2, None).unwrap()).unwrap();
assert_eq!(bare, r#"{"refresh_after":2}"#);
let full = serde_json::to_string(&Lease::new(0, 5, Some(10)).unwrap()).unwrap();
assert_eq!(full, r#"{"refresh_after":5,"exp":10}"#);
assert_eq!(serde_json::from_str::<Lease>(&bare).unwrap().exp(), None);
}
}