v0.34.7
This commit is contained in:
1 parent
3ea00045bf
commit
1cd6b7ac99
22 files changed
+550
-149
No files matched your search
@@ -2062,10 +2062,17 @@
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"varnish_grace_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 604800,
|
||||
"default": 60,
|
||||
"description": "Implicit grace window in seconds for eligible positive-TTL objects in Varnish-compatible mode without an explicit origin stale-while-revalidate directive. Zero disables implicit grace. Explicit origin stale-while-revalidate, including =0, takes precedence."
|
||||
},
|
||||
"varnish_compatible": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Default true: Varnish-style request cache policy (ignore client Cache-Control/Pragma directives including no-cache, max-age=0, only-if-cached); reject origin no-cache, Surrogate-Control no-store and nonpositive remaining TTL; 10-second stale-while-revalidate grace for eligible stored objects. Existing RustProxy privacy, cookies, Vary, explicit TTL and invalidation options remain in force. False restores strict client-cache directive handling; switching modes invalidates cache; not full VCL emulation."
|
||||
"description": "Default true: ignore only client hard-reload no-cache, max-age=0 and Pragma: no-cache on fresh cache lookups. Preserve client no-store, only-if-cached (504 on MISS), min-fresh/max-stale, origin ETag revalidation and zero-TTL stale-if-error storage. A positive default_ttl_seconds explicitly allows public origin no-cache responses to be reused for that fallback TTL, even without validators; zero keeps validator-only revalidation. Surrogate-Control: no-store and all private/no-store/Set-Cookie/Vary restrictions remain active. Eligible positive-TTL entries without an explicit stale-while-revalidate directive get configurable grace (varnish_grace_seconds, default 60s; explicit =0 is honored). False restores strict browser reload. Switching modes invalidates cache; not full Varnish VCL emulation."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -11,7 +11,7 @@ function renderSettings() {
|
||||
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
|
||||
}
|
||||
if (!settingsDirty.has("cache-form")) {
|
||||
const c = config.cache || {}; $("cache-enabled").checked = !!c.enabled; $("cache-varnish-compatible").checked = c.varnish_compatible !== false; $("cache-memory").value = (c.max_bytes ?? 268435456) / 1048576;
|
||||
const c = config.cache || {}; $("cache-enabled").checked = !!c.enabled; $("cache-varnish-compatible").checked = c.varnish_compatible !== false; $("cache-varnish-grace").value = c.varnish_grace_seconds ?? 60; $("cache-memory").value = (c.max_bytes ?? 268435456) / 1048576;
|
||||
$("cache-transient-memory").value = (c.transient_max_bytes ?? 67108864) / 1048576;
|
||||
$("cache-object").value = (c.max_object_bytes ?? 16777216) / 1024; $("cache-entries").value = c.max_entries ?? 4096;
|
||||
$("cache-eviction-limit").value = c.hot_eviction_max_objects ?? 64;
|
||||
@@ -286,7 +286,7 @@ bindSettingsForm("response-form", next => {
|
||||
next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked;
|
||||
}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression }));
|
||||
bindSettingsForm("cache-form", next => {
|
||||
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, varnish_compatible: $("cache-varnish-compatible").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), http_invalidation_allowed_cidrs: $("cache-http-invalidation-cidrs").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked, expose_only_hits: $("cache-only-hits").checked, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null };
|
||||
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, varnish_compatible: $("cache-varnish-compatible").checked, varnish_grace_seconds: Number($("cache-varnish-grace").value), memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), http_invalidation_allowed_cidrs: $("cache-http-invalidation-cidrs").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked, expose_only_hits: $("cache-only-hits").checked, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null };
|
||||
}, "cache");
|
||||
bindSettingsForm("transform-form", next => {
|
||||
next.transform = {
|
||||
|
||||
@@ -2210,9 +2210,24 @@
|
||||
Authorization, cookies and private/no-store responses still bypass the cache.
|
||||
One byte range can be served from a complete cached object;
|
||||
multipart/unsupported ranges pass through.</p>
|
||||
<label class="check"><input id="cache-varnish-compatible" type="checkbox" /><span
|
||||
data-i18n="cacheVarnishCompatible">Varnish-compatible cache policy (recommended; enabled by default)</span></label>
|
||||
<p class="fine" data-i18n="cacheVarnishCompatibleHelp">When enabled, client Cache-Control and Pragma are ignored for cache lookup: Shift+F5/no-cache/max-age=0 does not replace a fresh HIT or reset its Age. Response private/no-store/no-cache and Surrogate-Control no-store stay uncacheable, responses with no remaining TTL stay out of cache, and eligible expired objects may use up to 10 seconds of background-refresh grace. Existing static-cookie exceptions, Authorization protection, Vary, explicit TTL limits, BAN/PURGE, HOT/COLD, transforms and unsafe-force rules remain independent. Off restores the previous strict client HTTP caching policy. Changing modes invalidates stored objects. This is not full Varnish VCL emulation.</p>
|
||||
<div class="cache-varnish-setting">
|
||||
<label class="check"><input id="cache-varnish-compatible" type="checkbox" /><span
|
||||
data-i18n="cacheVarnishCompatible">Varnish-compatible cache policy (recommended; enabled by default)</span></label>
|
||||
<details class="metric-info cache-varnish-info">
|
||||
<summary data-i18n-aria="cacheVarnishCompatibleInfoLabel"
|
||||
aria-label="Varnish-compatible cache behavior">i</summary>
|
||||
<div class="metric-info-popover" role="note">
|
||||
<p class="fine" data-i18n="cacheVarnishCompatibleHelp">A browser Shift+F5 (no-cache, max-age=0 or Pragma: no-cache) uses a fresh cache HIT without replacing it or resetting Age.</p>
|
||||
<p class="fine" data-i18n="cacheVarnishCompatibleSafeguards">Preserves client no-store, only-if-cached (504 on MISS; no origin contact or background refresh), min-fresh and max-stale (including max-stale=0 during grace); existing cookie rules, Authorization, Vary, PURGE/BAN, HOT/COLD and transformations stay active.</p>
|
||||
<p class="fine" data-i18n="cacheVarnishCompatibleBackendHelp">Origin no-cache with a validator remains revalidatable. If an explicit fallback TTL is set, public no-cache responses may instead be reused for that TTL, even without a validator. Private/no-store/Set-Cookie and Surrogate-Control: no-store remain blocked. Zero-TTL objects may be retained for stale-if-error or revalidation; explicit stale-while-revalidate=0 is respected.</p>
|
||||
<p class="fine" data-i18n="cacheVarnishCompatibleModeHelp">Implicit grace (60 seconds by default, configurable below) only applies to eligible fresh-at-storage responses without an explicit stale-while-revalidate directive. Off restores strict client reload semantics. Changing the mode invalidates existing cached objects. This is not full Varnish VCL emulation.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="cache-varnish-grace-field">
|
||||
<label for="cache-varnish-grace" data-i18n="cacheVarnishGrace">Default grace (seconds, 0 = off)</label>
|
||||
<input id="cache-varnish-grace" type="number" min="0" max="604800" step="1" required />
|
||||
</div>
|
||||
<div class="card soft-card cache-memory-sizing">
|
||||
<div class="card-heading">
|
||||
<h3 data-i18n="cacheMemorySizingTitle">Memory sizing</h3>
|
||||
|
||||
@@ -5580,6 +5580,13 @@ dialog .window-heading {
|
||||
white-space: normal
|
||||
}
|
||||
|
||||
/* Nested Settings tab bars must scroll with their content. The global sticky
|
||||
rule otherwise lets cache subtabs paint across checkboxes while scrolling. */
|
||||
#panel-settings .settings-tabs.sub-tabs {
|
||||
position: static;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
#panel-settings .settings-pages {
|
||||
min-width: 0
|
||||
}
|
||||
@@ -9387,6 +9394,51 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cache-varnish-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cache-varnish-setting .check {
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.cache-varnish-info {
|
||||
position: static;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cache-varnish-grace-field {
|
||||
width: min(320px, 100%);
|
||||
min-width: 0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.cache-varnish-grace-field label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cache-varnish-grace-field input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cache-varnish-info .metric-info-popover {
|
||||
width: calc(min(440px, 100vw - 56px));
|
||||
max-height: calc(min(65vh, 450px));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cache-varnish-info .metric-info-popover .fine + .fine {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.cache-maintenance-help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Vendored
+4
@@ -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,
|
||||
|
||||
Vendored
+3
@@ -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,
|
||||
|
||||
Vendored
+9
-4
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+89
-24
@@ -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| {
|
||||
|
||||
Vendored
+25
-19
@@ -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()
|
||||
|
||||
Vendored
+4
-8
@@ -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
@@ -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");
|
||||
|
||||
@@ -470,10 +470,12 @@ mod cache_fast_path_tests {
|
||||
.insert("etag", HeaderValue::from_static("\"origin-v1\""));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static(if parts.uri.path() == "/private" {
|
||||
"private, max-age=600"
|
||||
} else {
|
||||
"public, max-age=600"
|
||||
HeaderValue::from_static(match parts.uri.path() {
|
||||
"/private" => "private, max-age=600",
|
||||
"/backend-no-cache" => "public, no-cache, max-age=600",
|
||||
"/backend-zero-stale" => "public, max-age=0, stale-if-error=120",
|
||||
"/grace" => "public, max-age=1",
|
||||
_ => "public, max-age=600",
|
||||
}),
|
||||
);
|
||||
response
|
||||
@@ -612,7 +614,7 @@ 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_revalidated");
|
||||
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(
|
||||
@@ -631,7 +633,87 @@ mod cache_fast_path_tests {
|
||||
"MISS"
|
||||
);
|
||||
}
|
||||
assert_eq!(origin.requests.load(Ordering::Relaxed), 6);
|
||||
assert_eq!(origin.requests.load(Ordering::Relaxed), 5);
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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;
|
||||
assert_eq!(miss.status, StatusCode::GATEWAY_TIMEOUT);
|
||||
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;
|
||||
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");
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compatible_origin_no_cache_preserves_etag_revalidation_and_fallback_ttl() {
|
||||
let origin = origin().await;
|
||||
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");
|
||||
let cached_only = fixture
|
||||
.get("/backend-no-cache", &[("cache-control", "only-if-cached")])
|
||||
.await;
|
||||
assert_eq!(cached_only.status, StatusCode::GATEWAY_TIMEOUT);
|
||||
assert_eq!(origin.requests.load(Ordering::Relaxed), 1);
|
||||
let validated = fixture.get("/backend-no-cache", &[]).await;
|
||||
assert_eq!(validated.status, StatusCode::OK);
|
||||
assert_eq!(validated.headers["x-cache"], "HIT");
|
||||
assert_eq!(validated.headers["x-cache-reason"], "cache_revalidated");
|
||||
assert_eq!(origin.requests.load(Ordering::Relaxed), 2);
|
||||
fixture.stop().await;
|
||||
|
||||
let origin = origin().await;
|
||||
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");
|
||||
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");
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_if_cached_stale_grace_never_background_refreshes_origin() {
|
||||
let origin = origin().await;
|
||||
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;
|
||||
assert_eq!(stale.status, StatusCode::OK);
|
||||
assert_eq!(stale.headers["x-cache"], "HIT");
|
||||
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");
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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;
|
||||
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!(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;
|
||||
assert_eq!(reload.headers["x-cache-reason"], "cache_hit");
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ if let Some(ticket) = &cache_ticket {
|
||||
} else {
|
||||
"cache_hit"
|
||||
};
|
||||
if hit.background_revalidate {
|
||||
// only-if-cached forbids any origin communication, including
|
||||
// asynchronous grace validation and proactive refresh.
|
||||
if hit.background_revalidate && !only_cached {
|
||||
if let (Some(cached), Some(guard)) = (
|
||||
cached_revalidation.clone(),
|
||||
cached_revalidation
|
||||
@@ -66,6 +68,7 @@ if let Some(ticket) = &cache_ticket {
|
||||
}
|
||||
}
|
||||
if cache_settings.proactive_refresh_enabled
|
||||
&& !only_cached
|
||||
&& !hit.stale
|
||||
&& hit.remaining_ttl > 0
|
||||
{
|
||||
|
||||
@@ -177,9 +177,8 @@ let original_policy = cache_plan.request_policy(
|
||||
request.uri().query().is_some(), cache_settings,
|
||||
);
|
||||
|
||||
let only_cached =
|
||||
cache_settings.active_for(route.cache) && !cache_settings.varnish_compatible
|
||||
&& crate::cache::only_if_cached(request.headers());
|
||||
let only_cached = cache_settings.active_for(route.cache)
|
||||
&& crate::cache::only_if_cached(request.headers());
|
||||
let origin_suffix = match route.rewritten_path(&path) {
|
||||
Ok(None) => public_suffix.clone(),
|
||||
Ok(Some(rewritten)) => {
|
||||
|
||||
@@ -113,8 +113,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
if cache_ticket.is_some() && !cache_settings.varnish_compatible
|
||||
&& crate::cache::only_if_cached(&request_headers) {
|
||||
if enabled && crate::cache::only_if_cached(&request_headers) {
|
||||
return error(StatusCode::GATEWAY_TIMEOUT,
|
||||
"RustProxy: only-if-cached request has no usable Web Server representation\n");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user