v0.34.7
This commit is contained in:
1 parent
133d2f0f91
commit
3ea00045bf
27 files changed
+446
-136
No files matched your search
Vendored
+3
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -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!(
|
||||
|
||||
Vendored
+3
-3
@@ -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;
|
||||
|
||||
Vendored
+1
@@ -88,6 +88,7 @@ pub struct Ticket {
|
||||
target: String,
|
||||
key: Key,
|
||||
request_headers: HeaderMap,
|
||||
freshness_headers: Option<HeaderMap>,
|
||||
generation: u64,
|
||||
revision: u64,
|
||||
scope: String,
|
||||
|
||||
Vendored
+3
@@ -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,
|
||||
|
||||
Vendored
+46
-1
@@ -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| {
|
||||
|
||||
Vendored
+21
-1
@@ -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(
|
||||
|
||||
Vendored
+3
@@ -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");
|
||||
Vendored
+22
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user