use jiff::fmt::rfc2822;
use super::cache_control::CacheControl;
use crate::config::CacheConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TtlDecision {
Cache { expires_at: i64 },
DoNotCache,
}
pub fn compute_ttl(
now: i64,
host: &str,
cache_control: &str,
expires_header: Option<&str>,
cfg: &CacheConfig,
) -> TtlDecision {
let cc = CacheControl::parse(cache_control);
let no_store_overridden = if cc.no_store {
let host_override = cfg
.override_no_store_domains
.iter()
.any(|d| d.eq_ignore_ascii_case(host));
if !cfg.override_no_store && !host_override {
return TtlDecision::DoNotCache;
}
true
} else {
false
};
let mut ttl_secs = if let Some(s) = cc.s_maxage {
s
} else if let Some(m) = cc.max_age {
m
} else if no_store_overridden {
0
} else if let Some(t) = expires_header.and_then(parse_expires_header) {
if t <= now {
return TtlDecision::DoNotCache;
}
(t - now) as u64
} else {
cfg.default_ttl.as_secs()
};
let min = cfg.min_ttl.as_secs();
if ttl_secs < min {
ttl_secs = min;
}
let max = cfg.max_ttl.as_secs();
if ttl_secs > max {
ttl_secs = max;
}
let expires_at = now.saturating_add(ttl_secs as i64);
TtlDecision::Cache { expires_at }
}
fn parse_expires_header(value: &str) -> Option<i64> {
rfc2822::parse(value)
.ok()
.map(|z| z.timestamp().as_second())
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
fn cfg() -> CacheConfig {
CacheConfig {
default_ttl: Duration::from_secs(3600),
min_ttl: Duration::from_secs(300),
max_ttl: Duration::from_secs(7 * 86400),
stale_while_revalidate_window: Duration::from_secs(300),
override_no_store: false,
override_no_store_domains: vec![],
store_raw_html: false,
}
}
#[test]
fn no_store_skips_cache() {
let d = compute_ttl(0, "example.com", "no-store", None, &cfg());
assert_eq!(d, TtlDecision::DoNotCache);
}
#[test]
fn no_store_overridden_floors_min_ttl() {
let mut c = cfg();
c.override_no_store = true;
let d = compute_ttl(0, "example.com", "no-store", None, &c);
assert_eq!(d, TtlDecision::Cache { expires_at: 300 });
}
#[test]
fn no_store_per_domain_override() {
let mut c = cfg();
c.override_no_store_domains = vec!["docs.example.com".into()];
let d = compute_ttl(0, "DOCS.example.com", "no-store, max-age=60", None, &c);
assert_eq!(d, TtlDecision::Cache { expires_at: 300 });
}
#[test]
fn max_age_used_when_present() {
let d = compute_ttl(1_000, "x", "max-age=600", None, &cfg());
assert_eq!(d, TtlDecision::Cache { expires_at: 1_600 });
}
#[test]
fn s_maxage_overrides_max_age() {
let d = compute_ttl(0, "x", "max-age=60, s-maxage=120", None, &cfg());
assert_eq!(d, TtlDecision::Cache { expires_at: 300 });
}
#[test]
fn expires_header_used_without_cache_control() {
let d = compute_ttl(0, "x", "", Some("Mon, 1 Jan 2035 00:00:00 GMT"), &cfg());
assert_eq!(
d,
TtlDecision::Cache {
expires_at: 7 * 86400
}
);
}
#[test]
fn expires_header_within_max_ttl_used_directly() {
let d = compute_ttl(
1_700_000_000,
"x",
"",
Some("Sun, 14 Nov 2023 22:30:00 GMT"),
&cfg(),
);
match d {
TtlDecision::Cache { expires_at } => {
assert!(
expires_at > 1_700_000_000,
"expires_at={expires_at} should be > now"
);
assert!(
expires_at < 1_700_000_000 + 7 * 86400,
"expires_at={expires_at} should be below now + max_ttl"
);
}
other => panic!("expected Cache, got {other:?}"),
}
}
#[test]
fn falls_back_to_default_ttl() {
let d = compute_ttl(0, "x", "", None, &cfg());
assert_eq!(d, TtlDecision::Cache { expires_at: 3600 });
}
#[test]
fn caps_at_max_ttl() {
let d = compute_ttl(0, "x", "max-age=99999999", None, &cfg());
assert_eq!(
d,
TtlDecision::Cache {
expires_at: 7 * 86400
}
);
}
#[test]
fn floors_at_min_ttl() {
let d = compute_ttl(0, "x", "max-age=10", None, &cfg());
assert_eq!(d, TtlDecision::Cache { expires_at: 300 });
}
#[test]
fn past_expires_skips_cache() {
let d = compute_ttl(
1_700_000_000,
"x",
"",
Some("Sat, 1 Jan 2000 00:00:00 GMT"),
&cfg(),
);
assert_eq!(d, TtlDecision::DoNotCache);
}
}