use crate::executor::headers::{
plan::HeaderAggregationStrategy, response::ResponseHeaderAggregator,
};
use crate::telemetry::logging::targets;
use http::HeaderValue;
use tracing::{debug, warn};
lazy_static::lazy_static! {
static ref NO_STORE_HEADER_VALUE: HeaderValue =
HeaderValue::from_static("no-store, no-cache, must-revalidate");
}
#[derive(Clone, Default)]
struct CacheControl {
no_store: bool,
no_cache: bool,
must_revalidate: bool,
proxy_revalidate: bool,
must_understand: bool,
no_transform: bool,
immutable: bool,
is_private: bool,
is_public: bool,
max_age: Option<u32>,
s_maxage: Option<u32>,
stale_while_revalidate: Option<u32>,
stale_if_error: Option<u32>,
}
fn poison() -> CacheControl {
CacheControl {
no_store: true,
no_cache: true,
..Default::default()
}
}
fn parse_u32(token: &str, value: Option<&str>) -> Result<u32, ()> {
match value {
Some(v) => v.parse::<u32>().map_err(|_| {
warn!(
target: targets::CACHE_CONTROL,
directive = token,
value = v,
"cache-control directive has non-numeric value"
);
}),
None => {
warn!(
target: targets::CACHE_CONTROL,
directive = token,
"cache-control directive is missing a value"
);
Err(())
}
}
}
fn parse(header: &str) -> Option<CacheControl> {
let trimmed = header.trim();
if trimmed.is_empty() {
return None;
}
let mut p = CacheControl::default();
for part in trimmed.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let (token, value) = match part.split_once('=') {
Some((t, v)) => (t.trim(), Some(v.trim())),
None => (part, None),
};
let token_lower = token.to_ascii_lowercase();
match token_lower.as_str() {
"no-store" => p.no_store = true,
"no-cache" => p.no_cache = true,
"private" => p.is_private = true,
"public" => p.is_public = true,
"must-revalidate" => p.must_revalidate = true,
"proxy-revalidate" => p.proxy_revalidate = true,
"must-understand" => p.must_understand = true,
"no-transform" => p.no_transform = true,
"immutable" => p.immutable = true,
"max-age" | "s-maxage" | "stale-while-revalidate" | "stale-if-error" => {
let Ok(n) = parse_u32(&token_lower, value) else {
return Some(poison());
};
match token_lower.as_str() {
"max-age" => p.max_age = Some(n),
"s-maxage" => p.s_maxage = Some(n),
"stale-while-revalidate" => p.stale_while_revalidate = Some(n),
_ => p.stale_if_error = Some(n),
}
}
v => {
warn!(target: targets::CACHE_CONTROL, directive = v, "cache-control has unrecognized directive");
return Some(poison());
}
}
}
Some(p)
}
fn merge_into(acc: &mut Option<CacheControl>, incoming: CacheControl) {
let Some(existing) = acc else {
*acc = Some(incoming);
return;
};
if existing.no_store || existing.no_cache || incoming.no_store || incoming.no_cache {
*existing = CacheControl {
no_store: true,
no_cache: true,
..Default::default()
};
return;
}
existing.is_private = existing.is_private || incoming.is_private;
existing.is_public = existing.is_public && incoming.is_public && !existing.is_private;
existing.immutable = existing.immutable && incoming.immutable;
existing.must_revalidate = existing.must_revalidate || incoming.must_revalidate;
existing.proxy_revalidate = existing.proxy_revalidate || incoming.proxy_revalidate;
existing.must_understand = existing.must_understand || incoming.must_understand;
existing.no_transform = existing.no_transform || incoming.no_transform;
let shared_max_age = min_opt(
existing.s_maxage.or(existing.max_age),
incoming.s_maxage.or(incoming.max_age),
);
let all_have_s_maxage = existing.s_maxage.is_some() && incoming.s_maxage.is_some();
existing.max_age = min_opt(existing.max_age, incoming.max_age);
existing.s_maxage = all_have_s_maxage.then_some(shared_max_age).flatten();
if !all_have_s_maxage {
existing.max_age = min_opt(existing.max_age, shared_max_age);
}
existing.stale_while_revalidate = min_opt(
existing.stale_while_revalidate,
incoming.stale_while_revalidate,
);
existing.stale_if_error = min_opt(existing.stale_if_error, incoming.stale_if_error);
}
fn min_opt(a: Option<u32>, b: Option<u32>) -> Option<u32> {
match (a, b) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, None) => a,
(None, b) => b,
}
}
fn to_header_value(p: &CacheControl) -> String {
if p.no_store || p.no_cache {
return "no-store, no-cache".to_string();
}
let mut parts: Vec<String> = Vec::new();
if p.is_private {
parts.push("private".to_string());
} else if p.is_public {
parts.push("public".to_string());
}
if let Some(age) = p.max_age {
parts.push(format!("max-age={age}"));
}
if let Some(age) = p.s_maxage {
parts.push(format!("s-maxage={age}"));
}
if let Some(age) = p.stale_while_revalidate {
parts.push(format!("stale-while-revalidate={age}"));
}
if let Some(age) = p.stale_if_error {
parts.push(format!("stale-if-error={age}"));
}
if p.must_revalidate {
parts.push("must-revalidate".to_string());
}
if p.proxy_revalidate {
parts.push("proxy-revalidate".to_string());
}
if p.must_understand {
parts.push("must-understand".to_string());
}
if p.no_transform {
parts.push("no-transform".to_string());
}
if p.immutable {
parts.push("immutable".to_string());
}
parts.join(", ")
}
pub fn finalize(
aggregator: &mut ResponseHeaderAggregator,
force_no_store: bool,
total_responses: usize,
) {
let Some((_, values)) = aggregator.entries.get(&http::header::CACHE_CONTROL) else {
return;
};
if force_no_store {
let value = NO_STORE_HEADER_VALUE.clone();
aggregator.entries.insert(
http::header::CACHE_CONTROL,
(HeaderAggregationStrategy::Last, vec![value]),
);
return;
}
let mut acc: Option<CacheControl> = None;
for v in values {
if let Ok(s) = v.to_str() {
if let Some(parsed) = parse(s) {
merge_into(&mut acc, parsed);
}
}
}
if let Some(mut merged) = acc {
if total_responses > values.len() {
merged.is_public = false;
merged.immutable = false;
}
let serialized = to_header_value(&merged);
let value = HeaderValue::from_str(&serialized).expect("to_header_value produced non-ASCII");
aggregator.entries.insert(
http::header::CACHE_CONTROL,
(HeaderAggregationStrategy::Last, vec![value]),
);
} else {
for v in values {
debug!(target: targets::CACHE_CONTROL, value = ?v, "invalid cache-control value");
}
warn!(target: targets::CACHE_CONTROL, "no valid cache-control values found, removing header");
aggregator.entries.remove(&http::header::CACHE_CONTROL);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn merge(a: Option<CacheControl>, b: CacheControl) -> CacheControl {
let mut acc = a;
merge_into(&mut acc, b);
acc.unwrap()
}
#[test]
fn first_value_adopted() {
let result = merge(
None,
CacheControl {
is_public: true,
max_age: Some(300),
..Default::default()
},
);
assert!(result.is_public);
assert_eq!(result.max_age, Some(300));
assert!(!result.no_store);
assert!(!result.no_cache);
}
#[test]
fn incoming_no_store_poisons() {
let result = merge(
Some(CacheControl {
is_public: true,
max_age: Some(300),
..Default::default()
}),
CacheControl {
no_store: true,
..Default::default()
},
);
assert!(result.no_store);
assert!(result.no_cache);
assert!(!result.is_public);
assert_eq!(result.max_age, None);
}
#[test]
fn incoming_no_cache_poisons() {
let result = merge(
Some(CacheControl {
is_public: true,
max_age: Some(60),
..Default::default()
}),
CacheControl {
no_cache: true,
..Default::default()
},
);
assert!(result.no_store);
assert!(result.no_cache);
assert!(!result.is_public);
}
#[test]
fn incoming_private_is_preserved() {
let result = merge(
Some(CacheControl {
is_public: true,
max_age: Some(120),
..Default::default()
}),
CacheControl {
is_private: true,
max_age: Some(50),
..Default::default()
},
);
assert!(!result.no_store);
assert!(!result.no_cache);
assert!(!result.is_public);
assert!(result.is_private);
assert_eq!(result.max_age, Some(50));
}
#[test]
fn existing_no_store_poisons() {
let result = merge(
Some(CacheControl {
no_store: true,
..Default::default()
}),
CacheControl {
is_public: true,
max_age: Some(300),
..Default::default()
},
);
assert!(result.no_store);
assert!(result.no_cache);
assert!(!result.is_public);
}
#[test]
fn existing_private_is_preserved() {
let result = merge(
Some(CacheControl {
is_private: true,
..Default::default()
}),
CacheControl {
is_public: true,
max_age: Some(300),
..Default::default()
},
);
assert!(!result.no_store);
assert!(!result.no_cache);
assert!(result.is_private);
assert!(!result.is_public);
}
#[test]
fn both_no_store() {
let result = merge(
Some(CacheControl {
no_store: true,
..Default::default()
}),
CacheControl {
no_store: true,
..Default::default()
},
);
assert!(result.no_store);
assert!(result.no_cache);
}
#[test]
fn max_age_takes_min() {
let result = merge(
Some(CacheControl {
max_age: Some(500),
..Default::default()
}),
CacheControl {
max_age: Some(300),
..Default::default()
},
);
assert_eq!(result.max_age, Some(300));
}
#[test]
fn max_age_takes_min_other_direction() {
let result = merge(
Some(CacheControl {
max_age: Some(100),
..Default::default()
}),
CacheControl {
max_age: Some(999),
..Default::default()
},
);
assert_eq!(result.max_age, Some(100));
}
#[test]
fn max_age_existing_only() {
let result = merge(
Some(CacheControl {
max_age: Some(200),
..Default::default()
}),
CacheControl {
max_age: None,
..Default::default()
},
);
assert_eq!(result.max_age, Some(200));
}
#[test]
fn max_age_incoming_only() {
let result = merge(
Some(CacheControl {
max_age: None,
..Default::default()
}),
CacheControl {
max_age: Some(60),
..Default::default()
},
);
assert_eq!(result.max_age, Some(60));
}
#[test]
fn max_age_neither() {
let result = merge(Some(CacheControl::default()), CacheControl::default());
assert_eq!(result.max_age, None);
}
#[test]
fn public_both_public() {
let result = merge(
Some(CacheControl {
is_public: true,
..Default::default()
}),
CacheControl {
is_public: true,
..Default::default()
},
);
assert!(result.is_public);
}
#[test]
fn public_stripped_when_incoming_not_public() {
let result = merge(
Some(CacheControl {
is_public: true,
..Default::default()
}),
CacheControl {
is_public: false,
..Default::default()
},
);
assert!(!result.is_public);
}
#[test]
fn public_neither() {
let result = merge(Some(CacheControl::default()), CacheControl::default());
assert!(!result.is_public);
}
#[test]
fn must_revalidate_from_incoming() {
let result = merge(
Some(CacheControl {
must_revalidate: false,
..Default::default()
}),
CacheControl {
must_revalidate: true,
..Default::default()
},
);
assert!(result.must_revalidate);
}
#[test]
fn must_revalidate_from_existing() {
let result = merge(
Some(CacheControl {
must_revalidate: true,
..Default::default()
}),
CacheControl {
must_revalidate: false,
..Default::default()
},
);
assert!(result.must_revalidate);
}
#[test]
fn must_revalidate_neither() {
let result = merge(Some(CacheControl::default()), CacheControl::default());
assert!(!result.must_revalidate);
}
#[test]
fn must_revalidate_cleared_on_poison() {
let result = merge(
Some(CacheControl {
must_revalidate: true,
..Default::default()
}),
CacheControl {
no_store: true,
..Default::default()
},
);
assert!(result.no_store);
assert!(result.no_cache);
assert!(!result.must_revalidate);
}
#[test]
fn three_way_all_public() {
let mut acc = None;
merge_into(
&mut acc,
CacheControl {
is_public: true,
max_age: Some(300),
..Default::default()
},
);
merge_into(
&mut acc,
CacheControl {
is_public: true,
max_age: Some(200),
..Default::default()
},
);
merge_into(
&mut acc,
CacheControl {
is_public: true,
max_age: Some(500),
..Default::default()
},
);
let result = acc.unwrap();
assert!(result.is_public);
assert_eq!(result.max_age, Some(200));
}
#[test]
fn three_way_one_not_public() {
let mut acc = None;
merge_into(
&mut acc,
CacheControl {
is_public: true,
max_age: Some(300),
..Default::default()
},
);
merge_into(
&mut acc,
CacheControl {
is_public: false,
max_age: Some(100),
..Default::default()
},
);
merge_into(
&mut acc,
CacheControl {
is_public: true,
max_age: Some(200),
..Default::default()
},
);
let result = acc.unwrap();
assert!(!result.is_public);
assert_eq!(result.max_age, Some(100));
}
#[test]
fn three_way_third_poisons() {
let mut acc = None;
merge_into(
&mut acc,
CacheControl {
is_public: true,
max_age: Some(300),
..Default::default()
},
);
merge_into(
&mut acc,
CacheControl {
is_public: true,
max_age: Some(200),
..Default::default()
},
);
merge_into(
&mut acc,
CacheControl {
no_store: true,
..Default::default()
},
);
let result = acc.unwrap();
assert!(result.no_store);
assert!(result.no_cache);
assert!(!result.is_public);
assert_eq!(result.max_age, None);
}
#[test]
fn three_way_first_poisons_no_recovery() {
let mut acc = None;
merge_into(
&mut acc,
CacheControl {
no_cache: true,
..Default::default()
},
);
merge_into(
&mut acc,
CacheControl {
is_public: true,
max_age: Some(300),
..Default::default()
},
);
merge_into(
&mut acc,
CacheControl {
is_public: true,
max_age: Some(200),
..Default::default()
},
);
let result = acc.unwrap();
assert!(result.no_store);
assert!(result.no_cache);
assert!(!result.is_public);
}
fn make_aggregator(values: &[&str]) -> ResponseHeaderAggregator {
let mut agg = ResponseHeaderAggregator::default();
for v in values {
agg.write(
&http::header::CACHE_CONTROL,
&http::HeaderValue::from_str(v).unwrap(),
HeaderAggregationStrategy::Append,
);
}
agg
}
fn cc_value(agg: &ResponseHeaderAggregator) -> Option<String> {
agg.entries
.get(&http::header::CACHE_CONTROL)
.and_then(|(_, vs)| vs.first())
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
#[test]
fn finalize_force_no_store_forces_no_store() {
let mut agg = make_aggregator(&["public, max-age=300"]);
finalize(&mut agg, true, 1);
assert_eq!(
cc_value(&agg).as_deref(),
Some("no-store, no-cache, must-revalidate")
);
}
#[test]
fn finalize_merges_two_appended_values() {
let mut agg = make_aggregator(&["public, max-age=300", "public, max-age=60"]);
finalize(&mut agg, false, 2);
assert_eq!(cc_value(&agg).as_deref(), Some("public, max-age=60"));
}
#[test]
fn finalize_private_is_preserved() {
let mut agg = make_aggregator(&["private"]);
finalize(&mut agg, false, 1);
assert_eq!(cc_value(&agg).as_deref(), Some("private"));
}
#[test]
fn finalize_user_requirement_private_overrides_public_and_keeps_min_age() {
let mut agg = make_aggregator(&["public, max-age=100", "private, max-age=50"]);
finalize(&mut agg, false, 2);
assert_eq!(cc_value(&agg).as_deref(), Some("private, max-age=50"));
}
#[test]
fn finalize_absent_entry_no_error_leaves_absent() {
let mut agg = ResponseHeaderAggregator::default();
finalize(&mut agg, false, 0);
assert!(agg.entries.get(&http::header::CACHE_CONTROL).is_none());
}
#[test]
fn finalize_empty_string_removes_header() {
let mut agg = make_aggregator(&[""]);
finalize(&mut agg, false, 1);
assert_eq!(cc_value(&agg).as_deref(), None);
}
#[test]
fn finalize_invalid_utf8_removes_header() {
let mut agg = ResponseHeaderAggregator::default();
let invalid = http::HeaderValue::from_bytes(&[0xFF, 0xFE]).unwrap();
agg.write(
&http::header::CACHE_CONTROL,
&invalid,
HeaderAggregationStrategy::Append,
);
finalize(&mut agg, false, 1);
assert_eq!(cc_value(&agg).as_deref(), None);
}
#[test]
fn finalize_unrecognized_value_poisons() {
let mut agg = make_aggregator(&["bogus-directive"]);
finalize(&mut agg, false, 1);
assert_eq!(cc_value(&agg).as_deref(), Some("no-store, no-cache"));
}
#[test]
fn finalize_single_unrecognized_directive_poisons() {
let mut agg = make_aggregator(&["public, max-age=300, huh"]);
finalize(&mut agg, false, 1);
assert_eq!(cc_value(&agg).as_deref(), Some("no-store, no-cache"));
}
#[test]
fn finalize_malformed_max_age_poisons() {
let mut agg = make_aggregator(&["public, max-age=woof"]);
finalize(&mut agg, false, 1);
assert_eq!(cc_value(&agg).as_deref(), Some("no-store, no-cache"));
}
#[test]
fn finalize_no_store_with_s_maxage_not_dropped() {
let mut agg = make_aggregator(&["no-store, s-maxage=0", "public, max-age=300"]);
finalize(&mut agg, false, 2);
assert_eq!(cc_value(&agg).as_deref(), Some("no-store, no-cache"));
}
#[test]
fn finalize_standard_directives_pass_through() {
let mut agg = make_aggregator(&[
"public, max-age=300, s-maxage=600, stale-while-revalidate=30, stale-if-error=60, no-transform, immutable",
]);
finalize(&mut agg, false, 1);
assert_eq!(
cc_value(&agg).as_deref(),
Some("public, max-age=300, s-maxage=600, stale-while-revalidate=30, stale-if-error=60, no-transform, immutable")
);
}
#[test]
fn finalize_user_requirement_s_maxage_takes_min() {
let mut agg = make_aggregator(&["public, s-maxage=100", "public, s-maxage=20"]);
finalize(&mut agg, false, 2);
assert_eq!(cc_value(&agg).as_deref(), Some("public, s-maxage=20"));
}
#[test]
fn finalize_durations_take_min() {
let mut agg = make_aggregator(&[
"s-maxage=600, stale-while-revalidate=10, stale-if-error=120",
"s-maxage=60, stale-while-revalidate=30, stale-if-error=15",
]);
finalize(&mut agg, false, 2);
assert_eq!(
cc_value(&agg).as_deref(),
Some("s-maxage=60, stale-while-revalidate=10, stale-if-error=15")
);
}
#[test]
fn finalize_mixed_max_age_and_s_maxage_take_shared_min() {
let mut agg = make_aggregator(&["s-maxage=600", "max-age=100"]);
finalize(&mut agg, false, 2);
assert_eq!(cc_value(&agg).as_deref(), Some("max-age=100"));
}
#[test]
fn finalize_restrictions_are_or() {
let mut agg = make_aggregator(&[
"max-age=100, no-transform",
"max-age=200, proxy-revalidate, must-understand",
]);
finalize(&mut agg, false, 2);
assert_eq!(
cc_value(&agg).as_deref(),
Some("max-age=100, proxy-revalidate, must-understand, no-transform")
);
}
#[test]
fn finalize_immutable_requires_all() {
let mut agg = make_aggregator(&["max-age=100, immutable", "max-age=100"]);
finalize(&mut agg, false, 2);
assert_eq!(cc_value(&agg).as_deref(), Some("max-age=100"));
let mut agg = make_aggregator(&["max-age=100, immutable", "max-age=100, immutable"]);
finalize(&mut agg, false, 2);
assert_eq!(cc_value(&agg).as_deref(), Some("max-age=100, immutable"));
}
#[test]
fn finalize_immutable_stripped_when_silent_subgraph() {
let mut agg = make_aggregator(&["public, max-age=100, immutable"]);
finalize(&mut agg, false, 2);
assert_eq!(cc_value(&agg).as_deref(), Some("max-age=100"));
}
#[test]
fn finalize_poison_clears_new_fields() {
let mut agg = make_aggregator(&[
"s-maxage=600, stale-while-revalidate=30, no-transform, immutable",
"no-store",
]);
finalize(&mut agg, false, 2);
assert_eq!(cc_value(&agg).as_deref(), Some("no-store, no-cache"));
}
#[test]
fn finalize_malformed_s_maxage_poisons() {
let mut agg = make_aggregator(&["public, s-maxage=woof"]);
finalize(&mut agg, false, 1);
assert_eq!(cc_value(&agg).as_deref(), Some("no-store, no-cache"));
}
#[test]
fn finalize_qualified_no_cache_poisons() {
let mut agg = make_aggregator(&["no-cache=\"set-cookie\", max-age=300"]);
finalize(&mut agg, false, 1);
assert_eq!(cc_value(&agg).as_deref(), Some("no-store, no-cache"));
}
#[test]
fn finalize_absent_entry_with_force_no_store_absent() {
let mut agg = ResponseHeaderAggregator::default();
finalize(&mut agg, true, 0);
assert!(agg.entries.get(&http::header::CACHE_CONTROL).is_none());
}
#[test]
fn finalize_public_stripped_when_silent_subgraph() {
let mut agg = make_aggregator(&["public, max-age=200"]);
finalize(&mut agg, false, 2);
let cc = cc_value(&agg).unwrap_or_default();
assert!(!cc.contains("public"), "expected no public, got: {cc}");
assert!(
cc.contains("max-age=200"),
"expected max-age=200, got: {cc}"
);
}
}