use chrono::{DateTime, Utc};
use reqwest::header::HeaderMap;
use std::time::Duration;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct CacheHeadersState {
pub etag: Option<String>,
pub last_modified: Option<String>,
pub fresh_for: Option<Duration>,
pub no_cache: bool,
}
impl CacheHeadersState {
pub(crate) fn from_headers(headers: &HeaderMap, now: DateTime<Utc>) -> Self {
let etag = headers
.get(reqwest::header::ETAG)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned);
let last_modified = headers
.get(reqwest::header::LAST_MODIFIED)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned);
let (cc_max_age, no_cache) = headers.get_all(reqwest::header::CACHE_CONTROL).iter().fold(
(None::<u64>, false),
|(prev_max_age, prev_no_cache), v| {
let Some(s) = v.to_str().ok() else {
return (prev_max_age, prev_no_cache);
};
let (this_max_age, this_no_cache) = parse_cache_control(s);
let merged_max_age = match (prev_max_age, this_max_age) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, b) => b,
};
(merged_max_age, prev_no_cache || this_no_cache)
},
);
let mut fresh_for = if let Some(secs) = cc_max_age {
Some(Duration::from_secs(secs))
} else if let Some(expires_at) = headers
.get(reqwest::header::EXPIRES)
.and_then(|v| v.to_str().ok())
.and_then(parse_http_date)
{
let reference_time = headers
.get(reqwest::header::DATE)
.and_then(|v| v.to_str().ok())
.and_then(parse_http_date)
.unwrap_or(now);
let delta = expires_at.signed_duration_since(reference_time);
Some(if delta.num_seconds() > 0 {
Duration::from_secs(delta.num_seconds().cast_unsigned())
} else {
Duration::ZERO
})
} else {
None
};
if no_cache {
fresh_for = Some(Duration::ZERO);
}
Self {
etag,
last_modified,
fresh_for,
no_cache,
}
}
pub(crate) fn has_validator(&self) -> bool {
self.etag.is_some() || self.last_modified.is_some()
}
pub(crate) fn merge_from(&mut self, other: Self) {
if other.etag.is_some() {
self.etag = other.etag;
}
if other.last_modified.is_some() {
self.last_modified = other.last_modified;
}
self.fresh_for = other.fresh_for;
if other.no_cache {
self.no_cache = true;
}
}
}
fn parse_cache_control(value: &str) -> (Option<u64>, bool) {
let mut max_age: Option<u64> = None;
let mut no_cache = false;
for raw in value.split(',') {
let directive = raw.trim();
if directive.is_empty() {
continue;
}
let lower = directive.to_ascii_lowercase();
if lower == "no-cache" || lower == "no-store" {
no_cache = true;
} else if let Some(rest) = lower.strip_prefix("max-age=") {
let trimmed = rest.trim().trim_matches('"');
if let Ok(secs) = trimmed.parse::<u64>() {
max_age = Some(secs);
}
}
}
(max_age, no_cache)
}
fn parse_http_date(value: &str) -> Option<DateTime<Utc>> {
let trimmed = value.trim();
if let Ok(dt) = DateTime::parse_from_rfc2822(trimmed) {
return Some(dt.with_timezone(&Utc));
}
if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
return Some(dt.with_timezone(&Utc));
}
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(trimmed, "%A, %d-%b-%y %H:%M:%S GMT") {
return Some(DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc));
}
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(trimmed, "%a %b %e %H:%M:%S %Y") {
return Some(DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use std::str::FromStr;
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut h = HeaderMap::new();
for (k, v) in pairs {
h.append(
HeaderName::from_str(k).unwrap(),
HeaderValue::from_str(v).unwrap(),
);
}
h
}
fn t0() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-05-22T12:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
#[test]
fn empty_headers_produce_empty_validators() {
let v = CacheHeadersState::from_headers(&HeaderMap::new(), t0());
assert_eq!(v, CacheHeadersState::default());
assert!(!v.has_validator());
}
#[test]
fn etag_and_last_modified_are_captured() {
let h = headers(&[
("etag", "\"abc123\""),
("last-modified", "Sun, 06 Nov 1994 08:49:37 GMT"),
]);
let v = CacheHeadersState::from_headers(&h, t0());
assert_eq!(v.etag.as_deref(), Some("\"abc123\""));
assert_eq!(
v.last_modified.as_deref(),
Some("Sun, 06 Nov 1994 08:49:37 GMT")
);
assert!(v.has_validator());
}
#[test]
fn weak_etag_preserved_verbatim() {
let h = headers(&[("etag", "W/\"weak-tag\"")]);
let v = CacheHeadersState::from_headers(&h, t0());
assert_eq!(v.etag.as_deref(), Some("W/\"weak-tag\""));
}
#[test]
fn cache_control_max_age_overrides_expires() {
let h = headers(&[
("cache-control", "max-age=600"),
("expires", "Sun, 22 May 2027 12:00:00 GMT"),
]);
let v = CacheHeadersState::from_headers(&h, t0());
assert_eq!(v.fresh_for, Some(Duration::from_secs(600)));
}
#[test]
fn cache_control_no_cache_zeros_freshness() {
let h = headers(&[("cache-control", "max-age=600, no-cache")]);
let v = CacheHeadersState::from_headers(&h, t0());
assert!(v.no_cache);
assert_eq!(v.fresh_for, Some(Duration::ZERO));
}
#[test]
fn cache_control_no_store_zeros_freshness_alongside_max_age() {
let h = headers(&[("cache-control", "no-store, max-age=600")]);
let v = CacheHeadersState::from_headers(&h, t0());
assert!(v.no_cache, "no-store must set no_cache so revalidation fires");
assert_eq!(
v.fresh_for,
Some(Duration::ZERO),
"no-store must zero freshness even when max-age is present",
);
}
#[test]
fn cache_control_split_across_multiple_header_lines_is_honored() {
let h = headers(&[
("cache-control", "max-age=600"),
("cache-control", "no-store"),
]);
let v = CacheHeadersState::from_headers(&h, t0());
assert!(
v.no_cache,
"no-store on a second Cache-Control header line must set no_cache",
);
assert_eq!(
v.fresh_for,
Some(Duration::ZERO),
"no-store from any field-value must force zero freshness",
);
}
#[test]
fn cache_control_min_max_age_wins_across_lines() {
let h = headers(&[
("cache-control", "max-age=600"),
("cache-control", "max-age=60"),
]);
let v = CacheHeadersState::from_headers(&h, t0());
assert_eq!(
v.fresh_for,
Some(Duration::from_secs(60)),
"smallest max-age across lines must win, got {:?}",
v.fresh_for,
);
}
#[test]
fn merge_from_preserves_prior_validators_when_new_is_empty() {
let mut prior = CacheHeadersState {
etag: Some("\"v1\"".to_string()),
last_modified: Some("Mon, 01 Jan 2024 00:00:00 GMT".to_string()),
fresh_for: Some(Duration::from_secs(60)),
no_cache: false,
};
let incoming = CacheHeadersState {
fresh_for: Some(Duration::from_secs(120)),
..CacheHeadersState::default()
};
prior.merge_from(incoming);
assert_eq!(
prior.etag.as_deref(),
Some("\"v1\""),
"ETag must survive a 304 that omits the field",
);
assert_eq!(
prior.last_modified.as_deref(),
Some("Mon, 01 Jan 2024 00:00:00 GMT"),
"Last-Modified must survive a 304 that omits the field",
);
assert_eq!(
prior.fresh_for,
Some(Duration::from_secs(120)),
"fresh_for present in the new response must overwrite the prior value",
);
}
#[test]
fn merge_from_clears_stale_fresh_for_when_new_response_omits_it() {
let mut prior = CacheHeadersState {
etag: Some("\"v1\"".to_string()),
last_modified: Some("Mon".to_string()),
fresh_for: Some(Duration::from_secs(10)),
no_cache: false,
};
let incoming = CacheHeadersState {
etag: Some("\"v1\"".to_string()),
..CacheHeadersState::default()
};
prior.merge_from(incoming);
assert_eq!(
prior.fresh_for, None,
"fresh_for must clear when the new response makes no freshness claim — otherwise the old per-response max-age locks the worker cadence indefinitely",
);
assert_eq!(
prior.etag.as_deref(),
Some("\"v1\""),
"validators must remain sticky even when fresh_for clears",
);
assert_eq!(
prior.last_modified.as_deref(),
Some("Mon"),
"Last-Modified must remain sticky even when fresh_for clears",
);
}
#[test]
fn merge_from_overwrites_when_new_provides_value() {
let mut prior = CacheHeadersState {
etag: Some("\"v1\"".to_string()),
..CacheHeadersState::default()
};
let incoming = CacheHeadersState {
etag: Some("\"v2\"".to_string()),
..CacheHeadersState::default()
};
prior.merge_from(incoming);
assert_eq!(
prior.etag.as_deref(),
Some("\"v2\""),
"new ETag must replace the prior one when present",
);
}
#[test]
fn merge_from_no_cache_is_sticky_within_a_session() {
let mut prior = CacheHeadersState {
no_cache: true,
..CacheHeadersState::default()
};
let incoming = CacheHeadersState {
fresh_for: Some(Duration::from_secs(60)),
..CacheHeadersState::default()
};
prior.merge_from(incoming);
assert!(
prior.no_cache,
"no_cache must be sticky — omission in a later response does not unset it",
);
}
#[test]
fn expires_in_future_yields_positive_fresh_for() {
let h = headers(&[("expires", "Fri, 22 May 2026 13:00:00 GMT")]);
let v = CacheHeadersState::from_headers(&h, t0());
assert_eq!(v.fresh_for, Some(Duration::from_secs(3600)));
}
#[test]
fn expires_in_past_yields_zero_fresh_for() {
let h = headers(&[("expires", "Mon, 01 Jan 1990 00:00:00 GMT")]);
let v = CacheHeadersState::from_headers(&h, t0());
assert_eq!(v.fresh_for, Some(Duration::ZERO));
}
#[test]
fn expires_freshness_uses_response_date_when_present_per_rfc_9111() {
let h = headers(&[
("date", "Fri, 22 May 2026 12:00:00 GMT"),
("expires", "Fri, 22 May 2026 12:30:00 GMT"),
]);
let v = CacheHeadersState::from_headers(&h, t0());
assert_eq!(
v.fresh_for,
Some(Duration::from_secs(1800)),
"freshness must be Expires - Date (RFC 9111 §5.3), independent of client clock",
);
}
#[test]
fn expires_freshness_immune_to_client_clock_skew() {
use chrono::TimeZone;
let client_now_skewed = Utc.with_ymd_and_hms(2026, 5, 22, 11, 50, 0).unwrap();
let h = headers(&[
("date", "Fri, 22 May 2026 12:00:00 GMT"),
("expires", "Fri, 22 May 2026 12:30:00 GMT"),
]);
let v = CacheHeadersState::from_headers(&h, client_now_skewed);
assert_eq!(
v.fresh_for,
Some(Duration::from_secs(1800)),
"Date-anchored freshness must ignore client clock skew",
);
}
#[test]
fn expires_falls_back_to_client_now_when_date_header_missing() {
let h = headers(&[("expires", "Fri, 22 May 2026 13:00:00 GMT")]);
let v = CacheHeadersState::from_headers(&h, t0());
assert_eq!(
v.fresh_for,
Some(Duration::from_secs(3600)),
"with no Date header, fall back to client `now`",
);
}
#[test]
fn malformed_max_age_is_ignored() {
let h = headers(&[("cache-control", "max-age=banana")]);
let v = CacheHeadersState::from_headers(&h, t0());
assert_eq!(v.fresh_for, None);
}
#[test]
fn negative_max_age_is_ignored() {
let h = headers(&[("cache-control", "max-age=-30")]);
let v = CacheHeadersState::from_headers(&h, t0());
assert_eq!(v.fresh_for, None);
}
#[test]
fn malformed_expires_is_ignored() {
let h = headers(&[("expires", "not a date")]);
let v = CacheHeadersState::from_headers(&h, t0());
assert_eq!(v.fresh_for, None);
}
#[test]
fn directives_are_case_insensitive() {
let h = headers(&[("cache-control", "MAX-AGE=42, NO-CACHE")]);
let v = CacheHeadersState::from_headers(&h, t0());
assert!(v.no_cache);
assert_eq!(v.fresh_for, Some(Duration::ZERO));
}
#[test]
fn empty_directive_segments_skipped() {
let h = headers(&[("cache-control", ",,max-age=15,,")]);
let v = CacheHeadersState::from_headers(&h, t0());
assert_eq!(v.fresh_for, Some(Duration::from_secs(15)));
}
#[test]
fn whitespace_only_etag_is_treated_as_absent() {
let h = headers(&[("etag", " ")]);
let v = CacheHeadersState::from_headers(&h, t0());
assert!(v.etag.is_none());
assert!(!v.has_validator());
}
#[test]
fn rfc850_date_format_parsed() {
let h = headers(&[("expires", "Sunday, 22-May-94 12:00:00 GMT")]);
let v = CacheHeadersState::from_headers(&h, t0());
assert_eq!(v.fresh_for, Some(Duration::ZERO));
}
}