194 lines
7.2 KiB
Rust
194 lines
7.2 KiB
Rust
impl Plan {
|
|
pub fn remaining_ttl(&self) -> u64 {
|
|
self.ttl.saturating_sub(self.initial_age.as_secs())
|
|
}
|
|
|
|
pub fn with_vary(mut self, name: &str) -> Self {
|
|
if !self.vary.iter().any(|value| value.eq_ignore_ascii_case(name)) {
|
|
self.vary.push(name.to_ascii_lowercase());
|
|
self.vary.sort();
|
|
}
|
|
self
|
|
}
|
|
|
|
pub fn without_vary(mut self, name: &str) -> Self {
|
|
self.vary.retain(|value| !value.eq_ignore_ascii_case(name));
|
|
self
|
|
}
|
|
|
|
pub fn restrict(mut self, transformed: Self) -> Self {
|
|
self.ttl = self.ttl.min(transformed.ttl);
|
|
self.stale_while_revalidate = self
|
|
.stale_while_revalidate
|
|
.min(transformed.stale_while_revalidate);
|
|
self.stale_if_error = self.stale_if_error.min(transformed.stale_if_error);
|
|
self.must_revalidate |= transformed.must_revalidate;
|
|
self.proxy_revalidate |= transformed.proxy_revalidate;
|
|
self.requires_revalidation |= transformed.requires_revalidation;
|
|
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
|
|
.saturating_add(transformed.received.duration_since(self.received));
|
|
self.received = transformed.received;
|
|
self.initial_age = carried.max(transformed.initial_age);
|
|
} else {
|
|
let carried = transformed
|
|
.initial_age
|
|
.saturating_add(self.received.duration_since(transformed.received));
|
|
self.initial_age = self.initial_age.max(carried);
|
|
}
|
|
self
|
|
}
|
|
}
|
|
pub fn plan(
|
|
status: StatusCode,
|
|
headers: &HeaderMap,
|
|
settings: &Settings,
|
|
response_delay: Duration,
|
|
) -> Option<Plan> {
|
|
if ["age", "date", "expires", "content-length"]
|
|
.iter()
|
|
.any(|name| headers.get_all(*name).iter().count() > 1)
|
|
{
|
|
return None;
|
|
}
|
|
if status != StatusCode::OK
|
|
|| headers.contains_key("set-cookie")
|
|
|| headers.contains_key("content-range")
|
|
|| headers.contains_key("trailer")
|
|
{
|
|
return None;
|
|
}
|
|
let bytes: usize = headers
|
|
.iter()
|
|
.map(|(k, v)| k.as_str().len() + v.as_bytes().len() + 32)
|
|
.sum();
|
|
if bytes > MAX_HEADER_BYTES {
|
|
return None;
|
|
}
|
|
if headers
|
|
.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.is_some_and(|v| {
|
|
let v = v.trim_start().to_ascii_lowercase();
|
|
v.starts_with("text/event-stream") || v.starts_with("application/grpc")
|
|
})
|
|
{
|
|
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"]
|
|
.iter()
|
|
.any(|name| control.contains_key(*name))
|
|
{
|
|
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");
|
|
let parse_window = |name: &str| -> Option<u64> {
|
|
match control.get(name) {
|
|
None => Some(0),
|
|
Some(Some(value)) => value.parse::<u64>().ok().map(|v| v.min(604_800)),
|
|
Some(None) => None,
|
|
}
|
|
};
|
|
let stale_while_revalidate = parse_window("stale-while-revalidate")?;
|
|
let stale_if_error = parse_window("stale-if-error")?;
|
|
if requires_revalidation
|
|
&& !headers.contains_key("etag")
|
|
&& !headers.contains_key("last-modified")
|
|
{
|
|
return None;
|
|
}
|
|
let now = SystemTime::now();
|
|
let date = match headers.get("date") {
|
|
Some(v) => httpdate::parse_http_date(v.to_str().ok()?).ok()?,
|
|
None => now,
|
|
};
|
|
let declared_ttl =
|
|
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(
|
|
httpdate::parse_http_date(expires.to_str().ok()?)
|
|
.ok()?
|
|
.duration_since(date)
|
|
.ok()?
|
|
.as_secs(),
|
|
)
|
|
} else if control.contains_key("public") {
|
|
Some(settings.default_ttl_seconds)
|
|
} else {
|
|
None
|
|
};
|
|
let age = match headers.get("age") {
|
|
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)),
|
|
None if requires_revalidation => 0,
|
|
None => return None,
|
|
};
|
|
if !requires_revalidation
|
|
&& initial_age.as_secs()
|
|
>= retention_deadline(
|
|
ttl,
|
|
stale_while_revalidate,
|
|
stale_if_error,
|
|
has_validator(headers),
|
|
)
|
|
{
|
|
return None;
|
|
}
|
|
let expected_length = match headers.get("content-length") {
|
|
Some(v) => Some(v.to_str().ok()?.parse::<usize>().ok()?),
|
|
None => None,
|
|
};
|
|
if expected_length.is_some_and(|n| n > settings.max_object_bytes) {
|
|
return None;
|
|
}
|
|
Some(Plan {
|
|
received: Instant::now(),
|
|
initial_age,
|
|
ttl,
|
|
stale_while_revalidate,
|
|
stale_if_error,
|
|
must_revalidate,
|
|
proxy_revalidate,
|
|
expected_length,
|
|
requires_revalidation,
|
|
vary,
|
|
})
|
|
}
|