This commit is contained in:
Mateusz Gruszczyński committed 2026-10-22 14:20:03 +02:00
1 parent 133d2f0f91
commit 3ea00045bf
27 files changed
+446 -136

No files matched your search

+3
View File
@@ -6,9 +6,12 @@ mod tests {
h.insert("cache-control", value.parse().unwrap());
h
}
// Legacy-policy regression tests explicitly opt out of the new 0.34.7
// default; varnish-compatible behavior has its own test module.
fn settings() -> Settings {
Settings {
enabled: true,
varnish_compatible: false,
..Default::default()
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ mod dashboard_tests {
}
#[test]
fn bypass_reasons_are_fixed_and_private_inputs_are_not_echoed() {
let settings = Settings::default();
let settings = Settings { varnish_compatible: false, ..Settings::default() };
let mut headers = HeaderMap::new();
headers.insert("cookie", HeaderValue::from_static("session=very-private"));
assert_eq!(
+3 -3
View File
@@ -40,7 +40,7 @@ impl Cache {
entry.must_revalidate,
entry.proxy_revalidate,
entry.requires_revalidation,
&ticket.request_headers,
ticket.freshness_headers(),
);
if state.servable() {
let sequence = store.next_sequence();
@@ -144,7 +144,7 @@ impl Cache {
entry.must_revalidate,
entry.proxy_revalidate,
entry.requires_revalidation,
&ticket.request_headers,
ticket.freshness_headers(),
);
if !state.servable() {
return None;
@@ -217,7 +217,7 @@ impl Cache {
entry.must_revalidate,
entry.proxy_revalidate,
entry.requires_revalidation,
&ticket.request_headers,
ticket.freshness_headers(),
);
if !state.servable() {
return None;
+1
View File
@@ -88,6 +88,7 @@ pub struct Ticket {
target: String,
key: Key,
request_headers: HeaderMap,
freshness_headers: Option<HeaderMap>,
generation: u64,
revision: u64,
scope: String,
+3
View File
@@ -87,6 +87,8 @@ pub enum ColdPreset {
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub enabled: bool,
/// Ignore client freshness directives on cache lookups, like Varnish builtin VCL.
pub varnish_compatible: bool,
pub memory_preset: Option<MemoryPreset>,
pub max_bytes: usize,
pub max_object_bytes: usize,
@@ -140,6 +142,7 @@ impl Default for Settings {
fn default() -> Self {
Self {
enabled: true,
varnish_compatible: true,
memory_preset: None,
max_bytes: 256 * 1024 * 1024,
max_object_bytes: 16 * 1024 * 1024,
+46 -1
View File
@@ -44,6 +44,21 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
}
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")
@@ -158,6 +173,11 @@ pub fn request_policy(
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");
};
@@ -346,7 +366,24 @@ pub fn varies_on(headers: &HeaderMap, wanted: &str) -> bool {
})
}
pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str {
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";
}
@@ -367,6 +404,14 @@ pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str
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| {
+21 -1
View File
@@ -92,6 +92,14 @@ pub fn plan(
{
return None;
}
// Varnish builtin.vcl treats these as uncacheable origin responses
// (hit-for-miss is not implemented by RustProxy). Never weaken private
// or no-store handling even if Surrogate-Control is also present.
if settings.varnish_compatible
&& (control.contains_key("no-cache") || surrogate_no_store(headers))
{
return None;
}
let requires_revalidation = control.contains_key("no-cache");
let must_revalidate = control.contains_key("must-revalidate");
let proxy_revalidate = control.contains_key("proxy-revalidate");
@@ -102,7 +110,7 @@ pub fn plan(
Some(None) => None,
}
};
let stale_while_revalidate = parse_window("stale-while-revalidate")?;
let mut stale_while_revalidate = parse_window("stale-while-revalidate")?;
let stale_if_error = parse_window("stale-if-error")?;
if requires_revalidation
&& !headers.contains_key("etag")
@@ -148,6 +156,18 @@ pub fn plan(
None if requires_revalidation => 0,
None => return None,
};
// Like Varnish's built-in VCL, a backend response with no remaining
// freshness is not a reusable shared object (Varnish stores a hit-for-miss
// marker instead; RustProxy does not implement those markers).
if settings.varnish_compatible && initial_age.as_secs() >= ttl {
return None;
}
// Varnish defaults to ten seconds of grace after a *positive* TTL.
// Keep RustProxy's existing must-revalidate/proxy-revalidate safety checks;
// never turn a no-cache or zero-TTL response into a grace HIT.
if settings.varnish_compatible && stale_while_revalidate == 0 {
stale_while_revalidate = 10;
}
if !requires_revalidation
&& initial_age.as_secs()
>= retention_deadline(
+3
View File
@@ -18,3 +18,6 @@ include!("invalidation/http_invalidation_tests.rs");
#[cfg(test)]
include!("lookup/hot_hit_034_tests.rs");
#[cfg(test)]
include!("varnish_compatible_tests.rs");
+22
View File
@@ -101,6 +101,7 @@ impl Cache {
target: target.to_owned(),
key,
request_headers: headers.clone(),
freshness_headers: None,
generation: store.generation,
revision,
scope: scope.to_owned(),
@@ -392,3 +393,24 @@ impl Cache {
}
}
}
impl Ticket {
/// In Varnish compatibility mode, request cache directives do not constrain
/// freshness. Keep original headers for Vary and downstream conditionals.
pub fn with_varnish_compatible(mut self, compatible: bool) -> Self {
if compatible
&& (self.request_headers.contains_key("cache-control")
|| self.request_headers.contains_key("pragma"))
{
let mut freshness = self.request_headers.clone();
freshness.remove("cache-control");
freshness.remove("pragma");
self.freshness_headers = Some(freshness);
}
self
}
fn freshness_headers(&self) -> &HeaderMap {
self.freshness_headers.as_ref().unwrap_or(&self.request_headers)
}
}
+158
View File
@@ -0,0 +1,158 @@
mod varnish_compatible_tests {
use super::*;
use http_body_util::BodyExt;
fn headers(cache_control: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("cache-control", HeaderValue::from_str(cache_control).unwrap());
h
}
#[test]
fn default_and_legacy_serialization_are_explicit() {
assert!(Settings::default().varnish_compatible);
let legacy: Settings = serde_json::from_str(r#"{"enabled":true}"#).unwrap();
assert!(legacy.varnish_compatible);
let strict: Settings = serde_json::from_str(r#"{"varnish_compatible":false}"#).unwrap();
assert!(!strict.varnish_compatible);
let enabled = serde_json::to_value(&legacy).unwrap();
assert_eq!(enabled["varnish_compatible"], true);
}
#[test]
fn client_freshness_is_ignored_only_in_varnish_mode() {
let mut compatible = Settings::default();
let directives = [
"no-cache", "no-store", "max-age=0", "max-age=1",
"min-fresh=600", "max-stale=9999", "only-if-cached", "invalid directive",
];
for value in directives {
let h = headers(value);
assert_eq!(
request_policy(&Method::GET, &h, true, "/style.css", false, &compatible),
RequestPolicy::Use,
"varnish mode should ignore client directive: {value}"
);
let freshness = request_freshness_headers(&h, &compatible);
assert!(!freshness.contains_key("cache-control"));
assert!(h.contains_key("cache-control"), "do not mutate origin request headers");
}
let mut pragma = HeaderMap::new();
pragma.insert("pragma", HeaderValue::from_static("no-cache"));
assert_eq!(request_policy(&Method::GET, &pragma, true, "/", false, &compatible), RequestPolicy::Use);
assert!(!request_freshness_headers(&pragma, &compatible).contains_key("pragma"));
compatible.varnish_compatible = false;
assert_eq!(request_policy(&Method::GET, &headers("no-cache"), true, "/", false, &compatible), RequestPolicy::Revalidate("request_no_cache"));
assert_eq!(request_policy(&Method::GET, &headers("no-store"), true, "/", false, &compatible), RequestPolicy::Bypass("request_no_store"));
assert!(only_if_cached(&headers("only-if-cached")), "strict-only 504 guard still recognizes directive");
}
#[test]
fn vcl_style_request_lookup_preserves_privacy_and_local_rules() {
let mut settings = Settings::default();
for (name, value) in [
("authorization", "Bearer private"),
("cookie", "session=private"),
("if-match", "\"etag\""),
("upgrade", "websocket"),
] {
let mut h = headers("no-cache");
h.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), HeaderValue::from_str(value).unwrap());
assert!(matches!(request_policy(&Method::GET, &h, true, "/index.html", false, &settings), RequestPolicy::Bypass(_)));
}
settings.bypass_query = true;
assert_eq!(request_policy(&Method::GET, &HeaderMap::new(), true, "/style.css", true, &settings), RequestPolicy::Bypass("query_excluded"));
settings.bypass_query = false;
settings.bypass_prefixes = vec!["/private".into()];
assert_eq!(request_policy(&Method::GET, &HeaderMap::new(), true, "/private/account", false, &settings), RequestPolicy::Bypass("path_excluded"));
assert_eq!(request_policy(&Method::POST, &HeaderMap::new(), true, "/style.css", false, &settings), RequestPolicy::Bypass("request_method"));
assert!(settings.ignore_cookie_for_path("/style.css"), "existing static cookie feature remains enabled");
assert!(!settings.ignore_cookie_for_path("/index.html"));
}
#[test]
fn backend_response_policy_matches_varnish_without_weakening_privacy() {
let compatible = Settings::default();
let mut strict = compatible.clone();
strict.varnish_compatible = false;
for control in ["private, max-age=60", "no-store, max-age=60"] {
assert!(plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO).is_none());
assert!(plan(StatusCode::OK, &headers(control), &strict, Duration::ZERO).is_none());
}
let mut no_cache = headers("no-cache, max-age=60");
no_cache.insert("etag", HeaderValue::from_static("\"origin\""));
assert!(plan(StatusCode::OK, &no_cache, &compatible, Duration::ZERO).is_none());
assert!(plan(StatusCode::OK, &no_cache, &strict, Duration::ZERO).is_some());
assert_eq!(response_reason(StatusCode::OK, &no_cache, &compatible), "response_cache_control");
let mut surrogate = headers("public, max-age=60");
surrogate.insert("surrogate-control", HeaderValue::from_static("max-age=120; NO-STORE"));
assert!(plan(StatusCode::OK, &surrogate, &compatible, Duration::ZERO).is_none());
assert_eq!(response_reason(StatusCode::OK, &surrogate, &compatible), "response_surrogate_control");
assert!(plan(StatusCode::OK, &surrogate, &strict, Duration::ZERO).is_some());
for control in ["max-age=0", "max-age=60, s-maxage=0"] {
assert!(plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO).is_none());
assert!(plan(StatusCode::OK, &headers(control), &strict, Duration::ZERO).is_some());
}
let mut cookie = headers("max-age=60");
cookie.insert("set-cookie", HeaderValue::from_static("session=private"));
assert!(plan(StatusCode::OK, &cookie, &compatible, Duration::ZERO).is_none());
let mut vary = headers("max-age=60");
vary.insert("vary", HeaderValue::from_static("*"));
assert!(plan(StatusCode::OK, &vary, &compatible, Duration::ZERO).is_none());
}
#[tokio::test]
async fn varnish_style_hard_reload_keeps_hot_hit_and_age_without_origin_validator() {
let cache = Cache::new(1);
let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/style.css", &HeaderMap::new()).unwrap();
let mut response = Response::new(Body::from("public css"));
*response.headers_mut() = headers("public, max-age=600");
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
cache.wrap(response, ticket.clone(), plan, &settings).into_body().collect().await.unwrap();
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.received = Instant::now() - Duration::from_secs(45);
}
}
let mut request = headers("no-cache, max-age=0, min-fresh=900");
request.insert("pragma", HeaderValue::from_static("no-cache"));
let compatibility_ticket = cache.ticket(1, "https", "example.test", "app", "/style.css", &request).unwrap().with_varnish_compatible(true);
let first = cache.get(&compatibility_ticket).expect("hard reload should keep a fresh HIT");
assert!(first.response.headers().get("age").unwrap().to_str().unwrap().parse::<u64>().unwrap() >= 45);
assert_eq!(first.hits, 1);
let again = cache.get(&compatibility_ticket).unwrap();
assert_eq!(again.hits, 2, "hard reload must not reset HIT counter");
assert_eq!(cache.view().entries, 1);
let strict = cache.ticket(1, "https", "example.test", "app", "/style.css", &request).unwrap();
assert!(cache.get(&strict).is_none(), "strict max-age=0 requires revalidation");
}
#[tokio::test]
async fn ten_second_grace_and_no_unconditional_stale_outside_window() {
let cache = Cache::new(1);
let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/asset.js", &HeaderMap::new()).unwrap();
let mut response = Response::new(Body::from("js"));
*response.headers_mut() = headers("public, max-age=60");
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
assert_eq!(plan.stale_while_revalidate, 10);
cache.wrap(response, ticket.clone(), plan, &settings).into_body().collect().await.unwrap();
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.received = Instant::now() - Duration::from_secs(65);
}
}
let hit = cache.get(&ticket).expect("grace HIT");
assert!(hit.stale && hit.background_revalidate);
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.received = Instant::now() - Duration::from_secs(75);
}
}
assert!(cache.get(&ticket).is_none(), "expired beyond grace does not produce HIT");
}
}