This commit is contained in:
Mateusz Gruszczyński committed 2026-10-22 15:34:17 +02:00
1 parent 1cd6b7ac99
commit 58c7d63194
5 files changed
+281 -71

No files matched your search

-3
View File
@@ -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)
+69 -17
View File
@@ -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;
}
+41 -9
View File
@@ -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<WebRevalidated> {
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)]
+158 -39
View File
@@ -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<Cache>, Ticket, Settings) {
async fn fixture() -> (
TempDir,
web_server::CompiledService,
Arc<Cache>,
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<Cache>, 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<WebRevalidated> {
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::<u64>().unwrap();
let hits = response.headers()["x-cache-hits"]
.to_str()
.unwrap()
.parse::<u64>()
.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());
}
+13 -3
View File
@@ -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::<Vec<_>>().join("\n");
let value = php
.php_admin_values
.iter()
.map(|(name, item)| format!("{name}={item}"))
.collect::<Vec<_>>()
.join("\n");
params.insert("PHP_ADMIN_VALUE".into(), value);
}
let mut encoded = Vec::with_capacity(4096);