0.34.6_repo_cleanup

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-22 09:31:15 +02:00
1 parent ce1a90397c
commit 133d2f0f91
273 files changed
+5969 -6926

No files matched your search

+5 -24
View File
@@ -1,4 +1,3 @@
/// Reject malformed/duplicate directives instead of guessing their meaning.
fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
let mut result = HashMap::new();
for header in headers.get_all("cache-control") {
@@ -11,8 +10,6 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
Some((k, v)) => {
let v = v.trim();
let value = if v.starts_with('"') {
// Fail closed on truncated/escaped/compound values. Quoted
// commas need not be supported by this conservative cache.
let inner = v.strip_prefix('"')?.strip_suffix('"')?;
if inner.contains(['"', '\\']) {
return None;
@@ -39,10 +36,6 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
return None;
}
if key == "immutable" {
// RFC 8246: immutable has no meaningful argument and duplicate
// occurrences are equivalent to one occurrence. Preserve only
// the presence of the directive instead of invalidating the
// complete Cache-Control field.
result.entry(key).or_insert(None);
} else if result.insert(key, value).is_some() {
return None;
@@ -111,7 +104,11 @@ pub fn forced_request_policy(
}
for key in ["if-match", "if-unmodified-since", "upgrade"] {
if headers.contains_key(key) {
return RequestPolicy::Bypass(if key == "upgrade" { "request_upgrade" } else { "request_precondition" });
return RequestPolicy::Bypass(if key == "upgrade" {
"request_upgrade"
} else {
"request_precondition"
});
}
}
RequestPolicy::Use
@@ -150,8 +147,6 @@ pub fn request_policy(
for (key, reason) in [
("authorization", "request_authorization"),
("cookie", "request_cookie"),
// Preconditions other than cache validators can produce 412 and must be
// evaluated by the origin until RustProxy implements the full precondition set.
("if-match", "request_precondition"),
("if-unmodified-since", "request_precondition"),
("upgrade", "request_upgrade"),
@@ -166,15 +161,9 @@ pub fn request_policy(
let Some(control) = directives(headers) else {
return RequestPolicy::Bypass("request_cache_control_invalid");
};
// RFC 9111: request no-store forbids using a stored response and storing this
// exchange. It is the hard client-side cache bypass.
if control.contains_key("no-store") {
return RequestPolicy::Bypass("request_no_store");
}
// no-cache does not mean no-store. It requires successful validation before a
// stored response can be reused. max-age/min-fresh are evaluated against the
// cached representation during lookup; only max-age=0 is an unconditional
// request-side validation signal.
if control.contains_key("no-cache") {
return RequestPolicy::Revalidate("request_no_cache");
}
@@ -200,8 +189,6 @@ pub fn request_policy(
return RequestPolicy::Bypass("request_cache_control_invalid");
}
}
// HTTP/1.0 Pragma: no-cache is a revalidation request when Cache-Control did
// not already define the policy. Other Pragma values do not disable caching.
if !headers.contains_key("cache-control") && pragma_no_cache(headers) {
return RequestPolicy::Revalidate("request_pragma_no_cache");
}
@@ -295,8 +282,6 @@ fn single_if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool {
&& token != "*"
&& weak_etag(token).is_some_and(|candidate| candidate == target)
}
/// Varnish-like client-side conditional delivery for a selected cached response.
/// If-None-Match takes precedence over If-Modified-Since.
pub fn conditional_not_modified(request: &HeaderMap, response: &HeaderMap) -> bool {
if request.contains_key("if-none-match") {
return response
@@ -326,11 +311,8 @@ pub fn make_not_modified(mut response: Response) -> Response {
}
response
}
/// Merge a successful backend 304 into the stored representation metadata.
pub fn merge_revalidated_headers(cached: &HeaderMap, validation: &HeaderMap) -> HeaderMap {
let mut merged = cached.clone();
// A new validation starts a new age calculation. Preserve origin Age only if
// it is explicitly supplied by the 304 response.
if !validation.contains_key("age") {
merged.remove("age");
}
@@ -364,7 +346,6 @@ pub fn varies_on(headers: &HeaderMap, wanted: &str) -> bool {
})
}
/// Explains rejected storage; it never relaxes origin or transformed policies.
pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str {
if status != StatusCode::OK {
return "response_status";
+10 -22
View File
@@ -4,7 +4,11 @@ impl Plan {
}
pub fn with_vary(mut self, name: &str) -> Self {
if !self.vary.iter().any(|value| value.eq_ignore_ascii_case(name)) {
if !self
.vary
.iter()
.any(|value| value.eq_ignore_ascii_case(name))
{
self.vary.push(name.to_ascii_lowercase());
self.vary.sort();
}
@@ -28,14 +32,7 @@ impl Plan {
self.vary.extend(transformed.vary);
self.vary.sort();
self.vary.dedup();
// The transformed response is the representation that is actually
// written to cache. Its Content-Length therefore replaces the origin
// length; comparing transformed bytes with the origin length rejects a
// valid fill and causes a permanent MISS -> MISS loop.
self.expected_length = transformed.expected_length;
// Express both age clocks at the later observation point before taking
// the stricter value. This avoids resetting or double-counting Age when
// origin headers are transformed inside the proxy.
if transformed.received >= self.received {
let carried = self
.initial_age
@@ -87,9 +84,6 @@ pub fn plan(
{
return None;
}
// Cache-policy and validator fields govern reuse rather than representation
// identity. Response Vary drives a secondary key, matching Varnish-style
// variants without fragmenting on unrelated request headers.
let vary = vary_names(headers)?;
let control = directives(headers)?;
if ["no-store", "private", "must-understand"]
@@ -141,22 +135,16 @@ pub fn plan(
Some(v) => v.to_str().ok()?.parse::<u64>().ok()?,
None => 0,
};
// RFC 9111 Section 4.2.3: the corrected initial age is the greater of
// the Date-derived apparent age and the received Age plus response delay.
// An upstream/CDN Age must be preserved on both transformed and normal HITs.
let apparent_age = now.duration_since(date).unwrap_or_default();
let corrected_age_value = Duration::from_secs(age).saturating_add(response_delay);
let initial_age = apparent_age.max(corrected_age_value);
// `ttl` is an absolute freshness lifetime (measured from the origin's
// Age clock), not the time since insertion into our HOT/COLD cache.
// Capping it directly to max_ttl_seconds makes an otherwise fresh 30-day
// upstream response with Age=18 days look expired when our own cap is 1 day.
// Limit the REMAINING freshness from admission, but preserve the Age clock.
// Origin freshness still wins: an origin response that has already expired
// must never become fresh here. `no-cache` continues to require validation.
let ttl = match declared_ttl {
Some(value) => value.min(initial_age.as_secs().saturating_add(settings.max_ttl_seconds)),
Some(value) => value.min(
initial_age
.as_secs()
.saturating_add(settings.max_ttl_seconds),
),
None if requires_revalidation => 0,
None => return None,
};
+150
View File
@@ -0,0 +1,150 @@
mod scope_tests {
use super::*;
use http_body_util::BodyExt;
async fn fill(cache: &Arc<Cache>, ticket: Ticket) {
let settings = Settings {
enabled: true,
..Settings::default()
};
let mut response = Response::new(Body::from("scope-payload"));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=30"),
);
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(response, ticket, plan, &settings)
.into_body()
.collect()
.await
.unwrap();
}
#[tokio::test]
async fn selective_purge_preserves_other_hosts_and_cancels_old_fills() {
let cache = Cache::new(1);
let ticket = |scope| {
cache
.ticket_scoped(
1,
scope,
"http",
"same.test",
"root",
"/",
&HeaderMap::new(),
)
.unwrap()
};
let a = ticket("a");
let b = ticket("b");
fill(&cache, a.clone()).await;
fill(&cache, b.clone()).await;
assert_eq!(cache.scope_view("a").entries, 1);
assert_eq!(cache.purge_scope("a"), 1);
assert!(cache.get(&a).is_none());
assert!(cache.get(&b).is_some());
fill(&cache, a).await;
assert_eq!(cache.scope_view("a").entries, 0);
fill(&cache, ticket("a")).await;
assert_eq!(cache.scope_view("a").entries, 1);
}
#[test]
fn stale_snapshot_cannot_issue_cache_ticket_after_config_commit() {
let cache = Cache::new(1);
cache.set_control_revision(7);
let ticket = |revision| cache.ticket_scoped_for_config(
revision, 1, "vhost-a", "http", "same.test", "main", "/resource", &HeaderMap::new()
);
assert!(ticket(7).is_some(), "the current snapshot can cache");
cache.set_control_revision(8);
assert_eq!(cache.revision(), 1);
assert!(ticket(7).is_none(), "late request from the previous snapshot must bypass");
assert!(ticket(8).is_some(), "the new snapshot can reuse the same cache identity");
}
#[test]
fn per_vhost_settings_inherit_and_cannot_expand_shared_capacity() {
let global = Settings::default();
let effective = Overrides {
enabled: Some(true),
max_ttl_seconds: Some(30),
expose_status: Some(true),
..Overrides::default()
}
.resolve(&global)
.unwrap();
assert!(effective.enabled && effective.expose_status);
let mut globally_disabled = global.clone();
globally_disabled.enabled = false;
assert!(!Overrides { enabled: Some(true), ..Overrides::default() }
.resolve(&globally_disabled)
.unwrap()
.enabled);
assert!(!Overrides { enabled: Some(false), ..Overrides::default() }
.resolve(&global)
.unwrap()
.enabled);
assert!(!Overrides::default()
.resolve(&globally_disabled)
.unwrap()
.enabled);
assert_eq!(effective.max_bytes, global.max_bytes);
assert_eq!(effective.max_ttl_seconds, 30);
assert!(Overrides {
max_object_bytes: Some(global.max_object_bytes + 1),
..Overrides::default()
}
.resolve(&global)
.is_err());
assert!(Overrides {
max_ttl_seconds: Some(1),
default_ttl_seconds: Some(2),
..Overrides::default()
}
.resolve(&global)
.is_err());
}
}
#[test]
fn unsafe_force_cache_is_per_vhost_and_matches_path_or_target_prefix() {
let force = UnsafeForce {
enabled: true,
targets: vec!["/convert?url=".into(), "/assets".into()],
};
force.validate().unwrap();
assert!(force.matches(
"/convert",
"/convert?url=https%3A%2F%2Fexample.test%2Flist.txt"
));
assert!(force.matches("/assets/app.js", "/assets/app.js?v=1"));
assert!(!force.matches("/api", "/api?url=x"));
assert!(UnsafeForce {
enabled: true,
targets: vec!["https://example.test/".into()],
}
.validate()
.is_err());
}
#[test]
fn forced_request_policy_keeps_protocol_safety_but_ignores_cookie_and_client_no_store() {
let mut headers = HeaderMap::new();
headers.insert("cookie", HeaderValue::from_static("session=abc"));
headers.insert("authorization", HeaderValue::from_static("Bearer token"));
headers.insert("cache-control", HeaderValue::from_static("no-store"));
assert_eq!(
forced_request_policy(&Method::GET, &headers, true),
RequestPolicy::Use
);
assert_eq!(
forced_request_policy(&Method::POST, &headers, true),
RequestPolicy::Bypass("request_method")
);
}