This commit is contained in:
Mateusz Gruszczyński committed 2026-10-22 15:31:47 +02:00
1 parent 3ea00045bf
commit 1cd6b7ac99
22 files changed
+550 -149

No files matched your search

+4
View File
@@ -61,6 +61,10 @@ impl Settings {
valid_relative_cache_path(&self.cold_path),
"cache.cold_path must be a simple relative path inside the data directory"
);
ensure!(
self.varnish_grace_seconds <= 604_800,
"cache.varnish_grace_seconds must be 0..604800"
);
ensure!(
(1..=604_800).contains(&self.max_ttl_seconds)
&& self.default_ttl_seconds <= self.max_ttl_seconds,
+3
View File
@@ -89,6 +89,8 @@ pub struct Settings {
pub enabled: bool,
/// Ignore client freshness directives on cache lookups, like Varnish builtin VCL.
pub varnish_compatible: bool,
/// Implicit grace in seconds for eligible fresh-at-storage cached responses.
pub varnish_grace_seconds: u64,
pub memory_preset: Option<MemoryPreset>,
pub max_bytes: usize,
pub max_object_bytes: usize,
@@ -143,6 +145,7 @@ impl Default for Settings {
Self {
enabled: true,
varnish_compatible: true,
varnish_grace_seconds: 60,
memory_preset: None,
max_bytes: 256 * 1024 * 1024,
max_object_bytes: 16 * 1024 * 1024,
+9 -4
View File
@@ -132,13 +132,18 @@ fn stale_mode(
return None;
}
let stale_for = age.saturating_sub(ttl);
let max_stale = client_max_stale(request_headers);
// An explicit client limit must also constrain implicit Varnish grace.
// In particular, max-stale=0 must not serve a stale representation.
if matches!(max_stale, Some(Some(limit)) if stale_for > limit) {
return None;
}
if stale_while_revalidate > 0 && stale_for <= stale_while_revalidate {
return Some(true);
}
match client_max_stale(request_headers) {
Some(None) => Some(false),
Some(Some(limit)) if stale_for <= limit => Some(false),
_ => None,
match max_stale {
Some(None) | Some(Some(_)) => Some(false),
None => None,
}
}
+89 -24
View File
@@ -44,19 +44,89 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
}
Some(result)
}
/// Keep all client cache restrictions except the hard-reload directives that
/// Varnish normally disregards for a fresh shared object. Never mutate the
/// actual request (it is still forwarded to the origin and used for Vary).
fn compatible_freshness_headers<'a>(
headers: &'a HeaderMap,
compatible: bool,
) -> std::borrow::Cow<'a, HeaderMap> {
if !compatible || (!headers.contains_key("cache-control") && !headers.contains_key("pragma")) {
return std::borrow::Cow::Borrowed(headers);
}
// An invalid cache-control header cannot be made valid by filtering it.
// Also avoid cloning the full map for ordinary requests with no reload
// directives, including min-fresh, max-stale and only-if-cached.
let Some(control) = directives(headers) else {
return std::borrow::Cow::Borrowed(headers);
};
let remove_reload = control.contains_key("no-cache")
|| control.get("max-age").and_then(|value| value.as_deref())
.and_then(|value| value.parse::<u64>().ok()) == Some(0);
if !remove_reload && !pragma_no_cache(headers) {
return std::borrow::Cow::Borrowed(headers);
}
let mut freshness = headers.clone();
let mut changed = false;
if pragma_no_cache(headers) {
// Pragma only affects freshness through its `no-cache` token in this
// implementation. Preserve other tokens, if any.
freshness.remove("pragma");
for header in headers.get_all("pragma") {
let Ok(text) = header.to_str() else {
return std::borrow::Cow::Borrowed(headers);
};
let retained = text.split(',')
.map(str::trim)
.filter(|token| !token.eq_ignore_ascii_case("no-cache"))
.collect::<Vec<_>>();
if !retained.is_empty() {
let Ok(value) = HeaderValue::from_str(&retained.join(", ")) else {
return std::borrow::Cow::Borrowed(headers);
};
freshness.append("pragma", value);
}
}
changed = true;
}
if headers.contains_key("cache-control") {
let mut retained_values = Vec::new();
for header in headers.get_all("cache-control") {
let Ok(text) = header.to_str() else {
return std::borrow::Cow::Borrowed(headers);
};
let retained = text.split(',').map(str::trim).filter(|token| {
let (name, value) = token.split_once('=').unwrap_or((token, ""));
let name = name.trim();
let skip = name.eq_ignore_ascii_case("no-cache")
|| (name.eq_ignore_ascii_case("max-age")
&& value.trim().trim_matches('"').parse::<u64>() == Ok(0));
if skip { changed = true; }
!skip
}).collect::<Vec<_>>();
if !retained.is_empty() {
let Ok(value) = HeaderValue::from_str(&retained.join(", ")) else {
return std::borrow::Cow::Borrowed(headers);
};
retained_values.push(value);
}
}
if changed {
freshness.remove("cache-control");
for value in retained_values {
freshness.append("cache-control", value);
}
}
}
if changed { std::borrow::Cow::Owned(freshness) }
else { std::borrow::Cow::Borrowed(headers) }
}
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)
compatible_freshness_headers(headers, settings.varnish_compatible)
}
pub fn only_if_cached(headers: &HeaderMap) -> bool {
@@ -173,25 +243,22 @@ 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;
}
// Compatibility only relaxes browser hard-reload freshness requests.
// Retain no-store, only-if-cached, min-fresh and max-stale semantics.
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") {
if !settings.varnish_compatible && 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::<u64>().ok()) else {
return RequestPolicy::Bypass("request_cache_control_invalid");
};
if value == 0 {
if value == 0 && !settings.varnish_compatible {
return RequestPolicy::Revalidate("request_max_age");
}
}
@@ -209,7 +276,10 @@ pub fn request_policy(
return RequestPolicy::Bypass("request_cache_control_invalid");
}
}
if !headers.contains_key("cache-control") && pragma_no_cache(headers) {
if !settings.varnish_compatible
&& !headers.contains_key("cache-control")
&& pragma_no_cache(headers)
{
return RequestPolicy::Revalidate("request_pragma_no_cache");
}
RequestPolicy::Use
@@ -404,13 +474,8 @@ pub fn response_reason(
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";
}
if settings.varnish_compatible && surrogate_no_store(headers) {
return "response_surrogate_control";
}
for value in headers.get_all("vary") {
if value.to_str().ok().is_none_or(|value| {
+25 -19
View File
@@ -92,15 +92,19 @@ 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))
{
// Surrogate-Control: no-store is a shared-cache veto in compatibility
// mode, regardless of public/fallback TTL or any other directive.
if settings.varnish_compatible && surrogate_no_store(headers) {
return None;
}
let requires_revalidation = control.contains_key("no-cache");
// Explicitly configured fallback TTL is an opt-in origin no-cache
// override *only* for public responses. With fallback disabled, keep
// the pre-0.34.7 validator-based revalidation and never grant a HIT.
let override_public_no_cache = settings.varnish_compatible
&& control.contains_key("no-cache")
&& control.contains_key("public")
&& settings.default_ttl_seconds > 0;
let requires_revalidation = control.contains_key("no-cache") && !override_public_no_cache;
let must_revalidate = control.contains_key("must-revalidate");
let proxy_revalidate = control.contains_key("proxy-revalidate");
let parse_window = |name: &str| -> Option<u64> {
@@ -124,7 +128,9 @@ pub fn plan(
None => now,
};
let declared_ttl =
if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) {
if override_public_no_cache {
Some(settings.default_ttl_seconds)
} else if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) {
Some(value.as_ref()?.parse::<u64>().ok()?)
} else if let Some(expires) = headers.get("expires") {
Some(
@@ -156,17 +162,17 @@ 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;
// Retain zero-TTL and already-expired representations for conditional
// revalidation, client max-stale or stale-if-error. They must not be
// served as fresh HITs. Varnish-style grace is only implicit when the
// origin did not explicitly set stale-while-revalidate (including =0),
// the object arrived fresh and no origin no-cache rule is in effect.
if settings.varnish_compatible
&& !control.contains_key("stale-while-revalidate")
&& !control.contains_key("no-cache")
&& initial_age.as_secs() < ttl
{
stale_while_revalidate = settings.varnish_grace_seconds;
}
if !requires_revalidation
&& initial_age.as_secs()
+4 -8
View File
@@ -395,16 +395,12 @@ impl Cache {
}
impl Ticket {
/// In Varnish compatibility mode, request cache directives do not constrain
/// freshness. Keep original headers for Vary and downstream conditionals.
/// Ignore only browser forced reload for freshness; keep min-fresh,
/// max-stale and other client constraints, plus original Vary headers.
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"))
if let std::borrow::Cow::Owned(freshness) =
compatible_freshness_headers(&self.request_headers, compatible)
{
let mut freshness = self.request_headers.clone();
freshness.remove("cache-control");
freshness.remove("pragma");
self.freshness_headers = Some(freshness);
}
self
+181 -32
View File
@@ -11,40 +11,56 @@ mod varnish_compatible_tests {
#[test]
fn default_and_legacy_serialization_are_explicit() {
assert!(Settings::default().varnish_compatible);
assert_eq!(Settings::default().varnish_grace_seconds, 60);
let legacy: Settings = serde_json::from_str(r#"{"enabled":true}"#).unwrap();
assert!(legacy.varnish_compatible);
assert_eq!(legacy.varnish_grace_seconds, 60);
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);
assert_eq!(enabled["varnish_grace_seconds"], 60);
let customized: Settings = serde_json::from_str(r#"{"varnish_grace_seconds":17}"#).unwrap();
assert_eq!(customized.varnish_grace_seconds, 17);
let mut invalid = customized.clone();
invalid.varnish_grace_seconds = 604_801;
assert!(invalid.validate().is_err(), "oversized grace must be rejected");
}
#[test]
fn client_freshness_is_ignored_only_in_varnish_mode() {
fn compatible_mode_ignores_hard_reload_but_preserves_client_restrictions() {
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 {
for value in ["no-cache", "max-age=0", "max-age=\"0\"", "max-age=000"] {
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}"
);
assert_eq!(request_policy(&Method::GET, &h, true, "/style.css", false, &compatible), RequestPolicy::Use);
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");
assert!(!freshness.contains_key("cache-control"), "hard reload must not constrain lookup: {value}");
assert!(h.contains_key("cache-control"), "origin request and Vary must remain unchanged");
}
for value in ["max-age=1", "min-fresh=600", "max-stale=9999", "only-if-cached"] {
let h = headers(value);
assert_eq!(request_policy(&Method::GET, &h, true, "/style.css", false, &compatible), RequestPolicy::Use);
assert!(request_freshness_headers(&h, &compatible).contains_key("cache-control"), "preserve {value}");
}
assert_eq!(request_policy(&Method::GET, &headers("no-store"), true, "/", false, &compatible), RequestPolicy::Bypass("request_no_store"));
assert_eq!(request_policy(&Method::GET, &headers("invalid directive"), true, "/", false, &compatible), RequestPolicy::Bypass("request_cache_control_invalid"));
assert!(only_if_cached(&headers("only-if-cached")));
let h = headers("no-cache, max-age=0, min-fresh=45, max-stale=90, only-if-cached");
let freshness = request_freshness_headers(&h, &compatible);
let parsed = directives(&freshness).unwrap();
assert!(!parsed.contains_key("no-cache"));
assert!(!parsed.contains_key("max-age"));
assert_eq!(parsed["min-fresh"].as_deref(), Some("45"));
assert_eq!(parsed["max-stale"].as_deref(), Some("90"));
assert!(parsed.contains_key("only-if-cached"));
let mut pragma = HeaderMap::new();
pragma.insert("pragma", HeaderValue::from_static("no-cache"));
pragma.insert("pragma", HeaderValue::from_static("no-cache, custom-token"));
assert_eq!(request_policy(&Method::GET, &pragma, true, "/", false, &compatible), RequestPolicy::Use);
assert!(!request_freshness_headers(&pragma, &compatible).contains_key("pragma"));
assert_eq!(request_freshness_headers(&pragma, &compatible)["pragma"], "custom-token");
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("max-age=0"), true, "/", false, &compatible), RequestPolicy::Revalidate("request_max_age"));
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]
@@ -71,7 +87,7 @@ mod varnish_compatible_tests {
}
#[test]
fn backend_response_policy_matches_varnish_without_weakening_privacy() {
fn backend_response_policy_preserves_validators_fallback_and_surrogate() {
let compatible = Settings::default();
let mut strict = compatible.clone();
strict.varnish_compatible = false;
@@ -81,24 +97,150 @@ mod varnish_compatible_tests {
}
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());
let validator_plan = plan(StatusCode::OK, &no_cache, &compatible, Duration::ZERO)
.expect("no-cache with ETag remains revalidatable without fallback");
assert!(validator_plan.requires_revalidation);
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");
assert!(plan(StatusCode::OK, &headers("no-cache, max-age=60"), &compatible, Duration::ZERO).is_none());
let mut fallback = compatible.clone();
fallback.default_ttl_seconds = 42;
let public_no_cache = headers("public, no-cache, max-age=600");
let fallback_plan = plan(StatusCode::OK, &public_no_cache, &fallback, Duration::ZERO)
.expect("explicit fallback TTL for public origin no-cache");
assert_eq!(fallback_plan.ttl, 42);
assert!(!fallback_plan.requires_revalidation);
let nonpublic_no_cache = headers("no-cache, max-age=60");
assert!(plan(StatusCode::OK, &nonpublic_no_cache, &fallback, Duration::ZERO).is_none());
let mut surrogate = public_no_cache.clone();
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());
assert!(plan(StatusCode::OK, &surrogate, &fallback, Duration::ZERO).is_none());
assert_eq!(response_reason(StatusCode::OK, &surrogate, &fallback), "response_surrogate_control");
assert!(plan(StatusCode::OK, &surrogate, &strict, Duration::ZERO).is_none(), "strict no-cache without ETag remains uncached");
let mut private = headers("public, private, no-cache");
private.insert("etag", HeaderValue::from_static("\"secret\""));
assert!(plan(StatusCode::OK, &private, &fallback, Duration::ZERO).is_none());
for control in ["max-age=0", "max-age=60, s-maxage=0", "max-age=0, stale-if-error=120"] {
let p = plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO)
.expect("zero TTL is retained for max-stale, validation or stale-if-error");
assert_eq!(p.ttl, 0, "{control}");
assert_eq!(p.stale_while_revalidate, 0, "zero TTL must not receive implicit grace");
}
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());
assert!(plan(StatusCode::OK, &cookie, &fallback, 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());
assert!(plan(StatusCode::OK, &vary, &fallback, Duration::ZERO).is_none());
}
#[test]
fn explicit_zero_grace_and_stale_if_error_are_preserved() {
let compatible = Settings::default();
let with_zero = plan(StatusCode::OK, &headers("public, max-age=60, stale-while-revalidate=0"), &compatible, Duration::ZERO).unwrap();
assert_eq!(with_zero.stale_while_revalidate, 0);
let with_grace = plan(StatusCode::OK, &headers("public, max-age=60"), &compatible, Duration::ZERO).unwrap();
assert_eq!(with_grace.stale_while_revalidate, 60);
let mut custom_settings = compatible.clone();
custom_settings.varnish_grace_seconds = 25;
let with_custom_default = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap();
assert_eq!(with_custom_default.stale_while_revalidate, 25);
custom_settings.varnish_grace_seconds = 0;
let grace_disabled = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap();
assert_eq!(grace_disabled.stale_while_revalidate, 0);
custom_settings.varnish_compatible = false;
custom_settings.varnish_grace_seconds = 60;
let strict = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap();
assert_eq!(strict.stale_while_revalidate, 0);
let with_custom = plan(StatusCode::OK, &headers("public, max-age=60, stale-while-revalidate=30"), &compatible, Duration::ZERO).unwrap();
assert_eq!(with_custom.stale_while_revalidate, 30);
let zero = plan(StatusCode::OK, &headers("public, max-age=0, stale-if-error=120"), &compatible, Duration::ZERO).unwrap();
assert_eq!(zero.ttl, 0);
assert_eq!(zero.stale_if_error, 120);
assert_eq!(zero.stale_while_revalidate, 0);
}
#[tokio::test]
async fn origin_no_cache_etag_can_still_revalidate_without_fallback() {
let cache = Cache::new(1);
let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/backend.css", &HeaderMap::new()).unwrap();
let mut response = Response::new(Body::from("etag css"));
*response.headers_mut() = headers("public, no-cache, max-age=600");
response.headers_mut().insert("etag", HeaderValue::from_static("\"origin-v1\""));
let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
assert!(p.requires_revalidation);
cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap();
assert!(cache.get(&ticket).is_none(), "backend no-cache cannot produce an unvalidated HIT");
let stored = cache.revalidation(&ticket).expect("validator and body retained for 304");
let mut request = HeaderMap::new();
assert!(stored.apply_validators(&mut request), "conditional request can validate stored object");
assert_eq!(request["if-none-match"], "\"origin-v1\"");
}
#[tokio::test]
async fn positive_fallback_ttl_reuses_only_public_no_cache_entries() {
let cache = Cache::new(1);
let mut settings = Settings::default();
settings.default_ttl_seconds = 75;
let ticket = cache.ticket(1, "https", "example.test", "app", "/backend.css", &HeaderMap::new()).unwrap();
let mut response = Response::new(Body::from("public css"));
*response.headers_mut() = headers("public, no-cache");
let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
assert_eq!(p.ttl, 75);
assert!(!p.requires_revalidation);
cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap();
let hit = cache.get(&ticket).expect("explicit public override allows fresh HIT without ETag");
assert!(hit.remaining_ttl > 0);
}
#[tokio::test]
async fn zero_ttl_stale_if_error_fallback_survives_in_varnish_mode() {
let cache = Cache::new(1);
let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/fallback.css", &HeaderMap::new()).unwrap();
let mut response = Response::new(Body::from("public fallback"));
*response.headers_mut() = headers("public, max-age=0, stale-if-error=120");
let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap();
assert!(cache.get(&ticket).is_none(), "zero TTL must never create a fresh HIT");
assert!(cache.revalidation(&ticket).unwrap().can_stale_if_error(), "body remains available on backend failure");
}
#[tokio::test]
async fn min_fresh_and_max_stale_still_constrain_compatibility_hits() {
let cache = Cache::new(1);
let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &HeaderMap::new()).unwrap();
let mut response = Response::new(Body::from("css"));
*response.headers_mut() = headers("public, max-age=60");
let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
cache.wrap(response, ticket.clone(), p, &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 too_fresh = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("no-cache, max-age=0, min-fresh=20")).unwrap().with_varnish_compatible(true);
assert!(cache.get(&too_fresh).is_none(), "min-fresh requires 20s but only 15s remain");
let enough = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("min-fresh=10")).unwrap().with_varnish_compatible(true);
assert!(cache.get(&enough).is_some(), "10s remaining freshness is available");
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.received = Instant::now() - Duration::from_secs(90);
}
}
let max_stale = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("no-cache, max-stale=45")).unwrap().with_varnish_compatible(true);
let stale = cache.get(&max_stale).expect("max-stale explicitly allows retained stale object");
assert!(stale.stale);
assert!(!stale.background_revalidate, "explicit max-stale never triggers implicit background refresh");
let too_old = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("max-stale=10")).unwrap().with_varnish_compatible(true);
assert!(cache.get(&too_old).is_none());
let default = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &HeaderMap::new()).unwrap().with_varnish_compatible(true);
assert!(cache.get(&default).is_none(), "expired object cannot be served without stale allowance");
}
#[tokio::test]
@@ -116,7 +258,7 @@ mod varnish_compatible_tests {
entry.received = Instant::now() - Duration::from_secs(45);
}
}
let mut request = headers("no-cache, max-age=0, min-fresh=900");
let mut request = headers("no-cache, max-age=0");
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");
@@ -130,14 +272,14 @@ mod varnish_compatible_tests {
}
#[tokio::test]
async fn ten_second_grace_and_no_unconditional_stale_outside_window() {
async fn sixty_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);
assert_eq!(plan.stale_while_revalidate, 60);
cache.wrap(response, ticket.clone(), plan, &settings).into_body().collect().await.unwrap();
{
let mut store = cache.store.lock().unwrap();
@@ -147,10 +289,17 @@ mod varnish_compatible_tests {
}
let hit = cache.get(&ticket).expect("grace HIT");
assert!(hit.stale && hit.background_revalidate);
let max_stale_zero = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("max-stale=0")).unwrap().with_varnish_compatible(true);
assert!(cache.get(&max_stale_zero).is_none(), "explicit max-stale=0 must override implicit grace");
let max_stale_small = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("no-cache, max-stale=3")).unwrap().with_varnish_compatible(true);
assert!(cache.get(&max_stale_small).is_none(), "max-stale=3 rejects object stale by 5s, even in grace");
let max_stale_enough = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("no-cache, max-stale=9")).unwrap().with_varnish_compatible(true);
let accepted = cache.get(&max_stale_enough).expect("max-stale=9 admits object 5s stale");
assert!(accepted.stale);
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.received = Instant::now() - Duration::from_secs(75);
entry.received = Instant::now() - Duration::from_secs(125);
}
}
assert!(cache.get(&ticket).is_none(), "expired beyond grace does not produce HIT");