use chrono::{DateTime, Duration, Utc};
use httpdate::parse_http_date;
use crate::error::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct VerifyPolicy {
pub max_age: Option<Duration>,
pub max_clock_skew_future: Option<Duration>,
pub require_timestamp: bool,
}
impl VerifyPolicy {
#[must_use]
pub const fn mastodon() -> Self {
Self {
max_age: Some(Duration::hours(12)),
max_clock_skew_future: Some(Duration::minutes(5)),
require_timestamp: false,
}
}
#[must_use]
pub const fn strict() -> Self {
Self {
max_age: Some(Duration::minutes(5)),
max_clock_skew_future: Some(Duration::minutes(1)),
require_timestamp: true,
}
}
#[must_use]
pub const fn no_freshness_check() -> Self {
Self {
max_age: None,
max_clock_skew_future: None,
require_timestamp: false,
}
}
pub fn check(
&self,
created_unix: Option<i64>,
expires_unix: Option<i64>,
date_header: Option<&str>,
now: DateTime<Utc>,
) -> Result<(), Error> {
let reference = created_unix
.and_then(unix_to_datetime)
.or_else(|| date_header.and_then(parse_date_header));
let Some(reference) = reference else {
if self.require_timestamp {
return Err(Error::TimestampMissing);
}
return Ok(());
};
if let Some(future_skew) = self.max_clock_skew_future
&& reference > now + future_skew
{
return Err(Error::TimestampInFuture {
timestamp: reference,
now,
});
}
if let Some(max_age) = self.max_age
&& now.signed_duration_since(reference) > max_age
{
return Err(Error::TimestampTooOld {
timestamp: reference,
now,
});
}
if let Some(expires_unix) = expires_unix
&& let Some(expires) = unix_to_datetime(expires_unix)
&& now > expires
{
return Err(Error::TimestampExpired { expires, now });
}
Ok(())
}
}
impl Default for VerifyPolicy {
fn default() -> Self {
Self::mastodon()
}
}
const fn unix_to_datetime(seconds: i64) -> Option<DateTime<Utc>> {
DateTime::<Utc>::from_timestamp(seconds, 0)
}
fn parse_date_header(value: &str) -> Option<DateTime<Utc>> {
let system_time = parse_http_date(value).ok()?;
Some(DateTime::<Utc>::from(system_time))
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
fn now() -> DateTime<Utc> {
DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid UNIX time")
}
#[test]
fn default_is_mastodon_policy() {
assert_eq!(VerifyPolicy::default(), VerifyPolicy::mastodon());
}
#[test]
fn fresh_signature_with_created_passes() {
let policy = VerifyPolicy::mastodon();
let created = now().timestamp() - 3600;
policy
.check(Some(created), None, None, now())
.expect("fresh");
}
#[test]
fn too_old_signature_is_rejected() {
let policy = VerifyPolicy::mastodon();
let created = now().timestamp() - 13 * 3600;
let err = policy
.check(Some(created), None, None, now())
.expect_err("too old");
assert!(matches!(err, Error::TimestampTooOld { .. }));
}
#[test]
fn signature_in_the_future_is_rejected() {
let policy = VerifyPolicy::mastodon();
let created = now().timestamp() + 10 * 60;
let err = policy
.check(Some(created), None, None, now())
.expect_err("future");
assert!(matches!(err, Error::TimestampInFuture { .. }));
}
#[test]
fn expires_in_the_past_is_rejected() {
let policy = VerifyPolicy::mastodon();
let created = now().timestamp() - 60;
let expires = now().timestamp() - 30;
let err = policy
.check(Some(created), Some(expires), None, now())
.expect_err("expired");
assert!(matches!(err, Error::TimestampExpired { .. }));
}
#[test]
fn date_header_is_used_when_created_is_absent() {
let policy = VerifyPolicy::mastodon();
let ts = DateTime::<Utc>::from_timestamp(now().timestamp() - 3600, 0).expect("valid");
let header = httpdate::fmt_http_date(std::time::SystemTime::from(ts));
policy
.check(None, None, Some(&header), now())
.expect("date-header fallback");
}
#[test]
fn missing_timestamp_passes_by_default() {
let policy = VerifyPolicy::mastodon();
policy.check(None, None, None, now()).expect("tolerated");
}
#[test]
fn missing_timestamp_fails_under_strict_policy() {
let policy = VerifyPolicy::strict();
let err = policy.check(None, None, None, now()).expect_err("required");
assert!(matches!(err, Error::TimestampMissing));
}
#[test]
fn malformed_date_header_is_ignored_and_treated_as_absent() {
let policy = VerifyPolicy::mastodon();
policy
.check(None, None, Some("not a date"), now())
.expect("ignored");
}
#[test]
fn no_freshness_check_preset_accepts_stale_timestamps() {
let policy = VerifyPolicy::no_freshness_check();
let stale = now().timestamp() - 100 * 365 * 24 * 3600;
policy
.check(Some(stale), None, None, now())
.expect("stale OK");
}
}