diff --git a/CHANGELOG.md b/CHANGELOG.md index c102000..dac4cf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,6 @@ Release history focused on **features, settings, default behavior, compatibility - **Settings → Cache → Varnish-compatible cache policy (`varnish_compatible`)** stays **ON by default**, including upgraded configurations without the field. An adjacent **i** popover (PL/EN) explains its effects, limitations and safety controls. OFF retains strict client hard-reload behavior. The live toggle invalidates existing entries across HOT/COLD. - Only client hard-reload `Cache-Control: no-cache`, `max-age=0` and `Pragma: no-cache` are ignored when considering a **fresh HIT**, retaining `Age` and hit counters. Kept the original `Cache-Control: no-store` bypass, `only-if-cached` 504 on cache MISS and no background origin refresh on HIT, and `min-fresh`/`max-stale`/positive `max-age` freshness constraints (including explicit `max-stale=0` during grace). Original request headers, Vary, access/privacy rules and Web Server handling remain intact. -- **Origin `no-cache` regression fix:** ETag/Last-Modified entries remain cacheable for conditional revalidation when fallback TTL is disabled. With **explicit positive fallback TTL**, only origin responses marked **`public`** may be reused for that TTL (capped by max TTL), even without origin validators. This is an explicit administrative override of origin `no-cache`, not the default. `private`, `no-store`, `Set-Cookie` and compatibility-mode `Surrogate-Control: no-store` remain vetoes. -- **Zero TTL regression fix:** keep expired representations for revalidation, client `max-stale` and configured `stale-if-error`; an expired object does not become a fresh HIT. Implicit grace defaults to **60 seconds**, can be set in Settings → Cache (`varnish_grace_seconds`, 0 disables it), and applies only to eligible objects stored fresh with no `stale-while-revalidate` directive; explicit `stale-while-revalidate=0` is never overwritten. -- **UI layout:** nested Settings tab bars no longer stick over Cache checkboxes while scrolling; the top-level Settings navigation remains sticky. - Source-level regression cases cover hard reload, fallback TTL, validator revalidation, no-store/private/Surrogate protections, zero-TTL stale-if-error, min-fresh/max-stale, and `only-if-cached` for proxy and Web Server. Native Rust compilation and runtime acceptance are required before a production release claim. This remains a Varnish-inspired policy subset, not a VCL clone. ## 0.34.6 — Docker live logs, neutral Publish switch and Vhost Content-Type rules (source candidate) diff --git a/src/proxy/forward/cache_fast_path_tests.rs b/src/proxy/forward/cache_fast_path_tests.rs index 3f17aab..ef4b574 100644 --- a/src/proxy/forward/cache_fast_path_tests.rs +++ b/src/proxy/forward/cache_fast_path_tests.rs @@ -614,7 +614,10 @@ mod cache_fast_path_tests { .await; assert_eq!(validated.status, StatusCode::OK); assert_eq!(validated.headers["x-cache"], "HIT"); - assert_eq!(validated.headers["x-cache-reason"], "cache_hit", "default compatibility mode must not revalidate a fresh object"); + assert_eq!( + validated.headers["x-cache-reason"], "cache_hit", + "default compatibility mode must not revalidate a fresh object" + ); assert_eq!(validated.body, "origin-a"); let conditional = fixture .get( @@ -641,17 +644,29 @@ mod cache_fast_path_tests { async fn compatible_only_if_cached_on_miss_never_contacts_origin() { let origin = origin().await; let fixture = Fixture::new(config_for(origin.addr, true)).await; - let miss = fixture.get("/missing", &[("cache-control", "no-cache, only-if-cached")]).await; + let miss = fixture + .get("/missing", &[("cache-control", "no-cache, only-if-cached")]) + .await; assert_eq!(miss.status, StatusCode::GATEWAY_TIMEOUT); - assert_eq!(origin.requests.load(Ordering::Relaxed), 0, "only-if-cached MISS must not contact backend"); + assert_eq!( + origin.requests.load(Ordering::Relaxed), + 0, + "only-if-cached MISS must not contact backend" + ); let primed = fixture.get("/vary", &[]).await; assert_eq!(primed.headers["x-cache"], "MISS"); - let hit = fixture.get("/vary", &[("cache-control", "only-if-cached")]).await; + let hit = fixture + .get("/vary", &[("cache-control", "only-if-cached")]) + .await; assert_eq!(hit.headers["x-cache"], "HIT"); assert_eq!(origin.requests.load(Ordering::Relaxed), 1); let blocked = fixture.get("/vary", &[("cache-control", "no-store")]).await; assert_eq!(blocked.headers["x-cache-reason"], "request_no_store"); - assert_eq!(origin.requests.load(Ordering::Relaxed), 2, "no-store must bypass even a fresh HIT"); + assert_eq!( + origin.requests.load(Ordering::Relaxed), + 2, + "no-store must bypass even a fresh HIT" + ); fixture.stop().await; } @@ -661,7 +676,10 @@ mod cache_fast_path_tests { let mut config = config_for(origin.addr, true); config.cache.default_ttl_seconds = 0; let fixture = Fixture::new(config).await; - assert_eq!(fixture.get("/backend-no-cache", &[]).await.headers["x-cache"], "MISS"); + assert_eq!( + fixture.get("/backend-no-cache", &[]).await.headers["x-cache"], + "MISS" + ); let cached_only = fixture .get("/backend-no-cache", &[("cache-control", "only-if-cached")]) .await; @@ -678,11 +696,18 @@ mod cache_fast_path_tests { let mut config = config_for(origin.addr, true); config.cache.default_ttl_seconds = 45; let fixture = Fixture::new(config).await; - assert_eq!(fixture.get("/backend-no-cache", &[]).await.headers["x-cache"], "MISS"); + assert_eq!( + fixture.get("/backend-no-cache", &[]).await.headers["x-cache"], + "MISS" + ); let reused = fixture.get("/backend-no-cache", &[]).await; assert_eq!(reused.headers["x-cache"], "HIT"); assert_eq!(reused.headers["x-cache-reason"], "cache_hit"); - assert_eq!(origin.requests.load(Ordering::Relaxed), 1, "configured fallback TTL avoids origin revalidation"); + assert_eq!( + origin.requests.load(Ordering::Relaxed), + 1, + "configured fallback TTL avoids origin revalidation" + ); fixture.stop().await; } @@ -692,12 +717,21 @@ mod cache_fast_path_tests { let fixture = Fixture::new(config_for(origin.addr, true)).await; assert_eq!(fixture.get("/grace", &[]).await.headers["x-cache"], "MISS"); tokio::time::sleep(Duration::from_millis(1200)).await; - let stale = fixture.get("/grace", &[("cache-control", "only-if-cached")]).await; + let stale = fixture + .get("/grace", &[("cache-control", "only-if-cached")]) + .await; assert_eq!(stale.status, StatusCode::OK); assert_eq!(stale.headers["x-cache"], "HIT"); - assert_eq!(stale.headers["x-cache-reason"], "cache_stale_while_revalidate"); + assert_eq!( + stale.headers["x-cache-reason"], + "cache_stale_while_revalidate" + ); tokio::time::sleep(Duration::from_millis(100)).await; - assert_eq!(origin.requests.load(Ordering::Relaxed), 1, "only-if-cached must not start async validation"); + assert_eq!( + origin.requests.load(Ordering::Relaxed), + 1, + "only-if-cached must not start async validation" + ); fixture.stop().await; } @@ -705,14 +739,32 @@ mod cache_fast_path_tests { async fn web_server_only_if_cached_on_miss_never_reads_origin() { let directory = TempDir::new().unwrap(); let fixture = Fixture::new(web_config(&directory, true)).await; - let miss = fixture.get("/style.css", &[("cache-control", "only-if-cached")]).await; + let miss = fixture + .get("/style.css", &[("cache-control", "only-if-cached")]) + .await; assert_eq!(miss.status, StatusCode::GATEWAY_TIMEOUT); - assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "MISS"); - let hit = fixture.get("/style.css", &[("cache-control", "only-if-cached")]).await; + assert_eq!( + fixture.get("/style.css", &[]).await.headers["x-cache"], + "MISS" + ); + let hit = fixture + .get("/style.css", &[("cache-control", "only-if-cached")]) + .await; assert_eq!(hit.headers["x-cache"], "HIT"); - let bypass = fixture.get("/style.css", &[("cache-control", "no-store, only-if-cached")]).await; - assert_eq!(bypass.status, StatusCode::GATEWAY_TIMEOUT, "uncacheable only-if-cached must not read the Web Server file"); - let reload = fixture.get("/style.css", &[("cache-control", "no-cache")]).await; + let bypass = fixture + .get( + "/style.css", + &[("cache-control", "no-store, only-if-cached")], + ) + .await; + assert_eq!( + bypass.status, + StatusCode::GATEWAY_TIMEOUT, + "uncacheable only-if-cached must not read the Web Server file" + ); + let reload = fixture + .get("/style.css", &[("cache-control", "no-cache")]) + .await; assert_eq!(reload.headers["x-cache-reason"], "cache_hit"); fixture.stop().await; } diff --git a/src/proxy/web_cache_revalidation/mod.rs b/src/proxy/web_cache_revalidation/mod.rs index 4f38e94..e12d554 100644 --- a/src/proxy/web_cache_revalidation/mod.rs +++ b/src/proxy/web_cache_revalidation/mod.rs @@ -14,7 +14,9 @@ pub(super) fn web_origin_request_headers( cached: Option<&Revalidation>, ) -> (HeaderMap, bool) { let mut headers = client.clone(); - let Some(cached) = cached else { return (headers, false) }; + let Some(cached) = cached else { + return (headers, false); + }; headers.remove("if-none-match"); headers.remove("if-modified-since"); let sent = cached.apply_validators(&mut headers); @@ -25,7 +27,12 @@ fn web_merged_validation_headers(cached: &Revalidation, validation: &HeaderMap) let mut merged = cached.merge_validation_headers(validation); if cached.transformed() { for name in [ - "content-encoding", "vary", "accept-ranges", "content-md5", "digest", "content-digest", + "content-encoding", + "vary", + "accept-ranges", + "content-md5", + "digest", + "content-digest", ] { merged.remove(name); for value in cached.headers().get_all(name) { @@ -50,7 +57,8 @@ pub(super) fn finish_web_revalidation( apply_rules: impl FnOnce(&mut HeaderMap), ) -> Option { let origin_merged = web_merged_validation_headers(cached, validation); - let origin_safe = crate::cache::plan(StatusCode::OK, &origin_merged, settings, elapsed).is_some(); + let origin_safe = + crate::cache::plan(StatusCode::OK, &origin_merged, settings, elapsed).is_some(); let mut merged = origin_merged; apply_rules(&mut merged); if force_cache { @@ -66,8 +74,17 @@ pub(super) fn finish_web_revalidation( let range = crate::cache::request_range(method, client); let if_range = client.get("if-range"); let published = if can_store { - cache.publish_revalidated(ticket.clone(), cached, merged.clone(), validation, settings, elapsed) - } else { None }; + cache.publish_revalidated( + ticket.clone(), + cached, + merged.clone(), + validation, + settings, + elapsed, + ) + } else { + None + }; let (mut response, hits, ttl, resident_age, transformed, reason) = if let Some(mut hit) = published { if !client_not_modified { @@ -76,8 +93,12 @@ pub(super) fn finish_web_revalidation( } } ( - hit.response, hit.hits, hit.remaining_ttl, hit.resident_age_seconds, - hit.transformed, "cache_revalidated", + hit.response, + hit.hits, + hit.remaining_ttl, + hit.resident_age_seconds, + hit.transformed, + "cache_revalidated", ) } else { if !cache.ticket_current(ticket) { @@ -93,7 +114,14 @@ pub(super) fn finish_web_revalidation( } else { cached.response(merged) }; - (response, 0, 0, 0, cached.transformed(), "cache_revalidated_uncached") + ( + response, + 0, + 0, + 0, + cached.transformed(), + "cache_revalidated_uncached", + ) }; if client_not_modified { response = crate::cache::make_not_modified(response); @@ -104,7 +132,11 @@ pub(super) fn finish_web_revalidation( settings.annotate_reason(response.headers_mut(), "HIT", hits, ttl, reason); settings.annotate_transformed(response.headers_mut(), transformed); settings.annotate_resident_age(response.headers_mut(), resident_age); - Some(WebRevalidated { response, transformed, reason }) + Some(WebRevalidated { + response, + transformed, + reason, + }) } #[cfg(test)] diff --git a/src/proxy/web_cache_revalidation/tests.rs b/src/proxy/web_cache_revalidation/tests.rs index dd72877..8bc3bae 100644 --- a/src/proxy/web_cache_revalidation/tests.rs +++ b/src/proxy/web_cache_revalidation/tests.rs @@ -1,8 +1,8 @@ use super::*; use crate::{cache, web_server}; use http_body_util::BodyExt; -use tempfile::TempDir; use std::sync::Arc; +use tempfile::TempDir; fn cache_settings() -> Settings { Settings { @@ -14,7 +14,13 @@ fn cache_settings() -> Settings { } } -async fn fixture() -> (TempDir, web_server::CompiledService, Arc, Ticket, Settings) { +async fn fixture() -> ( + TempDir, + web_server::CompiledService, + Arc, + Ticket, + Settings, +) { let dir = TempDir::new().unwrap(); std::fs::write(dir.path().join("shCore.js"), b"before").unwrap(); let service = web_server::Service { @@ -23,26 +29,51 @@ async fn fixture() -> (TempDir, web_server::CompiledService, Arc, Ticket, document_root: dir.path().to_path_buf(), cache_control: Some("public, max-age=604800".into()), ..Default::default() - }.compile().unwrap(); + } + .compile() + .unwrap(); let cache = Cache::new(1); let settings = cache_settings(); - let ticket = cache.ticket( - 1, "https", "blog.example.test", "wordpress", - "/shCore.js?ver=3.0.9b", &HeaderMap::new(), - ).unwrap(); + let ticket = cache + .ticket( + 1, + "https", + "blog.example.test", + "wordpress", + "/shCore.js?ver=3.0.9b", + &HeaderMap::new(), + ) + .unwrap(); let initial = serve_file(&service, Method::GET, HeaderMap::new()).await; assert_eq!(initial.status(), StatusCode::OK); - let plan = cache::plan(initial.status(), initial.headers(), &settings, Duration::ZERO) - .expect("static origin response is cacheable"); - cache.wrap(initial, ticket.clone(), plan, &settings) - .into_body().collect().await.unwrap(); + let plan = cache::plan( + initial.status(), + initial.headers(), + &settings, + Duration::ZERO, + ) + .expect("static origin response is cacheable"); + cache + .wrap(initial, ticket.clone(), plan, &settings) + .into_body() + .collect() + .await + .unwrap(); (dir, service, cache, ticket, settings) } -async fn serve_file(service: &web_server::CompiledService, method: Method, headers: HeaderMap) -> Response { +async fn serve_file( + service: &web_server::CompiledService, + method: Method, + headers: HeaderMap, +) -> Response { web_server::serve( - service, method, headers, - "/shCore.js?ver=3.0.9b".parse().unwrap(), "/shCore.js", Body::empty(), + service, + method, + headers, + "/shCore.js?ver=3.0.9b".parse().unwrap(), + "/shCore.js", + Body::empty(), web_server::RequestContext { client_ip: "127.0.0.1".parse().unwrap(), client_port: Some(12345), @@ -51,22 +82,41 @@ async fn serve_file(service: &web_server::CompiledService, method: Method, heade tls: true, version: http::Version::HTTP_2, }, - ).await + ) + .await } async fn validate( - service: &web_server::CompiledService, cache: &Cache, ticket: &Ticket, - settings: &Settings, method: Method, client: HeaderMap, + service: &web_server::CompiledService, + cache: &Cache, + ticket: &Ticket, + settings: &Settings, + method: Method, + client: HeaderMap, ) -> Option { let cached = cache.revalidation_tiered(ticket).await.unwrap(); let (headers, sent) = web_origin_request_headers(&client, Some(&cached)); - assert!(sent, "stored validator must be used for local source revalidation"); + assert!( + sent, + "stored validator must be used for local source revalidation" + ); let origin = serve_file(service, method.clone(), headers).await; if origin.status() != StatusCode::NOT_MODIFIED { return None; } - finish_web_revalidation(cache, ticket, &cached, origin.headers(), settings, - Duration::ZERO, &client, &method, false, false, |_| {}) + finish_web_revalidation( + cache, + ticket, + &cached, + origin.headers(), + settings, + Duration::ZERO, + &client, + &method, + false, + false, + |_| {}, + ) } #[tokio::test] @@ -77,13 +127,22 @@ async fn browser_refresh_304_preserves_web_server_hit_counter() { let mut client = HeaderMap::new(); client.insert("cache-control", directive.parse().unwrap()); let response = validate(&service, &cache, &ticket, &settings, Method::GET, client) - .await.unwrap().response; + .await + .unwrap() + .response; assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()["x-cache"], "HIT"); - let hits = response.headers()["x-cache-hits"].to_str().unwrap().parse::().unwrap(); + let hits = response.headers()["x-cache-hits"] + .to_str() + .unwrap() + .parse::() + .unwrap(); assert!(hits >= 2); assert_eq!(response.headers()["x-cache-reason"], "cache_revalidated"); - assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "before"); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "before" + ); } assert_eq!(cache.get_tiered(&ticket, &settings).await.unwrap().hits, 4); } @@ -96,11 +155,19 @@ async fn browser_conditional_refresh_returns_304_without_resetting_hits() { client.insert("cache-control", HeaderValue::from_static("no-cache")); client.insert("if-none-match", etag); let response = validate(&service, &cache, &ticket, &settings, Method::GET, client) - .await.unwrap().response; + .await + .unwrap() + .response; assert_eq!(response.status(), StatusCode::NOT_MODIFIED); assert!(!response.headers().contains_key("content-length")); assert_eq!(response.headers()["x-cache-hits"], "1"); - assert!(response.into_body().collect().await.unwrap().to_bytes().is_empty()); + assert!(response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .is_empty()); assert_eq!(cache.get_tiered(&ticket, &settings).await.unwrap().hits, 2); } @@ -111,9 +178,14 @@ async fn mismatched_browser_validator_cannot_validate_different_cached_object() client.insert("cache-control", HeaderValue::from_static("no-cache")); client.insert("if-none-match", HeaderValue::from_static("\"unrelated\"")); let response = validate(&service, &cache, &ticket, &settings, Method::GET, client) - .await.unwrap().response; + .await + .unwrap() + .response; assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "before"); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "before" + ); } #[tokio::test] @@ -123,13 +195,33 @@ async fn range_and_head_requests_use_validated_cached_body() { range.insert("cache-control", HeaderValue::from_static("max-age=0")); range.insert("range", HeaderValue::from_static("bytes=0-2")); let partial = validate(&service, &cache, &ticket, &settings, Method::GET, range) - .await.unwrap().response; + .await + .unwrap() + .response; assert_eq!(partial.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(partial.into_body().collect().await.unwrap().to_bytes(), "bef"); - let head = validate(&service, &cache, &ticket, &settings, Method::HEAD, HeaderMap::new()) - .await.unwrap().response; + assert_eq!( + partial.into_body().collect().await.unwrap().to_bytes(), + "bef" + ); + let head = validate( + &service, + &cache, + &ticket, + &settings, + Method::HEAD, + HeaderMap::new(), + ) + .await + .unwrap() + .response; assert_eq!(head.status(), StatusCode::OK); - assert!(head.into_body().collect().await.unwrap().to_bytes().is_empty()); + assert!(head + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .is_empty()); } #[tokio::test] @@ -143,8 +235,12 @@ async fn changed_local_file_is_not_falsely_revalidated() { assert_eq!(origin.status(), StatusCode::OK); let plan = cache::plan(origin.status(), origin.headers(), &settings, Duration::ZERO).unwrap(); cache.discard(&ticket); - cache.wrap(origin, ticket.clone(), plan, &settings) - .into_body().collect().await.unwrap(); + cache + .wrap(origin, ticket.clone(), plan, &settings) + .into_body() + .collect() + .await + .unwrap(); assert_eq!(cache.get_tiered(&ticket, &settings).await.unwrap().hits, 1); } @@ -153,12 +249,35 @@ async fn response_304_with_private_policy_discards_cached_representation() { let (_dir, _service, cache, ticket, settings) = fixture().await; let cached = cache.revalidation_tiered(&ticket).await.unwrap(); let mut headers = HeaderMap::new(); - headers.insert("cache-control", HeaderValue::from_static("private, max-age=300")); + headers.insert( + "cache-control", + HeaderValue::from_static("private, max-age=300"), + ); headers.insert("etag", cached.headers()["etag"].clone()); - let outcome = finish_web_revalidation(&cache, &ticket, &cached, &headers, - &settings, Duration::ZERO, &HeaderMap::new(), &Method::GET, - false, false, |_| {}).unwrap(); + let outcome = finish_web_revalidation( + &cache, + &ticket, + &cached, + &headers, + &settings, + Duration::ZERO, + &HeaderMap::new(), + &Method::GET, + false, + false, + |_| {}, + ) + .unwrap(); assert_eq!(outcome.reason, "cache_revalidated_uncached"); - assert_eq!(outcome.response.into_body().collect().await.unwrap().to_bytes(), "before"); + assert_eq!( + outcome + .response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(), + "before" + ); assert!(cache.revalidation_tiered(&ticket).await.is_none()); } diff --git a/src/web_server/fastcgi/protocol.rs b/src/web_server/fastcgi/protocol.rs index cc2863f..fb4b9df 100644 --- a/src/web_server/fastcgi/protocol.rs +++ b/src/web_server/fastcgi/protocol.rs @@ -163,7 +163,10 @@ pub(super) fn fastcgi_params( if context.tls { "on" } else { "off" }.into(), ); params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into()); - params.insert("SERVER_SOFTWARE".into(), concat!("RustProxy/", env!("CARGO_PKG_VERSION")).into()); + params.insert( + "SERVER_SOFTWARE".into(), + concat!("RustProxy/", env!("CARGO_PKG_VERSION")).into(), + ); params.insert("REDIRECT_STATUS".into(), "200".into()); if let Some(value) = headers .get(CONTENT_TYPE) @@ -199,7 +202,9 @@ pub(super) fn fastcgi_params( }) .or_insert_with(|| value.to_owned()); } - params.entry("HTTP_HOST".into()).or_insert_with(|| context.server_name.clone()); + params + .entry("HTTP_HOST".into()) + .or_insert_with(|| context.server_name.clone()); for (name, value) in &php.custom_params { params.insert(name.clone(), value.clone()); } @@ -216,7 +221,12 @@ pub(super) fn fastcgi_params( params.insert("PHP_VALUE".into(), value); } if !php.php_admin_values.is_empty() { - let value = php.php_admin_values.iter().map(|(name, item)| format!("{name}={item}")).collect::>().join("\n"); + let value = php + .php_admin_values + .iter() + .map(|(name, item)| format!("{name}={item}")) + .collect::>() + .join("\n"); params.insert("PHP_ADMIN_VALUE".into(), value); } let mut encoded = Vec::with_capacity(4096);