fn directives(headers: &HeaderMap) -> Option>> { let mut result = HashMap::new(); for header in headers.get_all("cache-control") { for raw in header.to_str().ok()?.split(',') { let raw = raw.trim(); if raw.is_empty() { return None; } let (key, value) = match raw.split_once('=') { Some((k, v)) => { let v = v.trim(); let value = if v.starts_with('"') { let inner = v.strip_prefix('"')?.strip_suffix('"')?; if inner.contains(['"', '\\']) { return None; } inner } else { if v.is_empty() || v.bytes().any(|b| b <= 32 || b == b'"' || b == b'\\') { return None; } v }; (k.trim().to_ascii_lowercase(), Some(value.to_owned())) } None => (raw.to_ascii_lowercase(), None), }; if key.parse::().is_err() { return None; } if matches!( key.as_str(), "public" | "no-store" | "must-revalidate" | "proxy-revalidate" | "only-if-cached" ) && value.is_some() { return None; } if key == "immutable" { result.entry(key).or_insert(None); } else if result.insert(key, value).is_some() { return None; } } } Some(result) } pub fn request_freshness_headers<'a>( headers: &'a HeaderMap, settings: &Settings, ) -> std::borrow::Cow<'a, HeaderMap> { if !settings.varnish_compatible || (!headers.contains_key("cache-control") && !headers.contains_key("pragma")) { return std::borrow::Cow::Borrowed(headers); } let mut freshness = headers.clone(); freshness.remove("cache-control"); freshness.remove("pragma"); std::borrow::Cow::Owned(freshness) } pub fn only_if_cached(headers: &HeaderMap) -> bool { headers .get_all("cache-control") .iter() .filter_map(|v| v.to_str().ok()) .any(|v| { v.split(',') .any(|v| v.trim().eq_ignore_ascii_case("only-if-cached")) }) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RequestPolicy { Use, Revalidate(&'static str), Bypass(&'static str), } impl RequestPolicy { pub fn bypass_reason(self) -> Option<&'static str> { match self { Self::Bypass(reason) => Some(reason), _ => None, } } pub fn revalidation_reason(self) -> Option<&'static str> { match self { Self::Revalidate(reason) => Some(reason), _ => None, } } } fn pragma_no_cache(headers: &HeaderMap) -> bool { headers .get_all("pragma") .iter() .filter_map(|value| value.to_str().ok()) .any(|value| { value .split(',') .any(|token| token.trim().eq_ignore_ascii_case("no-cache")) }) } pub fn forced_request_policy( method: &Method, headers: &HeaderMap, empty_body: bool, ) -> RequestPolicy { if method != Method::GET && method != Method::HEAD { return RequestPolicy::Bypass("request_method"); } if !empty_body { return RequestPolicy::Bypass("request_body"); } if matches!(request_range(method, headers), RangeSelection::Unsupported) { return RequestPolicy::Bypass("request_range_unsupported"); } if headers.contains_key("if-range") && !headers.contains_key("range") { return RequestPolicy::Bypass("request_precondition"); } for key in ["if-match", "if-unmodified-since", "upgrade"] { if headers.contains_key(key) { return RequestPolicy::Bypass(if key == "upgrade" { "request_upgrade" } else { "request_precondition" }); } } RequestPolicy::Use } pub fn request_policy( method: &Method, headers: &HeaderMap, empty_body: bool, path: &str, has_query: bool, settings: &Settings, ) -> RequestPolicy { if method != Method::GET && method != Method::HEAD { return RequestPolicy::Bypass("request_method"); } if !empty_body { return RequestPolicy::Bypass("request_body"); } if matches!(request_range(method, headers), RangeSelection::Unsupported) { return RequestPolicy::Bypass("request_range_unsupported"); } if headers.contains_key("if-range") && !headers.contains_key("range") { return RequestPolicy::Bypass("request_precondition"); } if settings.bypass_query && has_query { return RequestPolicy::Bypass("query_excluded"); } if settings .bypass_prefixes .iter() .any(|prefix| crate::config::prefix_matches(prefix, path)) { return RequestPolicy::Bypass("path_excluded"); } for (key, reason) in [ ("authorization", "request_authorization"), ("cookie", "request_cookie"), ("if-match", "request_precondition"), ("if-unmodified-since", "request_precondition"), ("upgrade", "request_upgrade"), ] { if headers.contains_key(key) { if key == "cookie" && settings.ignore_cookie_for_path(path) { continue; } return RequestPolicy::Bypass(reason); } } // Varnish builtin.vcl performs a cache lookup without interpreting // client Cache-Control or Pragma. Security bypasses above still apply. if settings.varnish_compatible { return RequestPolicy::Use; } let Some(control) = directives(headers) else { return RequestPolicy::Bypass("request_cache_control_invalid"); }; if control.contains_key("no-store") { return RequestPolicy::Bypass("request_no_store"); } if control.contains_key("no-cache") { return RequestPolicy::Revalidate("request_no_cache"); } if let Some(value) = control.get("max-age") { let Some(value) = value.as_deref().and_then(|value| value.parse::().ok()) else { return RequestPolicy::Bypass("request_cache_control_invalid"); }; if value == 0 { return RequestPolicy::Revalidate("request_max_age"); } } if let Some(value) = control.get("min-fresh") { if value .as_deref() .and_then(|value| value.parse::().ok()) .is_none() { return RequestPolicy::Bypass("request_cache_control_invalid"); } } if let Some(Some(value)) = control.get("max-stale") { if value.parse::().is_err() { return RequestPolicy::Bypass("request_cache_control_invalid"); } } if !headers.contains_key("cache-control") && pragma_no_cache(headers) { return RequestPolicy::Revalidate("request_pragma_no_cache"); } RequestPolicy::Use } pub fn request_reason( method: &Method, headers: &HeaderMap, empty_body: bool, path: &str, has_query: bool, settings: &Settings, ) -> Option<&'static str> { request_policy(method, headers, empty_body, path, has_query, settings).bypass_reason() } pub fn request_cacheable( method: &Method, headers: &HeaderMap, empty_body: bool, path: &str, has_query: bool, settings: &Settings, ) -> bool { !matches!( request_policy(method, headers, empty_body, path, has_query, settings), RequestPolicy::Bypass(_) ) } fn weak_etag(value: &str) -> Option<&str> { let value = value.trim(); let value = value .strip_prefix("W/") .or_else(|| value.strip_prefix("w/")) .unwrap_or(value) .trim(); (value.len() >= 2 && value.starts_with('"') && value.ends_with('"')).then_some(value) } fn if_none_match_matches(headers: &HeaderMap, etag: &str) -> Option { let target = weak_etag(etag)?; let values: Vec<_> = headers.get_all("if-none-match").iter().collect(); if values.is_empty() { return None; } for value in values { let Ok(value) = value.to_str() else { return Some(false); }; let mut start = 0usize; let mut quoted = false; for (index, ch) in value.char_indices() { if ch == '"' { quoted = !quoted; } else if ch == ',' && !quoted { let token = value[start..index].trim(); if token == "*" || weak_etag(token).is_some_and(|candidate| candidate == target) { return Some(true); } start = index + 1; } } if quoted { return Some(false); } let token = value[start..].trim(); if token == "*" || weak_etag(token).is_some_and(|candidate| candidate == target) { return Some(true); } } Some(false) } fn single_if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool { let Some(target) = weak_etag(etag) else { return false; }; let values: Vec<_> = headers.get_all("if-none-match").iter().collect(); if values.len() != 1 { return false; } let Ok(value) = values[0].to_str() else { return false; }; let mut tokens = value .split(',') .map(str::trim) .filter(|token| !token.is_empty()); let Some(token) = tokens.next() else { return false; }; tokens.next().is_none() && token != "*" && weak_etag(token).is_some_and(|candidate| candidate == target) } pub fn conditional_not_modified(request: &HeaderMap, response: &HeaderMap) -> bool { if request.contains_key("if-none-match") { return response .get("etag") .and_then(|etag| etag.to_str().ok()) .and_then(|etag| if_none_match_matches(request, etag)) .unwrap_or(false); } let Some(since) = request .get("if-modified-since") .and_then(|value| value.to_str().ok()) .and_then(|value| httpdate::parse_http_date(value).ok()) else { return false; }; response .get("last-modified") .and_then(|value| value.to_str().ok()) .and_then(|value| httpdate::parse_http_date(value).ok()) .is_some_and(|modified| modified <= since) } pub fn make_not_modified(mut response: Response) -> Response { *response.status_mut() = StatusCode::NOT_MODIFIED; *response.body_mut() = Body::empty(); for name in ["content-length", "content-range", "transfer-encoding"] { response.headers_mut().remove(name); } response } pub fn merge_revalidated_headers(cached: &HeaderMap, validation: &HeaderMap) -> HeaderMap { let mut merged = cached.clone(); if !validation.contains_key("age") { merged.remove("age"); } for name in validation.keys() { if matches!( name.as_str(), "content-length" | "content-range" | "transfer-encoding" ) { continue; } merged.remove(name); for value in validation.get_all(name).iter() { merged.append(name.clone(), value.clone()); } } if !validation.contains_key("date") { if let Ok(value) = httpdate::fmt_http_date(SystemTime::now()).parse() { merged.insert("date", value); } } merged } pub fn varies_on(headers: &HeaderMap, wanted: &str) -> bool { headers.get_all("vary").iter().any(|value| { value.to_str().ok().is_some_and(|value| { value .split(',') .any(|name| name.trim().eq_ignore_ascii_case(wanted)) }) }) } pub fn surrogate_no_store(headers: &HeaderMap) -> bool { headers.get_all("surrogate-control").iter().any(|value| { value.to_str().ok().is_some_and(|text| { text.split([',', ';']).any(|part| { part.trim() .split_once('=') .map_or(part.trim(), |(key, _)| key.trim()) .eq_ignore_ascii_case("no-store") }) }) }) } pub fn response_reason( status: StatusCode, headers: &HeaderMap, settings: &Settings, ) -> &'static str { if status != StatusCode::OK { return "response_status"; } if headers.contains_key("set-cookie") { return "response_set_cookie"; } if headers.contains_key("content-range") { return "response_range"; } if headers.contains_key("trailer") { return "response_trailers"; } let Some(control) = directives(headers) else { return "response_cache_control_invalid"; }; for key in ["no-store", "private", "must-understand"] { if control.contains_key(key) { return "response_cache_control"; } } if settings.varnish_compatible { if control.contains_key("no-cache") { return "response_cache_control"; } if surrogate_no_store(headers) { return "response_surrogate_control"; } } for value in headers.get_all("vary") { if value.to_str().ok().is_none_or(|value| { value.split(',').any(|name| { let name = name.trim(); name == "*" || name.parse::().is_err() }) }) { return "response_vary"; } } "response_ttl_size_or_metadata" } pub struct Plan { received: Instant, initial_age: Duration, ttl: u64, stale_while_revalidate: u64, stale_if_error: u64, must_revalidate: bool, proxy_revalidate: bool, expected_length: Option, requires_revalidation: bool, vary: Vec, }