This commit is contained in:
Mateusz Gruszczyński committed 2026-10-17 11:57:16 +02:00
1 parent 6b937aaf85
commit 87c8393d86
79 files changed
+6727 -297

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ mod challenge_tests {
async fn fixture() -> (Arc<App>, tempfile::TempDir) {
let directory = tempfile::tempdir().unwrap();
let certs = directory.path().join("certs");
crate::tls::initialize_default(&certs).unwrap();
let root = certs.join("acme-http01/.well-known/acme-challenge");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("sample_token"), b"sample_token.thumbprint").unwrap();
+7 -6
View File
@@ -4,12 +4,6 @@ async fn certificate_deploy(
certificate_api_access: Option<Extension<CertificateApiAccess>>,
Json(input): Json<CertificateDeploy>,
) -> Result<Json<Value>, ApiError> {
let directory = state.policy.cert_dir.clone().ok_or_else(|| {
ApiError(
StatusCode::CONFLICT,
"certificate storage is unavailable; configure the certificate directory".into(),
)
})?;
let CertificateDeploy {
certificate_id,
cert_pem,
@@ -30,6 +24,13 @@ async fn certificate_deploy(
));
}
}
// Check the credential scope before disclosing certificate storage state.
let directory = state.policy.cert_dir.clone().ok_or_else(|| {
ApiError(
StatusCode::CONFLICT,
"certificate storage is unavailable; configure the certificate directory".into(),
)
})?;
let snapshot = state.config.load_full();
let expected_revision = snapshot.envelope.revision;
+69 -37
View File
@@ -901,13 +901,19 @@ mod tests {
.oneshot(request(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(public.status(), StatusCode::OK, "anonymous {path}");
let public_body = axum::body::to_bytes(public.into_body(), 100_000)
.await
.unwrap();
assert!(std::str::from_utf8(&public_body)
.unwrap()
.contains(&format!("/assets/login_{}.js", super::asset_build_id())));
if path == "/logs" {
// The operational log route is private even on browser refresh.
assert_eq!(public.status(), StatusCode::UNAUTHORIZED);
assert_eq!(json_body(public).await, json!({"error":"auth.required"}));
} else {
assert_eq!(public.status(), StatusCode::OK, "anonymous {path}");
let public_body = axum::body::to_bytes(public.into_body(), 100_000)
.await
.unwrap();
assert!(std::str::from_utf8(&public_body)
.unwrap()
.contains(&format!("/assets/login_{}.js", super::asset_build_id())));
}
let private = router
.clone()
@@ -1314,6 +1320,13 @@ mod tests {
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
// This fixture has no certificate directory. An out-of-scope credential
// must see only the grant error, never the storage availability error.
assert_eq!(
json_body(response).await,
json!({"error":"certificate_api.certificate_forbidden"})
);
let missing_key = router
.clone()
.oneshot(
@@ -1821,6 +1834,32 @@ mod tests {
"backend_table_sort":"requests",
"backend_table_descending":true
});
// Older clients may omit fields added since their preferences were
// saved. The API returns and stores the typed, defaulted document.
let normalized = serde_json::to_value(
serde_json::from_value::<AdminPreferences>(selected.clone()).unwrap(),
)
.unwrap();
for (field, value) in selected.as_object().unwrap() {
assert_eq!(&normalized[field], value, "selected field {field}");
}
assert_eq!(normalized["dashboard_widget_sizes"], Value::Null);
assert_eq!(normalized["file_log_sort"], "time");
assert_eq!(normalized["file_log_descending"], true);
assert_eq!(normalized["vhost_list_sort"], "id");
assert_eq!(normalized["docker_vhost_sort"], "service");
assert_eq!(normalized["stream_list_sort"], "name");
assert_eq!(normalized["redirect_list_sort"], "id");
assert_eq!(normalized["notfound_list_sort"], "id");
for field in [
"vhost_list_descending",
"docker_vhost_descending",
"stream_list_descending",
"redirect_list_descending",
"notfound_list_descending",
] {
assert_eq!(normalized[field], false, "default field {field}");
}
let response = router
.clone()
.oneshot(
@@ -1837,12 +1876,12 @@ mod tests {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(json_body(response).await, selected);
assert_eq!(json_body(response).await, normalized);
let saved: Value =
serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap())
.unwrap();
assert_eq!(saved, selected);
assert_eq!(saved, normalized);
// Preferences must also survive a subsequent authenticated read.
let persisted = router
.clone()
@@ -1854,35 +1893,22 @@ mod tests {
)
.await
.unwrap();
let mut upgraded = selected.clone();
for id in [
"stream_overview",
"stream_traffic",
"stream_sessions",
"stream_backend_health",
"backend_health",
"backend_activity",
"backend_errors",
"backend_latency",
"backend_traffic",
"backend_protocols",
"backend_requests",
"backend_capacity",
"backend_http5xx",
"backend_timeouts",
"backend_network",
"backend_retries",
"backend_queue",
"backend_rotations",
] {
let mut upgraded = normalized.clone();
for widget in DASHBOARD_WIDGETS.iter() {
let id = json!(widget.id);
if selected["dashboard_widgets"].as_array().unwrap().contains(&id) {
continue;
}
upgraded["dashboard_widgets"]
.as_array_mut()
.unwrap()
.push(json!(id));
upgraded["dashboard_hidden_widgets"]
.as_array_mut()
.unwrap()
.push(json!(id));
.push(id.clone());
if widget.default_hidden {
upgraded["dashboard_hidden_widgets"]
.as_array_mut()
.unwrap()
.push(id);
}
}
assert_eq!(json_body(persisted).await, upgraded);
// Wrong allowlist value is rejected even if the rest is valid.
@@ -2192,7 +2218,10 @@ mod tests {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path}");
assert_eq!(response.headers()["cache-control"], "no-store");
assert_eq!(
response.headers()["cache-control"],
"private, no-store, no-cache, no-transform"
);
assert!(!response
.headers()
.contains_key("access-control-allow-origin"));
@@ -2294,7 +2323,10 @@ mod tests {
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "{path}");
assert_eq!(response.headers()["x-frame-options"], "DENY");
assert_eq!(response.headers()["cache-control"], "no-store");
assert_eq!(
response.headers()["cache-control"],
"private, no-store, no-cache, no-transform"
);
}
let response = router
.clone()
+15 -1
View File
@@ -7,12 +7,26 @@ mod tests {
let input = dir.path().join("input");
let encrypted = dir.path().join("backup.rpb");
let output = dir.path().join("output");
let wrong_output = dir.path().join("wrong-password-output");
let tampered = dir.path().join("tampered.rpb");
let tampered_output = dir.path().join("tampered-output");
fs::write(&input, b"private backup payload").unwrap();
let crypto = new_crypto("correct horse battery staple").unwrap();
encrypt_archive(&input, &encrypted, &crypto).unwrap();
assert!(decrypt_archive(&encrypted, &output, "wrong password").is_err());
let error = decrypt_archive(&encrypted, &wrong_output, "wrong password").unwrap_err();
assert!(error.to_string().contains("incorrect backup password or corrupted archive"));
decrypt_archive(&encrypted, &output, "correct horse battery staple").unwrap();
assert_eq!(fs::read(&output).unwrap(), b"private backup payload");
let mut bytes = fs::read(&encrypted).unwrap();
// Corrupt the last authentication-tag byte, before the four-byte EOF marker.
let tag_byte = bytes.len() - 5;
bytes[tag_byte] ^= 1;
fs::write(&tampered, bytes).unwrap();
let error = decrypt_archive(&tampered, &tampered_output, "correct horse battery staple")
.unwrap_err();
assert!(error.to_string().contains("incorrect backup password or corrupted archive"));
assert_eq!(fs::read(&output).unwrap(), b"private backup payload");
}
#[test]
+3 -4
View File
@@ -323,7 +323,9 @@ impl Cache {
continue;
};
let age = e.initial_age.saturating_add(e.received.elapsed()).as_secs();
if age >= e.ttl || !scope.is_none_or(|s| s == e.scope) {
// This is an inventory of retained objects, not a freshness lookup.
// Expired representations still occupy RAM and may be revalidated.
if !scope.is_none_or(|s| s == e.scope) {
continue;
}
let row = groups
@@ -373,9 +375,6 @@ impl Cache {
continue;
}
let age = cold_age(e);
if age >= e.ttl && !e.requires_revalidation {
continue;
}
let row = groups
.entry((e.scope.clone(), e.domain.clone()))
.or_insert_with(|| DomainView {
+7
View File
@@ -205,6 +205,13 @@ impl Cache {
_permit: permit,
_fill_leader: fill_leader,
};
// A genuinely finished body (including an empty local file) may never
// be polled by Hyper. EOS is the proof here, not Content-Length alone;
// finish still enforces expected length, privacy and ticket validity.
if body.is_end_stream() {
candidate.finish();
return Response::from_parts(parts, body);
}
Response::from_parts(
parts,
Body::new(FillBody {
+31
View File
@@ -134,6 +134,37 @@ async fn lru_demotion_persists_and_survives_restart() {
restart_shutdown.cancel();
}
#[tokio::test]
async fn dashboard_lists_retained_expired_cold_objects_without_permitting_fresh_hits() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone());
let first = fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
{
let mut cold = cache.cold.lock().unwrap();
for entry in cold.entries.values_mut() {
entry.stored_at = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
}
}
let dashboard = cache.dashboard(Some("cold-test"), 0, 50);
assert_eq!(dashboard.matching_objects, 2);
let row = dashboard.objects.iter().find(|row| row.path == "/one").unwrap();
assert_eq!(row.tier, "cold");
assert_eq!(row.remaining_ttl_seconds, 0);
assert!(row.age_seconds >= 2 * 60 * 60);
assert_eq!(row.storage_bytes, cache.view().cold_bytes);
assert!(cache.get_tiered(&first, &settings).await.is_none());
let mut stale = first;
stale.request_headers.insert("cache-control", HeaderValue::from_static("max-stale"));
let hit = cache.get_tiered(&stale, &settings).await.expect("explicit max-stale can reuse the retained disk object");
assert!(hit.stale);
assert_eq!(hit.response.into_body().collect().await.unwrap().to_bytes(), "first-payload");
shutdown.cancel();
}
#[tokio::test]
async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_transform() {
let dir = tempfile::tempdir().unwrap();
+9 -2
View File
@@ -78,7 +78,7 @@ mod dashboard_tests {
#[tokio::test]
async fn dashboard_retains_expired_entries_for_client_max_stale() {
let cache = Cache::new(7);
fill(&cache, "a", "a.example", "/").await;
let ticket = fill(&cache, "a", "a.example", "/").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
@@ -86,9 +86,16 @@ mod dashboard_tests {
}
store.last_sweep = None;
}
assert_eq!(cache.dashboard(None, 0, 50).matching_objects, 1);
let dashboard = cache.dashboard(None, 0, 50);
assert_eq!(dashboard.matching_objects, 1);
assert_eq!(dashboard.objects[0].remaining_ttl_seconds, 0);
assert!(dashboard.objects[0].age_seconds >= 2 * 60 * 60);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().expired, 0);
assert!(cache.get(&ticket).is_none(), "inventory visibility cannot permit unconditional stale delivery");
let mut stale = ticket;
stale.request_headers.insert("cache-control", HeaderValue::from_static("max-stale"));
assert!(cache.get(&stale).unwrap().stale);
}
#[test]
fn bypass_reasons_are_fixed_and_private_inputs_are_not_echoed() {
+74 -13
View File
@@ -23,10 +23,8 @@ mod tests {
for control in [
"private, max-age=60",
"no-store, max-age=60",
"max-age=0",
"max-age=invalid",
"max-age=60,max-age=90",
"public",
"must-understand, max-age=60",
"max-age=\"60",
"max-age=60\"",
@@ -53,13 +51,6 @@ mod tests {
let mut validator_only = headers("no-cache");
validator_only.insert("etag", HeaderValue::from_static("\"validator\""));
assert!(plan(StatusCode::OK, &validator_only, &s, Duration::ZERO).is_some());
assert!(plan(
StatusCode::OK,
&headers("max-age=60,s-maxage=0"),
&s,
Duration::ZERO
)
.is_none());
let shared = plan(
StatusCode::OK,
&headers("public, max-age=600, s-maxage=30"),
@@ -75,6 +66,24 @@ mod tests {
h.insert("set-cookie", "session=secret".parse().unwrap());
assert!(plan(StatusCode::OK, &h, &s, Duration::ZERO).is_none());
}
#[tokio::test]
async fn zero_freshness_is_retained_but_never_served_as_an_unconditional_hit() {
for control in ["max-age=0", "public", "max-age=60,s-maxage=0"] {
let cache = Cache::new(1);
let settings = settings();
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::from("zero-freshness"));
*response.headers_mut() = headers(control);
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO)
.expect("valid zero freshness is distinct from a no-store response");
assert_eq!(plan.ttl, 0, "{control}");
cache.wrap(response, ticket.clone(), plan, &settings)
.into_body().collect().await.unwrap();
assert_eq!(cache.view().entries, 1, "{control}");
assert!(cache.get(&ticket).is_none(), "zero-TTL {control} cannot be an unconditional HIT");
}
}
#[test]
fn request_policy_uses_conditional_cache_and_revalidation() {
for name in [
@@ -1362,21 +1371,23 @@ mod tests {
&a,
)
.unwrap();
let other_transport = cache
// Compare unrelated headers/member choice within the same frontend
// scheme. HTTP and HTTPS intentionally have distinct cache identities.
let other_member = cache
.ticket(
1,
"https",
"http",
"example.test",
"pool/member-b",
"/asset.js?x=1",
&b,
)
.unwrap();
assert_eq!(base.key, other_transport.key);
assert_eq!(base.key, other_member.key);
let other_url = cache
.ticket(
1,
"https",
"http",
"example.test",
"pool/member-b",
"/asset.js?x=2",
@@ -1479,4 +1490,54 @@ mod tests {
assert_eq!(cache.view().fills_active, 0);
}
}
#[test]
fn empty_complete_body_is_cached_without_requiring_a_consumer_poll() {
let cache = Cache::new(1);
let settings = settings();
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600");
response.headers_mut().insert("content-length", HeaderValue::from_static("0"));
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
let response = cache.wrap(response, ticket.clone(), plan, &settings);
assert!(response.body().is_end_stream());
// HTTP/1.1 Content-Length: 0 does not ask the response body for frames.
drop(response);
assert!(cache.get(&ticket).is_some());
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().fills_active, 0);
assert_eq!(cache.view().rejected_fills, 0);
}
#[test]
fn empty_body_with_nonzero_content_length_cannot_publish_a_fill() {
let cache = Cache::new(1);
let settings = settings();
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600");
response.headers_mut().insert("content-length", HeaderValue::from_static("6"));
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
drop(cache.wrap(response, ticket.clone(), plan, &settings));
assert!(cache.get(&ticket).is_none());
assert_eq!(cache.view().fills_active, 0);
assert_eq!(cache.view().rejected_fills, 1);
}
#[test]
fn empty_body_completion_still_obeys_purge_barrier() {
let cache = Cache::new(1);
let settings = settings();
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600");
response.headers_mut().insert("content-length", HeaderValue::from_static("0"));
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
cache.purge(None);
drop(cache.wrap(response, ticket.clone(), plan, &settings));
assert!(cache.get(&ticket).is_none());
assert_eq!(cache.view().entries, 0);
assert_eq!(cache.view().fills_active, 0);
}
}
+17 -6
View File
@@ -556,6 +556,13 @@ mod v022_cache_tests {
#[tokio::test]
async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() {
fn current_ticket(cache: &Cache, old: &Ticket) -> Ticket {
// Each post-PURGE request obtains the current publication generation.
// Reusing an old ticket would reject unrelated entries too and make
// assertions about removed objects pass without testing the selector.
cache.ticket_scoped(22, &old.scope, &old.scheme, &old.authority,
"origin@127.0.0.1:8080", &old.target, &old.request_headers).unwrap()
}
let cache = Cache::new(22);
let one = store(
&cache,
@@ -587,8 +594,9 @@ mod v022_cache_tests {
))
.unwrap();
assert_eq!(result.affected_entries, 1);
assert!(cache.get(&one).is_none());
assert!(cache.get(&two).is_some());
assert!(cache.get(&current_ticket(&cache, &one)).is_none());
let survivor = cache.get(&current_ticket(&cache, &two)).expect("unrelated query remains stored");
assert_eq!(survivor.response.into_body().collect().await.unwrap().to_bytes(), "two");
let three = store(
&cache,
@@ -623,8 +631,9 @@ mod v022_cache_tests {
.affected_entries,
2
);
assert!(cache.get(&three).is_none());
assert!(cache.get(&four).is_none());
assert!(cache.get(&current_ticket(&cache, &three)).is_none());
assert!(cache.get(&current_ticket(&cache, &four)).is_none());
assert!(cache.get(&current_ticket(&cache, &two)).is_some());
let tagged = store(
&cache,
@@ -649,7 +658,8 @@ mod v022_cache_tests {
.affected_entries,
1
);
assert!(cache.get(&tagged).is_none());
assert!(cache.get(&current_ticket(&cache, &tagged)).is_none());
assert!(cache.get(&current_ticket(&cache, &two)).is_some());
let keyed = store(
&cache,
@@ -675,7 +685,8 @@ mod v022_cache_tests {
.affected_entries,
1
);
assert!(cache.get(&keyed).is_none());
assert!(cache.get(&current_ticket(&cache, &keyed)).is_none());
assert!(cache.get(&current_ticket(&cache, &two)).is_some());
}
#[tokio::test]
+13
View File
@@ -173,6 +173,19 @@ pub fn no_transform(headers: &HeaderMap) -> bool {
})
}
/// Preserve exactly the request inputs used by encoding after forwarding has
/// consumed the request. Repeated field values and opaque bytes stay intact;
/// unrelated cookies, authorization and custom headers do not need a copy.
pub(crate) fn request_headers(headers: &HeaderMap) -> HeaderMap {
let mut selected = HeaderMap::new();
for name in ["accept-encoding", "range", "cache-control"] {
for value in headers.get_all(name) {
selected.append(name, value.clone());
}
}
selected
}
fn eligible(
response: &Response,
request: &HeaderMap,
+32
View File
@@ -24,6 +24,38 @@ mod tests {
assert!(settings.enabled());
}
#[tokio::test]
async fn narrowed_request_headers_preserve_encoding_and_skip_decisions() {
for accept in ["gzip", "br;q=0.8, gzip;q=0.5", "zstd", "identity", "*;q=0"] {
for method in [Method::GET, Method::HEAD, Method::POST] {
for skip in [None, Some("range"), Some("cache-control")] {
let mut headers = HeaderMap::new();
headers.append("accept-encoding", accept.parse().unwrap());
headers.append("accept-encoding", "deflate;q=0".parse().unwrap());
headers.append("cache-control", "max-age=0".parse().unwrap());
if let Some(name) = skip {
headers.append(name, if name == "range" { "bytes=0-3" } else { "no-transform" }.parse().unwrap());
}
headers.insert("cookie", "session=private".parse().unwrap());
headers.insert("authorization", "Bearer test".parse().unwrap());
headers.insert("x-large-custom", "x".repeat(4096).parse().unwrap());
let selected = request_headers(&headers);
for name in ["accept-encoding", "range", "cache-control"] {
assert_eq!(headers.get_all(name).iter().collect::<Vec<_>>(), selected.get_all(name).iter().collect::<Vec<_>>());
}
assert!(!selected.contains_key("cookie"));
assert!(!selected.contains_key("authorization"));
let full = encode(response(), &headers, &method, &Settings::default()).await;
let narrow = encode(response(), &selected, &method, &Settings::default()).await;
assert_eq!(full.status(), narrow.status());
assert_eq!(full.headers(), narrow.headers());
assert_eq!(full.into_body().collect().await.unwrap().to_bytes(), narrow.into_body().collect().await.unwrap().to_bytes());
}
}
}
assert!(request_headers(&HeaderMap::new()).is_empty());
}
#[test]
fn scoped_codec_overrides_keep_global_codecs_authoritative() {
let global = Settings::default();
+12 -7
View File
@@ -392,14 +392,19 @@ pub fn normalize_host_pattern(value: &str) -> Result<String> {
pub fn request_host(value: &str) -> Result<String> {
ensure!(!value.contains('@'), "invalid Host");
let authority: Authority = value.parse().context("invalid Host authority")?;
if let Some(port) = authority.port() {
let number: u16 = port.as_str().parse().context("invalid Host port")?;
ensure!(number > 0, "invalid Host port");
// Authority::port() returns None for both an absent and an invalid port.
// Inspect the explicit suffix so an out-of-range port cannot bypass validation.
let raw_host = authority.host();
let suffix = &authority.as_str()[raw_host.len()..];
if !suffix.is_empty() {
let port = suffix.strip_prefix(':').context("invalid Host port")?;
// HTTP permits an empty port suffix, which selects the default port.
if !port.is_empty() {
let number: u16 = port.parse().context("invalid Host port")?;
ensure!(number > 0, "invalid Host port");
}
}
let host = authority
.host()
.trim_start_matches('[')
.trim_end_matches(']');
let host = raw_host.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = host.parse::<IpAddr>() {
return Ok(ip.to_string());
}
+17
View File
@@ -142,6 +142,11 @@ fn missing_source_fields_remain_reverse_proxy_for_old_configs() {
#[test]
fn host_normalization() {
assert_eq!(request_host("EXAMPLE.com.:8080").unwrap(), "example.com");
assert_eq!(request_host("example.com:65535").unwrap(), "example.com");
assert_eq!(request_host("[::1]").unwrap(), "::1");
assert_eq!(request_host("[::1]:8080").unwrap(), "::1");
assert_eq!(request_host("a:").unwrap(), "a");
assert_eq!(request_host("[::1]:").unwrap(), "::1");
for invalid in [
"",
"a..b",
@@ -150,6 +155,12 @@ fn host_normalization() {
"user@host",
"-bad.test",
"a:99999",
"a:0",
"a:abc",
"[::1]:99999",
"[::1]:0",
"[::1]suffix",
"[::1]suffix:80",
] {
assert!(request_host(invalid).is_err(), "{invalid}");
}
@@ -516,6 +527,7 @@ fn invalid_per_backend_health_policy_rejected_without_global_override() {
mode: crate::balance::HealthMode::Http,
interval_ms: 300,
timeout_ms: 400,
slow_response_ms: 0,
..Default::default()
});
assert!(compile(c.clone(), 1, &policy()).is_err());
@@ -690,6 +702,9 @@ fn local_response_hosts_reserve_frontends_and_do_not_require_backends() {
#[test]
fn conflicts_are_rejected() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
// Keep both entries as real Custom Locations; the untouched legacy root
// is intentionally normalized away before duplicate-matcher validation.
c.vhosts[0].routes[0].path_prefix = "/api".into();
let mut duplicate = c.vhosts[0].routes[0].clone();
duplicate.id = "other".into();
c.vhosts[0].routes.push(duplicate);
@@ -800,6 +815,8 @@ fn http_health_check_defaults_host_to_first_exact_vhost_dns_name() {
fn per_member_http_health_inherits_vhost_dns_even_when_global_health_is_tcp() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
// demo_config explicitly disables probes; this case exercises TCP fallback.
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Tcp;
assert_eq!(c.vhosts[0].load_balancing.health_check.mode, crate::balance::HealthMode::Tcp);
c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck {
mode: crate::balance::HealthMode::Http, path: "/healthz".into(), ..Default::default()
+1
View File
@@ -73,6 +73,7 @@ fn protected(name: &str) -> bool {
| "forwarded"
| "x-real-ip"
| "x-request-id"
| "x-rustproxy-backend"
| "x-rust-proxy-backend"
| "x-rust-proxy-backend-time"
| "x-rustproxy-backend-time"
+3
View File
@@ -22,6 +22,7 @@ mod tests {
"x-forwarded-for",
"Upgrade",
"X-RustProxy-Backend",
"X-Rust-Proxy-Backend",
"X-RustProxy-Backend-Time",
"X-Rust-Proxy-Backend-Time",
] {
@@ -34,6 +35,8 @@ mod tests {
response: vec![],
};
assert!(rules.compile().is_err(), "{name}");
let response_rules = HeaderRules { request: vec![], response: rules.request };
assert!(response_rules.compile().is_err(), "response {name}");
}
let rules = HeaderRules {
request: vec![Rule {
+9 -1
View File
@@ -33,12 +33,14 @@ fn redact_regex(pattern: &'static str) -> &'static regex::Regex {
static AUTH: OnceLock<regex::Regex> = OnceLock::new();
static QUERY: OnceLock<regex::Regex> = OnceLock::new();
static JSON: OnceLock<regex::Regex> = OnceLock::new();
static ESCAPED_JSON: OnceLock<regex::Regex> = OnceLock::new();
static KV: OnceLock<regex::Regex> = OnceLock::new();
static PEM: OnceLock<regex::Regex> = OnceLock::new();
match pattern {
"auth" => AUTH.get_or_init(|| regex::Regex::new(r"(?i)(authorization\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+" ).expect("redaction regex")),
"query" => QUERY.get_or_init(|| regex::Regex::new(r"(?i)([?&](?:token|access_token|api_token|password|backup_password|secret|credential|credentials|key_secret|application_secret|consumer_key)=)[^&\s]+" ).expect("redaction regex")),
"json" => JSON.get_or_init(|| regex::Regex::new(r#"(?i)(\"(?:authorization|proxy_authorization|api_token|access_token|application_secret|consumer_key|key_secret|backup_password|password|credential|credentials|secret|token|private_key|master_key)\"\s*:\s*\")[^\"]*(\")"#).expect("redaction regex")),
"json" => JSON.get_or_init(|| regex::Regex::new(r#"(?i)("(?:authorization|proxy_authorization|api_token|access_token|application_secret|consumer_key|key_secret|backup_password|password|credential|credentials|secret|token|private_key|master_key)"\s*:\s*")(?:\\.|[^"\\])*(")"#).expect("redaction regex")),
"escaped_json" => ESCAPED_JSON.get_or_init(|| regex::Regex::new(r#"(?i)(\\"(?:authorization|proxy_authorization|api_token|access_token|application_secret|consumer_key|key_secret|backup_password|password|credential|credentials|secret|token|private_key|master_key)\\"\s*:\s*\\")(?:[^"\\]|\\\\(?:\\\\|\\"|[^"\\]))*(\\")"#).expect("redaction regex")),
"kv" => KV.get_or_init(|| regex::Regex::new(r#"(?i)((?:authorization|proxy[_-]?authorization|api[_-]?token|access[_-]?token|application[_-]?secret|consumer[_-]?key|key[_-]?secret|backup[_-]?password|password|credential|credentials|secret|token|private[_-]?key|master[_-]?key)\s*[=:]\s*)[^\s,;"']+"#).expect("redaction regex")),
"pem" => PEM.get_or_init(|| regex::Regex::new(r"(?s)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----" ).expect("redaction regex")),
_ => unreachable!(),
@@ -76,6 +78,12 @@ pub fn redact_text(input: &str) -> String {
value = redact_regex("query")
.replace_all(&value, "$1[REDACTED]")
.into_owned();
// Debug-formatted fields can contain a JSON string with escaped quote
// delimiters. Redact that form before plain JSON, retaining quote escapes
// and consuming escaped quotes inside a secret instead of leaking its tail.
value = redact_regex("escaped_json")
.replace_all(&value, "$1[REDACTED]$2")
.into_owned();
value = redact_regex("json")
.replace_all(&value, "$1[REDACTED]$2")
.into_owned();
+13 -6
View File
@@ -136,14 +136,21 @@ impl Logs {
self.settings.load().capture_country
}
pub fn push(&self, entry: LogEntry) {
if let Ok(mut entries) = self.entries.try_lock() {
if entries.len() == HISTORY_CAPACITY {
entries.pop_front();
}
entries.push_back(entry.clone());
// String cloning and eviction cleanup do not need the history mutex.
let history_entry = entry.clone();
let evicted = if let Ok(mut entries) = self.entries.try_lock() {
let evicted = if entries.len() == HISTORY_CAPACITY {
entries.pop_front()
} else {
None
};
entries.push_back(history_entry);
evicted
} else {
self.dropped.fetch_add(1, Ordering::Relaxed);
}
None
};
drop(evicted);
let _ = self.live.send(entry.clone());
self.push_sink(entry);
}
+131
View File
@@ -209,6 +209,41 @@ mod tests {
assert!(!text.contains("pem-data"));
}
#[test]
fn error_redaction_preserves_json_and_debug_escaping_without_leaking_secret_suffixes() {
for secret in ["pem-data", "left\"right", "left\\right", "ending\\", "left\\\"right", "first\nsecond"] {
let plain = serde_json::to_string(&serde_json::json!({
"private_key": secret, "visible": "keep"
})).unwrap();
let redacted = redact_text(&plain);
let value: Value = serde_json::from_str(&redacted).unwrap();
assert_eq!(value["private_key"], "[REDACTED]");
assert_eq!(value["visible"], "keep");
// EventVisitor::record_debug uses this exact formatting operation.
let debug = format!("{plain:?}");
let debug_redacted = redact_text(&debug);
let decoded: String = serde_json::from_str(&debug_redacted).unwrap();
let value: Value = serde_json::from_str(&decoded).unwrap();
assert_eq!(value["private_key"], "[REDACTED]");
assert_eq!(value["visible"], "keep");
assert_eq!(
redact_text(&format!("before {debug} after")),
format!("before {debug_redacted} after")
);
}
}
#[test]
fn error_redaction_removes_private_pem_and_preserves_public_diagnostics() {
for label in ["PRIVATE KEY", "RSA PRIVATE KEY", "EC PRIVATE KEY", "ENCRYPTED PRIVATE KEY"] {
let input = format!("before -----BEGIN {label}-----\nsynthetic-secret\n-----END {label}----- after");
assert_eq!(redact_text(&input), "before [REDACTED PRIVATE KEY] after");
}
let public = "-----BEGIN CERTIFICATE-----\npublic-test-data\n-----END CERTIFICATE-----";
assert_eq!(redact_text(public), public);
}
#[test]
fn error_json_is_one_record_and_reserved_fields_cannot_be_overwritten() {
let event = ErrorEvent {
@@ -291,4 +326,100 @@ mod tests {
LIVE_WINDOW + 80
);
}
#[test]
fn full_history_evicts_oldest_keeps_exact_fields_and_does_not_count_eviction_as_drop() {
fn history_entry(id: usize) -> LogEntry {
let mut item = entry();
item.id = id as u64;
item.time_ms += id as u64;
item.path = format!("/history/{id}");
item.authority = Some("app.example:443".into());
item.matched_by = Some("host");
item.upstream_error = Some("diagnostic with \"quotes\" and UTF-8: €".into());
item
}
let logs = Logs::default();
for id in 0..HISTORY_CAPACITY + 3 {
logs.push(history_entry(id));
}
let retained = logs.recent_limit(HISTORY_CAPACITY + 1);
assert_eq!(retained.len(), HISTORY_CAPACITY);
assert_eq!(
retained.iter().map(|item| item.id).collect::<Vec<_>>(),
(3..HISTORY_CAPACITY + 3).rev().map(|id| id as u64).collect::<Vec<_>>()
);
for item in [&retained[0], &retained[HISTORY_CAPACITY / 2], retained.last().unwrap()] {
assert_eq!(
serde_json::to_value(item).unwrap(),
serde_json::to_value(history_entry(item.id as usize)).unwrap()
);
}
assert_eq!(logs.recent_limit(0).len(), 0);
assert_eq!(logs.recent_limit(2)[0].id, (HISTORY_CAPACITY + 2) as u64);
assert_eq!(logs.dropped.load(Ordering::Relaxed), 0);
let stats = logs.stats();
assert_eq!(stats.queue_capacity, SINK_QUEUE);
assert_eq!(stats.queue_dropped, 0, "disabled sinks must not enqueue");
assert_eq!(stats.sent, 0);
assert_eq!(stats.errors, 0);
}
#[tokio::test]
async fn history_contention_counts_one_drop_but_preserves_live_and_file_delivery() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("access.jsonl");
let mut settings = Settings::default();
settings.file.enabled = true;
settings.file.path = path.to_string_lossy().into_owned();
let shutdown = CancellationToken::new();
let logs = Logs::start(settings, shutdown.clone());
let mut live = logs.subscribe();
let mut sentinel = entry();
sentinel.id = 6;
let mut history = logs.entries.lock().unwrap();
history.push_back(sentinel.clone());
let mut item = entry();
item.path = "/contention/€\"quoted\"".into();
item.authority = Some("app.example:443".into());
item.matched_by = Some("host");
let expected = serde_json::to_value(&item).unwrap();
let producer = logs.clone();
let (finished, completion) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
producer.push(item);
finished.send(()).unwrap();
});
// Release the guard before joining even on failure, so an accidental
// blocking-lock regression fails this test instead of hanging the suite.
let completed = completion.recv_timeout(Duration::from_secs(2));
drop(history);
worker.join().unwrap();
completed.expect("history contention must not block live/sink delivery");
assert_eq!(logs.dropped.load(Ordering::Relaxed), 1);
assert_eq!(serde_json::to_value(logs.recent()).unwrap(), json!([sentinel]));
assert_eq!(serde_json::to_value(live.try_recv().unwrap()).unwrap(), expected);
assert!(matches!(live.try_recv(), Err(broadcast::error::TryRecvError::Empty)));
let persisted = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Ok(content) = tokio::fs::read_to_string(&path).await {
if content.ends_with('\n') && logs.stats().sent == 1 {
break content;
}
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}).await.expect("contended history entry must still reach the configured file sink");
shutdown.cancel();
assert_eq!(persisted.lines().count(), 1);
assert_eq!(serde_json::from_str::<Value>(persisted.trim_end()).unwrap(), expected);
let stats = logs.stats();
assert_eq!(stats.queue_capacity, SINK_QUEUE);
assert_eq!(stats.queue_dropped, 0);
assert_eq!(stats.sent, 1);
assert_eq!(stats.errors, 0);
}
}
+4 -2
View File
@@ -34,7 +34,9 @@ mod tests {
#[test]
fn application_style_hosts_require_a_bundled_template() {
assert!(host(crate::pages::Kind::Builtin, 404).validate().is_ok());
assert!(host(crate::pages::Kind::Builtin, 500).validate().is_err());
for status in crate::pages::BUILTIN_ERROR_CODES {
assert!(host(crate::pages::Kind::Builtin, status).validate().is_ok(), "{status}");
}
assert!(host(crate::pages::Kind::Builtin, 418).validate().is_err());
}
}
+17 -7
View File
@@ -18,7 +18,7 @@ use std::{
};
use tokio::{
sync::OwnedSemaphorePermit,
time::{sleep, Sleep},
time::{sleep_until, Sleep},
};
pub struct Exchange {
@@ -263,7 +263,8 @@ pub struct MeteredBody {
inner: Body,
exchange: Option<Arc<Exchange>>,
direction: Direction,
timer: Pin<Box<Sleep>>,
timer: Option<Pin<Box<Sleep>>>,
deadline: tokio::time::Instant,
idle: Duration,
finished: bool,
}
@@ -274,13 +275,17 @@ impl MeteredBody {
inner,
exchange: Some(exchange),
direction,
timer: Box::pin(sleep(idle)),
// Ready/empty bodies never need a timer allocation. Keep the
// original deadline so a delayed first poll still times out.
timer: None,
deadline: tokio::time::Instant::now() + idle,
idle,
finished,
}
}
fn finish(&mut self) {
self.finished = true;
self.timer.take();
self.exchange.take();
}
}
@@ -318,11 +323,13 @@ impl HttpBody for MeteredBody {
}
}
}
this.timer
.as_mut()
.reset(tokio::time::Instant::now() + this.idle);
if this.inner.is_end_stream() {
this.finish();
} else {
this.deadline = tokio::time::Instant::now() + this.idle;
if let Some(timer) = this.timer.as_mut() {
timer.as_mut().reset(this.deadline);
}
}
Poll::Ready(Some(Ok(frame)))
}
@@ -342,7 +349,10 @@ impl HttpBody for MeteredBody {
Poll::Ready(None)
}
Poll::Pending => {
if this.timer.as_mut().poll(cx).is_ready() {
let timer = this
.timer
.get_or_insert_with(|| Box::pin(sleep_until(this.deadline)));
if timer.as_mut().poll(cx).is_ready() {
if let Some(exchange) = &this.exchange {
exchange.failed.store(true, Ordering::Relaxed);
exchange.backend_error(match this.direction {
+130
View File
@@ -95,4 +95,134 @@ mod tests {
assert_eq!(slots.available_permits(), 1);
assert_eq!(metrics.errors.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn ready_and_empty_bodies_keep_accounting_without_timer_allocations() {
for payload in ["", "ready response"] {
let (exchange, slots) = observation().await;
let metrics = exchange.metrics.clone();
let logs = exchange.logs.clone();
let mut body = MeteredBody::new(
Body::from(payload),
exchange,
Direction::Response,
Duration::from_secs(1),
);
assert!(body.timer.is_none());
assert_eq!(slots.available_permits(), 0);
let mut received = Vec::new();
while let Some(frame) = body.frame().await {
received.extend_from_slice(frame.unwrap().data_ref().unwrap());
assert!(body.timer.is_none());
}
assert_eq!(received, payload.as_bytes());
assert_eq!(slots.available_permits(), 1);
assert_eq!(metrics.completed.load(Ordering::Relaxed), 1);
assert_eq!(
metrics.bytes_out.load(Ordering::Relaxed),
payload.len() as u64
);
assert_eq!(metrics.errors.load(Ordering::Relaxed), 0);
assert_eq!(logs.recent().len(), 1);
}
}
#[tokio::test]
async fn delayed_first_poll_does_not_extend_the_original_idle_deadline() {
let (exchange, slots) = observation().await;
let metrics = exchange.metrics.clone();
let pending = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
let mut body = MeteredBody::new(
Body::from_stream(pending),
exchange,
Direction::Request,
Duration::from_secs(1),
);
// Move the saved initial deadline into the past without wall-clock
// sleeps: the first Pending must create an already-expired timer.
body.deadline = tokio::time::Instant::now() - Duration::from_secs(1);
assert!(body.timer.is_none());
let result = tokio::time::timeout(Duration::from_millis(200), body.frame()).await;
assert!(result.unwrap().unwrap().is_err());
assert!(body.timer.is_none());
assert_eq!(slots.available_permits(), 1);
assert_eq!(metrics.errors.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn progress_resets_a_lazy_timer_and_trailers_pass_unchanged() {
let (exchange, slots) = observation().await;
let metrics = exchange.metrics.clone();
let (sender, receiver) =
tokio::sync::mpsc::channel::<Result<Frame<Bytes>, std::io::Error>>(4);
let stream = futures_util::stream::unfold(receiver, |mut receiver| async {
receiver.recv().await.map(|frame| (frame, receiver))
});
let mut body = MeteredBody::new(
Body::new(http_body_util::StreamBody::new(stream)),
exchange,
Direction::Response,
Duration::from_secs(5),
);
assert!(futures_util::poll!(body.frame()).is_pending());
assert!(body.timer.is_some());
let original_deadline = body.deadline;
sender
.send(Ok(Frame::data(Bytes::from_static(b"part"))))
.await
.unwrap();
assert_eq!(
body.frame().await.unwrap().unwrap().data_ref().unwrap(),
"part"
);
assert!(body.deadline >= original_deadline);
assert_eq!(body.timer.as_ref().unwrap().deadline(), body.deadline);
let mut trailers = http::HeaderMap::new();
trailers.insert("x-checksum", "complete".parse().unwrap());
sender
.send(Ok(Frame::trailers(trailers.clone())))
.await
.unwrap();
drop(sender);
assert_eq!(
body.frame().await.unwrap().unwrap().trailers_ref(),
Some(&trailers)
);
assert!(body.frame().await.is_none());
assert!(body.timer.is_none());
assert_eq!(metrics.bytes_out.load(Ordering::Relaxed), 4);
assert_eq!(metrics.errors.load(Ordering::Relaxed), 0);
assert_eq!(slots.available_permits(), 1);
}
#[tokio::test]
async fn cancellation_and_read_errors_keep_their_original_outcomes() {
for cancel in [true, false] {
let (exchange, slots) = observation().await;
let metrics = exchange.metrics.clone();
let logs = exchange.logs.clone();
let inner = if cancel {
Body::from_stream(futures_util::stream::pending::<Result<Bytes, std::io::Error>>())
} else {
Body::from_stream(futures_util::stream::once(async {
Err::<Bytes, _>(std::io::Error::other("test read error"))
}))
};
let mut body =
MeteredBody::new(inner, exchange, Direction::Response, Duration::from_secs(1));
if cancel {
assert!(futures_util::poll!(body.frame()).is_pending());
drop(body);
} else {
assert!(body.frame().await.unwrap().is_err());
assert!(body.timer.is_none());
}
assert_eq!(slots.available_permits(), 1);
assert_eq!(metrics.errors.load(Ordering::Relaxed), 1);
assert_eq!(
logs.recent()[0].outcome,
if cancel { "cancelled" } else { "body_error" }
);
}
}
}
+1
View File
@@ -301,6 +301,7 @@ fn apply_backend_diagnostic_values(
backend_header: bool,
backend_timing_header: bool,
) {
response.headers_mut().remove("x-rustproxy-backend");
response.headers_mut().remove("x-rust-proxy-backend");
response.headers_mut().remove("x-rust-proxy-backend-time");
response.headers_mut().remove("x-rustproxy-backend-time");
+93
View File
@@ -0,0 +1,93 @@
//! Request-local cache decisions derived from the already validated route.
//! A disabled plan retains its diagnostic reason without preparing cache-only
//! policy, cookie matching or request metadata. Access and origin policy stay
//! in the common forwarding path and must never depend on this plan.
use crate::cache::{RequestPolicy, Settings};
use http::{HeaderMap, Method};
#[derive(Debug, Clone, Copy)]
pub(super) enum CachePlan {
Disabled(&'static str),
Enabled { force_cache: bool, ignore_cookie: bool },
}
impl CachePlan {
pub(super) fn new(
disabled_reason: Option<&'static str>,
settings: &Settings,
path: &str,
force_match: impl FnOnce() -> bool,
) -> Self {
if let Some(reason) = disabled_reason {
return Self::Disabled(reason);
}
let force_cache = force_match();
Self::Enabled {
force_cache,
ignore_cookie: !force_cache && settings.ignore_cookie_for_path(path),
}
}
pub(super) fn force_cache(self) -> bool {
matches!(self, Self::Enabled { force_cache: true, .. })
}
pub(super) fn ignore_cookie(self) -> bool {
matches!(self, Self::Enabled { ignore_cookie: true, .. })
}
pub(super) fn request_policy(
self,
method: &Method,
headers: &HeaderMap,
body_empty: bool,
path: &str,
has_query: bool,
settings: &Settings,
) -> RequestPolicy {
match self {
Self::Disabled(reason) => RequestPolicy::Bypass(reason),
Self::Enabled { force_cache: true, .. } =>
crate::cache::forced_request_policy(method, headers, body_empty),
Self::Enabled { .. } =>
crate::cache::request_policy(method, headers, body_empty, path, has_query, settings),
}
}
}
/// Header rules may only make a request less cacheable. An original bypass is
/// final, so there is no reason to parse the rewritten headers in that case.
pub(super) fn combine_policy(
original: RequestPolicy,
rewritten: impl FnOnce() -> RequestPolicy,
) -> RequestPolicy {
if matches!(original, RequestPolicy::Bypass(_)) {
return original;
}
match (original, rewritten()) {
(_, RequestPolicy::Bypass(reason)) => RequestPolicy::Bypass(reason),
(RequestPolicy::Revalidate(reason), _) => RequestPolicy::Revalidate(reason),
(_, RequestPolicy::Revalidate(reason)) => RequestPolicy::Revalidate(reason),
_ => RequestPolicy::Use,
}
}
/// Origin Range semantics also disable transformation/compression when the
/// cache is off. Only a cache-eligible request needs byte-range parsing.
pub(super) fn range_selection(
cache_eligible: bool,
method: &Method,
headers: &HeaderMap,
) -> crate::cache::RangeSelection {
if cache_eligible {
crate::cache::request_range(method, headers)
} else if headers.contains_key("range") {
crate::cache::RangeSelection::Unsupported
} else {
crate::cache::RangeSelection::None
}
}
#[cfg(test)]
mod tests;
+113
View File
@@ -0,0 +1,113 @@
use super::*;
use crate::cache::RangeSelection;
use http::HeaderValue;
fn settings() -> Settings {
Settings {
enabled: true,
static_ignore_cookies: true,
static_extensions: vec!["css".into()],
..Default::default()
}
}
#[test]
fn disabled_cache_retains_reason_without_matching_force_rules_or_reparsing_headers() {
let settings = settings();
let mut headers = HeaderMap::new();
headers.insert("authorization", HeaderValue::from_static("private"));
headers.insert("cache-control", HeaderValue::from_static("max-age=not-a-number"));
for reason in [
"cache_disabled", "route_disabled", "admin_control_plane", "basic_auth",
"tinyauth", "sticky_cookie", "request_upgrade",
] {
let plan = CachePlan::new(Some(reason), &settings, "/style.css", || {
panic!("disabled cache must not match force-cache rules")
});
assert!(!plan.force_cache());
assert!(!plan.ignore_cookie());
let policy = plan.request_policy(&Method::GET, &headers, true, "/style.css", false, &settings);
assert_eq!(policy, RequestPolicy::Bypass(reason));
assert_eq!(combine_policy(policy, || panic!("disabled cache must not parse post-rule policy")), policy);
}
}
#[test]
fn cache_on_keeps_all_existing_request_policy_outcomes() {
let settings = settings();
for forced in [false, true] {
let plan = CachePlan::new(None, &settings, "/style.css", || forced);
for (name, value) in [
("x-irrelevant", "yes"), ("cache-control", "no-store"),
("cache-control", "no-cache"), ("cache-control", "max-age=0"),
("cache-control", "min-fresh=broken"), ("authorization", "private"),
("cookie", "session=private"), ("range", "bytes=0-2"),
("range", "bytes=0-2,4-5"), ("if-range", "\"etag\""),
("if-match", "\"etag\""), ("upgrade", "websocket"),
] {
let mut headers = HeaderMap::new();
headers.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), value.parse().unwrap());
for method in [Method::GET, Method::HEAD, Method::POST] {
for body_empty in [false, true] {
let expected = if forced {
crate::cache::forced_request_policy(&method, &headers, body_empty)
} else {
crate::cache::request_policy(&method, &headers, body_empty, "/style.css", false, &settings)
};
assert_eq!(plan.request_policy(&method, &headers, body_empty, "/style.css", false, &settings), expected);
}
}
}
}
}
#[test]
fn request_header_rules_cannot_relax_bypass_or_revalidation() {
let cases = [
RequestPolicy::Use,
RequestPolicy::Revalidate("request_no_cache"),
RequestPolicy::Revalidate("request_max_age"),
RequestPolicy::Bypass("request_authorization"),
RequestPolicy::Bypass("request_no_store"),
];
for original in cases {
for rewritten in cases {
let expected = match (original, rewritten) {
(RequestPolicy::Bypass(reason), _) | (_, RequestPolicy::Bypass(reason)) => RequestPolicy::Bypass(reason),
(RequestPolicy::Revalidate(reason), _) | (_, RequestPolicy::Revalidate(reason)) => RequestPolicy::Revalidate(reason),
_ => RequestPolicy::Use,
};
assert_eq!(combine_policy(original, || rewritten), expected);
}
}
}
#[test]
fn cache_off_still_blocks_range_transformation_and_preserves_origin_headers() {
for method in [Method::GET, Method::HEAD, Method::POST] {
for range in [None, Some("bytes=0-2"), Some("bytes=0-2,4-5"), Some("broken"), Some("bytes=99-")] {
let mut headers = HeaderMap::new();
if let Some(range) = range { headers.insert("range", range.parse().unwrap()); }
headers.insert("if-range", HeaderValue::from_static("\"same-validator\""));
headers.insert("if-none-match", HeaderValue::from_static("\"client-validator\""));
let original = headers.clone();
let baseline = crate::cache::request_range(&method, &headers);
assert_eq!(range_selection(true, &method, &headers), baseline);
assert_eq!(matches!(range_selection(false, &method, &headers), RangeSelection::None), matches!(baseline, RangeSelection::None));
assert_eq!(headers, original, "origin must receive the original Range/conditional fields");
}
}
}
#[test]
fn force_cache_keeps_cookie_variants_and_disabled_cookie_matching_has_no_effect() {
let settings = settings();
let ordinary = CachePlan::new(None, &settings, "/style.css", || false);
assert!(ordinary.ignore_cookie());
let forced = CachePlan::new(None, &settings, "/style.css", || true);
assert!(forced.force_cache());
assert!(!forced.ignore_cookie());
let disabled = CachePlan::new(Some("route_disabled"), &settings, "/style.css", || true);
assert!(!disabled.force_cache());
assert!(!disabled.ignore_cookie());
}
+27 -53
View File
@@ -167,7 +167,7 @@ let body_empty = if cache_enabled
}
}
} else {
request.body().is_end_stream()
cache_enabled && request.body().is_end_stream()
};
let public_suffix = request
.uri()
@@ -175,29 +175,18 @@ let public_suffix = request
.map(|v| v.as_str())
.unwrap_or("/")
.to_owned();
let force_cache = policy_reason.is_none()
&& cache_settings.active_for(route.cache)
&& route
let cache_plan = cache_plan::CachePlan::new(policy_reason, cache_settings, &path, || {
cache_settings.active_for(route.cache) && route
.frontend
.as_ref()
.is_some_and(|frontend| frontend.force_cache.matches(&path, &public_suffix));
let ignore_static_cookie = !force_cache && cache_settings.ignore_cookie_for_path(&path);
let original_policy = policy_reason
.map(crate::cache::RequestPolicy::Bypass)
.unwrap_or_else(|| {
if force_cache {
crate::cache::forced_request_policy(request.method(), request.headers(), body_empty)
} else {
crate::cache::request_policy(
request.method(),
request.headers(),
body_empty,
&path,
request.uri().query().is_some(),
cache_settings,
)
}
});
.is_some_and(|frontend| frontend.force_cache.matches(&path, &public_suffix))
});
let force_cache = cache_plan.force_cache();
let ignore_static_cookie = cache_plan.ignore_cookie();
let original_policy = cache_plan.request_policy(
request.method(), request.headers(), body_empty, &path,
request.uri().query().is_some(), cache_settings,
);
let only_cached =
cache_settings.active_for(route.cache) && crate::cache::only_if_cached(request.headers());
@@ -282,48 +271,31 @@ if websocket_legacy_support {
}
}
}
let range_selection = crate::cache::request_range(request.method(), request.headers());
let if_range = request.headers().get("if-range").cloned();
let range_requested = !matches!(range_selection, crate::cache::RangeSelection::None);
let post_policy = if force_cache {
crate::cache::forced_request_policy(request.method(), request.headers(), body_empty)
} else {
crate::cache::request_policy(
request.method(),
request.headers(),
body_empty,
&path,
request.uri().query().is_some(),
cache_settings,
let effective_policy = cache_plan::combine_policy(original_policy, || {
cache_plan.request_policy(
request.method(), request.headers(), body_empty, &path,
request.uri().query().is_some(), cache_settings,
)
};
});
let force_vary_cookie = force_cache && request.headers().contains_key("cookie");
let force_vary_authorization = force_cache && request.headers().contains_key("authorization");
let effective_policy = match (original_policy, post_policy) {
(crate::cache::RequestPolicy::Bypass(reason), _) => {
crate::cache::RequestPolicy::Bypass(reason)
}
(_, crate::cache::RequestPolicy::Bypass(reason)) => {
crate::cache::RequestPolicy::Bypass(reason)
}
(crate::cache::RequestPolicy::Revalidate(reason), _) => {
crate::cache::RequestPolicy::Revalidate(reason)
}
(_, crate::cache::RequestPolicy::Revalidate(reason)) => {
crate::cache::RequestPolicy::Revalidate(reason)
}
_ => crate::cache::RequestPolicy::Use,
};
let effective_reason = effective_policy.bypass_reason();
let revalidation_reason = effective_policy.revalidation_reason();
let effective_cacheable = effective_reason.is_none();
let range_selection = cache_plan::range_selection(
effective_cacheable, request.method(), request.headers(),
);
let if_range = if effective_cacheable {
request.headers().get("if-range").cloned()
} else { None };
let range_requested = !matches!(range_selection, crate::cache::RangeSelection::None);
// Match Varnish-style cache identity: a load-balanced backend member must not
// fragment the object cache. The vhost scope + canonical host + request target
// already identify the public resource; Vary handles representation variants.
let cache_route = route.id.as_str();
let mut cache_key_headers = None;
if ignore_static_cookie && request.headers().contains_key("cookie") {
if effective_cacheable && ignore_static_cookie && request.headers().contains_key("cookie") {
let mut headers = request.headers().clone();
headers.remove("cookie");
cache_key_headers = Some(headers);
@@ -344,7 +316,9 @@ let cache_ticket = if effective_cacheable {
} else {
None
};
let client_conditionals = request.headers().clone();
let client_conditionals = if cache_ticket.is_some() {
request.headers().clone()
} else { HeaderMap::new() };
let cached_revalidation = if let Some(ticket) = cache_ticket.as_ref() {
state.cache.revalidation_tiered(ticket).await
} else {
+2 -1
View File
@@ -94,7 +94,8 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent {
// Both origin and transformed policies must be cacheable; hiding a
// Set-Cookie/private header never authorizes storage of private content.
let transformed_plan =
if ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie") {
if origin_plan.is_none()
|| (ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie")) {
None
} else {
crate::cache::plan(
+6 -1
View File
@@ -1,5 +1,10 @@
{
let origin_validation_headers = response.headers().clone();
// Only a real cache revalidation needs origin headers after response rules.
let origin_validation_headers = if revalidation_sent
&& response.status() == StatusCode::NOT_MODIFIED
{
response.headers().clone()
} else { HeaderMap::new() };
if let Some(exchange) = &exchange {
exchange
.upstream_status
+3 -1
View File
@@ -119,7 +119,9 @@ if websocket_upstream_h2 {
// Do not classify a slow client upload as a slow origin response. Probes
// independently measure origin liveness/latency with body-free requests.
let request_body_empty = request.body().size_hint().exact() == Some(0);
let proactive_base_headers = request.headers().clone();
let proactive_base_headers = if cache_ticket.is_some() && cache_settings.proactive_refresh_enabled {
request.headers().clone()
} else { HeaderMap::new() };
let response_header_timeout = frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
.unwrap_or(state.header_timeout);
+48 -38
View File
@@ -28,7 +28,9 @@
// makes PHP-FPM omit HTTP_HOST, breaking WordPress.
request.headers_mut().insert(HOST, original_host.clone());
let request_method = request.method().clone();
let request_headers = request.headers().clone();
// This branch owns local-origin delivery. Move its headers instead of
// cloning them; the request remains only for exchange/body extensions.
let mut request_headers = std::mem::take(request.headers_mut());
let request_uri = request.uri().clone();
let request_version = request.version();
let public_suffix = request_uri.path_and_query()
@@ -41,32 +43,33 @@
Err(_) => return error(StatusCode::BAD_REQUEST,
"RustProxy: invalid or incomplete request body\n"),
}
} else { request.body().is_end_stream() };
let force_cache = enabled && route.frontend.as_ref().is_some_and(|frontend|
frontend.force_cache.matches(&path, &public_suffix));
let policy = if enabled {
if force_cache {
crate::cache::forced_request_policy(&request_method, &request_headers, empty_body)
} else {
crate::cache::request_policy(
&request_method, &request_headers, empty_body, &path,
request_uri.query().is_some(), cache_settings)
}
} else {
crate::cache::RequestPolicy::Bypass("cache_disabled")
};
} else { enabled && request.body().is_end_stream() };
let cache_plan = cache_plan::CachePlan::new(
(!enabled).then_some("cache_disabled"), cache_settings, &path,
|| route.frontend.as_ref().is_some_and(|frontend|
frontend.force_cache.matches(&path, &public_suffix)),
);
let force_cache = cache_plan.force_cache();
let ignore_cookie = cache_plan.ignore_cookie();
let policy = cache_plan.request_policy(
&request_method, &request_headers, empty_body, &path,
request_uri.query().is_some(), cache_settings,
);
// HEAD can use an existing GET representation but may not fill a cache
// entry with its empty downstream body.
let eligible = enabled && policy.bypass_reason().is_none();
let mut key_headers = request_headers.clone();
let ignore_cookie = !force_cache && cache_settings.ignore_cookie_for_path(&path);
if ignore_cookie { key_headers.remove("cookie"); }
let key_headers = if eligible && ignore_cookie && request_headers.contains_key("cookie") {
let mut headers = request_headers.clone();
headers.remove("cookie");
Some(headers)
} else { None };
let cache_ticket = if eligible {
state.cache.ticket_scoped(state.cache.revision(), scope, context.scheme,
original_host.to_str().unwrap_or(""), &route.id, &public_suffix, &key_headers)
original_host.to_str().unwrap_or(""), &route.id, &public_suffix,
key_headers.as_ref().unwrap_or(&request_headers))
} else { None };
let range = crate::cache::request_range(&request_method, &request_headers);
let if_range = request_headers.get("if-range").cloned();
let range = cache_plan::range_selection(eligible, &request_method, &request_headers);
let if_range = if eligible { request_headers.get("if-range").cloned() } else { None };
let range_requested = !matches!(range, crate::cache::RangeSelection::None);
// Unlike the original Web Server path, retain stale and client-forced
// revalidation candidates. A local 304 can refresh them without replacing
@@ -122,9 +125,14 @@
// Neither the Request nor the streaming Body is held across a borrowed
// await: the filesystem/PHP source owns the body for its full lifetime.
let static_body = std::mem::replace(request.body_mut(), Body::empty());
let (origin_request_headers, revalidation_sent) =
let (origin_request_headers, revalidation_sent) = if cache_ticket.is_some() {
web_cache_revalidation::web_origin_request_headers(
&request_headers, cached_revalidation.as_ref());
&request_headers, cached_revalidation.as_ref())
} else {
// No cache result can need the original conditionals later. Preserve
// all origin HTTP semantics by handing over the untouched header map.
(std::mem::take(&mut request_headers), false)
};
let started = Instant::now();
let response = crate::web_server::serve(service, request_method.clone(),
origin_request_headers, request_uri, &service_path, static_body,
@@ -181,25 +189,27 @@
state.cache.discard(ticket);
}
}
let origin_headers = response.headers().clone();
let original_plan = crate::cache::plan(response.status(), &origin_headers,
cache_settings, started.elapsed());
// Do not allow a later response-header rewrite to override a private,
// explicitly uncacheable PHP/CGI origin response.
let explicit_origin_policy = origin_headers.contains_key("cache-control")
|| origin_headers.contains_key("expires")
|| origin_headers.contains_key("set-cookie")
|| origin_headers.contains_key("vary");
let origin_safe = !explicit_origin_policy || original_plan.is_some();
let no_transform = crate::transform::no_transform(&origin_headers);
let skip_compression = crate::compression::no_transform(&origin_headers);
let origin_headers = response.headers();
let origin_safe = cache_ticket.is_some() && {
let explicit_origin_policy = origin_headers.contains_key("cache-control")
|| origin_headers.contains_key("expires")
|| origin_headers.contains_key("set-cookie")
|| origin_headers.contains_key("vary");
!explicit_origin_policy || crate::cache::plan(response.status(), origin_headers,
cache_settings, started.elapsed()).is_some()
};
let no_transform = crate::transform::no_transform(origin_headers);
let skip_compression = crate::compression::no_transform(origin_headers);
let origin_validators = crate::transform::OriginValidators {
etag: origin_headers.get("etag").cloned(),
last_modified: origin_headers.get("last-modified").cloned(),
};
if skip_compression || range_requested {
response.extensions_mut().insert(crate::compression::Skip);
}
response.extensions_mut().insert(crate::transform::OriginValidators {
etag: origin_headers.get("etag").cloned(),
last_modified: origin_headers.get("last-modified").cloned(),
});
response.extensions_mut().insert(origin_validators);
apply_response_rules(response.headers_mut(), &snapshot, route.frontend.as_deref(),
Some(route), false);
if response.extensions().get::<crate::pages::PageServed>().is_some() {
@@ -271,4 +281,4 @@
let transformed = response.extensions().get::<crate::transform::Applied>().is_some();
cache_settings.annotate_transformed(response.headers_mut(), transformed);
response
}
}
+3
View File
@@ -1,6 +1,7 @@
include!("types.rs");
include!("error_fallback.rs");
include!("cache_helpers.rs");
mod cache_plan;
mod web_cache_revalidation;
include!("cache_http_invalidation.rs");
include!("upstream_helpers.rs");
@@ -14,3 +15,5 @@ include!("tests/h3_navigation_tests.rs");
#[cfg(test)]
include!("tests/http_invalidation_tests.rs");
#[cfg(test)]
include!("tests/cache_fast_path_tests.rs");
+26
View File
@@ -0,0 +1,26 @@
fn request_id_hash(sequence: u64) -> String {
use rand::TryRng;
use sha2::{Digest, Sha256};
let mut nonce = [0u8; 32];
let mut rng = rand::rngs::SysRng;
rng.try_fill_bytes(&mut nonce)
.expect("secure operating system random source unavailable");
let mut hash = Sha256::new();
hash.update(sequence.to_be_bytes());
hash.update(nonce);
request_id_hex(&hash.finalize())
}
fn request_id_hex(digest: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut encoded = String::with_capacity(digest.len() * 2);
for &byte in digest {
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0f) as usize] as char);
}
encoded
}
#[cfg(test)]
include!("request_id/tests.rs");
+58
View File
@@ -0,0 +1,58 @@
mod request_id_tests {
use super::*;
use sha2::{Digest, Sha256};
#[test]
fn hexadecimal_encoding_matches_previous_sha256_display() {
for byte in 0u8..=255 {
let digest = Sha256::digest([byte; 32]);
assert_eq!(request_id_hex(&digest), format!("{digest:x}"));
}
assert_eq!(
request_id_hex(&[0, 1, 15, 16, 127, 128, 254, 255]),
"00010f107f80feff"
);
}
#[test]
fn request_ids_keep_their_64_hexadecimal_format_and_randomness() {
let mut seen = std::collections::HashSet::new();
for _ in 0..512 {
let id = request_id_hash(1);
assert_eq!(id.len(), 64);
assert!(id
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)));
assert!(
seen.insert(id),
"fixed sequence must still use independent OS entropy"
);
}
}
#[test]
#[ignore = "explicit release-mode microbenchmark; not an acceptance threshold"]
fn benchmark_request_id_formatting() {
use std::hint::black_box;
const ITERATIONS: u32 = 200_000;
let digest = Sha256::digest(b"same SHA256 digest for both formatters");
for round in 0..7 {
for candidate in if round % 2 == 0 {
[false, true]
} else {
[true, false]
} {
let start = Instant::now();
for _ in 0..ITERATIONS {
let value = if candidate {
request_id_hex(black_box(&digest))
} else {
format!("{:x}", black_box(&digest))
};
black_box(value);
}
println!("{{\"round\":{round},\"candidate\":{candidate},\"iterations\":{ITERATIONS},\"ns_per_op\":{}}}", start.elapsed().as_nanos() as f64 / ITERATIONS as f64);
}
}
}
}
+5 -5
View File
@@ -218,6 +218,7 @@ mod authority_tests {
#[test]
fn generated_policy_and_gateway_errors_use_html_pages() {
for status in [
StatusCode::BAD_GATEWAY,
StatusCode::FORBIDDEN,
StatusCode::TOO_MANY_REQUESTS,
StatusCode::SERVICE_UNAVAILABLE,
@@ -231,11 +232,6 @@ mod authority_tests {
);
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
}
let response = error(StatusCode::BAD_GATEWAY, "internal detail\n");
assert_ne!(
response.headers().get("content-type").unwrap(),
"text/html; charset=utf-8"
);
}
#[test]
fn locally_generated_pages_remove_cache_metadata() {
@@ -344,6 +340,7 @@ mod authority_tests {
#[test]
fn backend_diagnostics_are_opt_in_and_strip_spoofed_values() {
let mut response = Response::new(Body::empty());
response.headers_mut().insert("x-rustproxy-backend", HeaderValue::from_static("canonical-spoof"));
response
.headers_mut()
.insert("x-rust-proxy-backend", HeaderValue::from_static("spoofed"));
@@ -356,6 +353,7 @@ mod authority_tests {
HeaderValue::from_static("999ms"),
);
apply_backend_diagnostic_values(&mut response, false, false);
assert!(response.headers().get("x-rustproxy-backend").is_none());
assert!(response.headers().get("x-rust-proxy-backend").is_none());
assert!(response
.headers()
@@ -369,7 +367,9 @@ mod authority_tests {
response
.extensions_mut()
.insert(OriginTiming(Duration::from_millis(12)));
response.headers_mut().insert("x-rustproxy-backend", HeaderValue::from_static("canonical-spoof"));
apply_backend_diagnostic_values(&mut response, true, true);
assert!(response.headers().get("x-rustproxy-backend").is_none());
assert_eq!(
response.headers().get("x-rust-proxy-backend").unwrap(),
"origin-a"
+319
View File
@@ -0,0 +1,319 @@
mod cache_fast_path_tests {
use super::*;
use crate::{auth::Auth, config::{self, Config, Update, ValidationPolicy}, db::Store};
use std::net::SocketAddr;
use tempfile::TempDir;
use tower::ServiceExt;
const CSS: &[u8] = b"/* origin comment */\nbody { color: red; margin: 0px; }\n";
struct Fixture { app: Arc<App>, _directory: TempDir }
struct Received { status: StatusCode, headers: HeaderMap, body: Bytes }
impl Fixture {
async fn new(config: Config) -> Self {
let directory = TempDir::new().unwrap();
let policy = ValidationPolicy {
tls_addr: None, cert_dir: None, data_dir: None,
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
admin_addr: "127.0.0.1:9090".parse().unwrap(),
};
let store = Store::open(&directory.path().join("db"), &config).await.unwrap();
let auth = Auth::new(store.clone(), directory.path().to_owned()).await.unwrap();
let snapshot = config::compile(config, 0, &policy).unwrap();
let app = App::new(store, snapshot, policy, auth, 32,
Duration::from_secs(5), Duration::from_secs(5), Duration::from_secs(30));
Self { app, _directory: directory }
}
async fn send(&self, method: Method, path: &str, headers: &[(&str, &str)], body: Body) -> Received {
send(self.app.clone(), method, path, headers, body).await
}
async fn get(&self, path: &str, headers: &[(&str, &str)]) -> Received {
self.send(Method::GET, path, headers, Body::empty()).await
}
async fn change(&self, edit: impl FnOnce(&mut Config)) {
let current = self.app.config.load_full();
let mut config = current.envelope.config.clone();
edit(&mut config);
self.app.update(Update { expected_revision: current.envelope.revision, config }).await.unwrap();
}
async fn stop(self) {
self.app.shutdown.cancel();
self.app.tunnels.close();
self.app.tunnels.wait().await;
self.app.store.close().await;
}
}
async fn send(app: Arc<App>, method: Method, path: &str, headers: &[(&str, &str)], body: Body) -> Received {
let mut builder = Request::builder().method(method).uri(path)
.header(HOST, "localhost").header(USER_AGENT, "cache-plan-regression/1.0");
for (name, value) in headers { builder = builder.header(*name, *value); }
let mut request = builder.body(body).unwrap();
request.extensions_mut().insert(PeerAddr("192.0.2.50:45678".parse().unwrap()));
request.extensions_mut().insert(LocalAddr("127.0.0.1:8080".parse().unwrap()));
let response = router(app).oneshot(request).await.unwrap();
let (parts, body) = response.into_parts();
let body = body.collect().await.unwrap().to_bytes();
Received { status: parts.status, headers: parts.headers, body }
}
fn config_for(origin: SocketAddr, enabled: bool) -> Config {
let mut config = config::demo_config(origin);
config.cache.enabled = enabled;
config.cache.expose_status = true;
config.cache.expose_hits = true;
config.cache.expose_cache_status = true;
config.cache.expose_only_hits = false;
config.cache.static_ignore_cookies = false;
config
}
fn web_config(directory: &TempDir, enabled: bool) -> Config {
std::fs::write(directory.path().join("style.css"), CSS).unwrap();
let mut config = config_for("127.0.0.1:9000".parse().unwrap(), enabled);
config.web_services.push(crate::web_server::Service {
id: "site".into(), name: "Site".into(), document_root: directory.path().to_owned(),
cache_control: Some("public, max-age=600".into()), ..Default::default()
});
config.vhosts[0].source = crate::vhost::Source::WebServer { web_service_id: "site".into() };
config.vhosts[0].backends.clear();
config.vhosts[0].routes.clear();
config
}
#[tokio::test]
async fn web_cache_disabled_keeps_get_head_range_conditionals_and_observability() {
let directory = TempDir::new().unwrap();
let fixture = Fixture::new(web_config(&directory, false)).await;
let first = fixture.get("/style.css", &[]).await;
assert_eq!(first.status, StatusCode::OK);
assert_eq!(first.body, CSS);
assert!(!first.headers.contains_key("x-cache"));
let etag = first.headers["etag"].to_str().unwrap();
let head = fixture.send(Method::HEAD, "/style.css", &[], Body::empty()).await;
assert_eq!(head.status, StatusCode::OK);
assert_eq!(head.headers["content-length"], CSS.len().to_string());
assert!(head.body.is_empty());
let partial = fixture.get("/style.css", &[("range", "bytes=0-3"), ("if-range", etag)]).await;
assert_eq!(partial.status, StatusCode::PARTIAL_CONTENT);
assert_eq!(partial.body, &CSS[..4]);
let mismatch = fixture.get("/style.css", &[("range", "bytes=0-3"), ("if-range", "\"different\"")]).await;
assert_eq!(mismatch.status, StatusCode::OK);
assert_eq!(mismatch.body, CSS);
let conditional = fixture.get("/style.css", &[("if-none-match", etag)]).await;
assert_eq!(conditional.status, StatusCode::NOT_MODIFIED);
assert!(conditional.body.is_empty());
let invalid = fixture.get("/style.css", &[("range", "bytes=99999-")]).await;
assert_eq!(invalid.status, StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(invalid.headers["content-range"], format!("bytes */{}", CSS.len()));
let only_cached = fixture.get("/style.css", &[("cache-control", "only-if-cached")]).await;
assert_eq!(only_cached.body, CSS);
let logs = fixture.app.logs.recent();
assert_eq!(logs.len(), 7);
assert!(logs.iter().all(|entry| entry.cache_reason == "cache_disabled"));
assert!(logs.iter().all(|entry| entry.request_id.len() == 64));
assert_eq!(fixture.app.metrics.completed.load(Ordering::Relaxed), 7);
assert_eq!(fixture.app.metrics.active.load(Ordering::Relaxed), 0);
assert_eq!(fixture.app.logs.stats().queue_dropped, 0);
fixture.stop().await;
}
#[tokio::test]
async fn web_cache_hot_toggles_keep_miss_hit_and_bypass_diagnostics() {
let directory = TempDir::new().unwrap();
let fixture = Fixture::new(web_config(&directory, false)).await;
assert!(!fixture.get("/style.css", &[]).await.headers.contains_key("x-cache"));
fixture.change(|config| config.cache.enabled = true).await;
assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "MISS");
let hit = fixture.get("/style.css", &[]).await;
assert_eq!(hit.headers["x-cache"], "HIT");
assert_eq!(hit.body, CSS);
fixture.change(|config| config.vhosts[0].routes.push(crate::vhost::PathRoute {
id: "uncached".into(), path_prefix: "/style.css".into(), cache: Some(false),
source: crate::vhost::Source::WebServer { web_service_id: "site".into() },
..Default::default()
})).await;
let bypass = fixture.get("/style.css", &[]).await;
assert_eq!(bypass.headers["x-cache"], "BYPASS");
assert_eq!(bypass.headers["x-cache-reason"], "cache_disabled");
fixture.change(|config| {
config.vhosts[0].routes.clear();
config.vhosts[0].cache.enabled = Some(false);
}).await;
assert!(!fixture.get("/style.css", &[]).await.headers.contains_key("x-cache"));
fixture.change(|config| config.vhosts[0].cache.enabled = None).await;
assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "MISS");
assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "HIT");
fixture.stop().await;
}
#[tokio::test]
async fn web_transform_remains_independent_of_disabled_cache_and_range() {
let directory = TempDir::new().unwrap();
let mut config = web_config(&directory, false);
config.transform.enabled = true;
config.vhosts[0].transform.enabled = true;
let fixture = Fixture::new(config).await;
let normal = fixture.get("/style.css", &[]).await;
assert_eq!(normal.status, StatusCode::OK);
assert!(normal.body.len() < CSS.len(), "transform must remain active with cache off");
let range = fixture.get("/style.css", &[("range", "bytes=0-3"), ("accept-encoding", "gzip")]).await;
assert_eq!(range.status, StatusCode::PARTIAL_CONTENT);
assert_eq!(range.body, &CSS[..4]);
assert!(!range.headers.contains_key("content-encoding"));
fixture.change(|config| config.vhosts[0].transform.enabled = false).await;
assert_eq!(fixture.get("/style.css", &[]).await.body, CSS);
fixture.stop().await;
}
#[tokio::test]
async fn cache_fast_paths_keep_global_and_location_access_control() {
let directory = TempDir::new().unwrap();
for enabled in [false, true] {
let mut config = web_config(&directory, enabled);
let deny = crate::acl::Acl { default: crate::acl::Action::Deny, rules: vec![] };
config.host_policies.push(config::HostPolicy { hosts: vec!["localhost".into()], acl: deny.clone() });
let fixture = Fixture::new(config).await;
assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::FORBIDDEN);
fixture.change(|config| {
config.host_policies.clear();
config.vhosts[0].routes.push(crate::vhost::PathRoute {
id: "private".into(), path_prefix: "/style.css".into(), acl: Some(deny),
source: crate::vhost::Source::WebServer { web_service_id: "site".into() },
..Default::default()
});
}).await;
assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::FORBIDDEN);
fixture.stop().await;
}
}
struct Origin { addr: SocketAddr, requests: Arc<AtomicU64>, task: tokio::task::JoinHandle<()> }
impl Drop for Origin { fn drop(&mut self) { self.task.abort(); } }
async fn origin() -> Origin {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let requests = Arc::new(AtomicU64::new(0));
let count = requests.clone();
let app = Router::new().fallback(move |request: Request| {
let count = count.clone();
async move {
count.fetch_add(1, Ordering::Relaxed);
let (parts, body) = request.into_parts();
if parts.uri.path() == "/slow" { tokio::time::sleep(Duration::from_millis(25)).await; }
let input = body.collect().await.unwrap().to_bytes();
let variant = parts.headers.get("x-variant").and_then(|v| v.to_str().ok()).unwrap_or("default");
let output = if parts.method == Method::POST { input } else { Bytes::from(format!("origin-{variant}")) };
let mut response = Response::new(Body::from(output.clone()));
response.headers_mut().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" }));
response.headers_mut().insert("vary", HeaderValue::from_static("X-Variant"));
response.headers_mut().insert("content-type", HeaderValue::from_static("text/plain"));
for name in ["range", "if-range", "if-none-match", "cookie", "authorization", "x-request-id"] {
if let Some(value) = parts.headers.get(name) {
let echoed: http::HeaderName = format!("x-seen-{name}").parse().unwrap();
response.headers_mut().insert(echoed, value.clone());
}
}
if parts.headers.get("if-none-match").is_some_and(|v| v == "\"origin-v1\"") {
*response.status_mut() = StatusCode::NOT_MODIFIED;
*response.body_mut() = Body::empty();
} else if parts.headers.get("range").is_some_and(|v| v == "bytes=0-2")
&& parts.headers.get("if-range").is_none_or(|v| v == "\"origin-v1\"") {
*response.status_mut() = StatusCode::PARTIAL_CONTENT;
response.headers_mut().insert("content-range", format!("bytes 0-2/{}", output.len()).parse().unwrap());
*response.body_mut() = Body::from(output.slice(..3));
}
response
}
});
let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); });
Origin { addr, requests, task }
}
#[tokio::test]
async fn proxy_disabled_cache_preserves_headers_post_and_conditionals() {
let origin = origin().await;
let fixture = Fixture::new(config_for(origin.addr, false)).await;
let range = fixture.get("/inspect", &[("range", "bytes=0-2"), ("if-range", "\"origin-v1\""),
("cookie", "session=one"), ("authorization", "Bearer example")]).await;
assert_eq!(range.status, StatusCode::PARTIAL_CONTENT);
assert_eq!(range.body, "ori");
assert_eq!(range.headers["x-seen-range"], "bytes=0-2");
assert_eq!(range.headers["x-seen-if-range"], "\"origin-v1\"");
assert_eq!(range.headers["x-seen-cookie"], "session=one");
assert_eq!(range.headers["x-seen-authorization"], "Bearer example");
assert_eq!(range.headers["x-seen-x-request-id"].as_bytes().len(), 64);
assert!(!range.headers.contains_key("x-cache"));
let conditional = fixture.get("/inspect", &[("if-none-match", "\"origin-v1\"")]).await;
assert_eq!(conditional.status, StatusCode::NOT_MODIFIED);
assert!(conditional.body.is_empty());
let input = futures_util::stream::iter([
Ok::<_, std::io::Error>(Bytes::from_static(b"streamed ")),
Ok(Bytes::from_static(b"content")),
]);
let post = fixture.send(Method::POST, "/echo", &[], Body::from_stream(input)).await;
assert_eq!(post.status, StatusCode::OK);
assert_eq!(post.body, "streamed content");
assert_eq!(origin.requests.load(Ordering::Relaxed), 3);
assert!(fixture.app.logs.recent().iter().all(|entry| entry.cache_reason == "cache_disabled"));
fixture.stop().await;
}
#[tokio::test]
async fn proxy_cache_on_preserves_vary_private_revalidation_and_ranges() {
let origin = origin().await;
let fixture = Fixture::new(config_for(origin.addr, true)).await;
for variant in ["a", "b"] {
assert_eq!(fixture.get("/vary", &[("x-variant", variant)]).await.headers["x-cache"], "MISS");
}
let hit = fixture.get("/vary", &[("x-variant", "a")]).await;
assert_eq!(hit.headers["x-cache"], "HIT");
assert_eq!(hit.body, "origin-a");
let range = fixture.get("/vary", &[("x-variant", "a"), ("range", "bytes=0-2"), ("if-range", "\"origin-v1\"")]).await;
assert_eq!(range.status, StatusCode::PARTIAL_CONTENT);
assert_eq!(range.headers["x-cache"], "HIT");
assert_eq!(range.body, "ori");
let validated = fixture.get("/vary", &[("x-variant", "a"), ("cache-control", "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!(validated.body, "origin-a");
let conditional = fixture.get("/vary", &[("x-variant", "a"), ("if-none-match", "\"origin-v1\"")]).await;
assert_eq!(conditional.status, StatusCode::NOT_MODIFIED);
let bypass = fixture.get("/vary", &[("authorization", "Bearer example")]).await;
assert_eq!(bypass.headers["x-cache-reason"], "request_authorization");
for _ in 0..2 {
assert_eq!(fixture.get("/private", &[]).await.headers["x-cache"], "MISS");
}
assert_eq!(origin.requests.load(Ordering::Relaxed), 6);
fixture.stop().await;
}
#[tokio::test]
async fn proxy_cache_enabled_concurrent_fill_stays_single_flight() {
let origin = origin().await;
let fixture = Fixture::new(config_for(origin.addr, true)).await;
let mut requests = tokio::task::JoinSet::new();
for _ in 0..12 {
let app = fixture.app.clone();
requests.spawn(async move { send(app, Method::GET, "/slow", &[], Body::empty()).await });
}
while let Some(result) = requests.join_next().await {
let response = result.unwrap();
assert_eq!(response.status, StatusCode::OK);
assert_eq!(response.body, "origin-default");
}
assert_eq!(origin.requests.load(Ordering::Relaxed), 1);
assert_eq!(fixture.app.metrics.completed.load(Ordering::Relaxed), 12);
assert_eq!(fixture.app.metrics.active.load(Ordering::Relaxed), 0);
fixture.stop().await;
}
}
+9 -14
View File
@@ -1,16 +1,4 @@
fn request_id_hash(sequence: u64) -> String {
use rand::TryRng;
use sha2::{Digest, Sha256};
let mut nonce = [0u8; 32];
let mut rng = rand::rngs::SysRng;
rng.try_fill_bytes(&mut nonce)
.expect("secure operating system random source unavailable");
let mut hash = Sha256::new();
hash.update(sequence.to_be_bytes());
hash.update(nonce);
format!("{:x}", hash.finalize())
}
include!("request_id.rs");
fn should_replace_error_page(response: &Response, minimal_welcome: bool) -> bool {
!minimal_welcome
@@ -43,7 +31,13 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
.unwrap_or_default()
.tls;
request.extensions_mut().insert(snapshot.clone());
let original_headers = request.headers().clone();
let original_headers = if snapshot.envelope.config.compression.enabled()
&& request.method() != Method::HEAD
{
crate::compression::request_headers(request.headers())
} else {
HeaderMap::new()
};
let method = request.method().clone();
let is_head = method == Method::HEAD;
let ws_attempt = request
@@ -242,6 +236,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
"x-rustproxy-request-id",
"x-rustproxy-revision",
"x-rust-proxy-backend",
"x-rustproxy-backend",
"x-rust-proxy-backend-time",
"x-rustproxy-backend-time",
"x-cache",
+2 -2
View File
@@ -426,8 +426,8 @@ mod tests {
let input = include_str!("../admin/web/style.css");
let result = css::minify(input.as_bytes()).expect("minify panel stylesheet");
let output = std::str::from_utf8(&result).expect("UTF-8 CSS");
assert!(output.contains("html:is([data-corner-style=\"square\"],[data-corner-style=\"subtle\"],[data-corner-style=\"rounded\"],[data-corner-style=\"extra\"]) :is("), "lost descendant pseudo-class combinator");
assert!(output.contains("html:is([data-corner-style=square],[data-corner-style=subtle],[data-corner-style=rounded],[data-corner-style=extra]) :is("), "lost descendant pseudo-class combinator");
assert!(output.contains(".card,.stat,dialog,.login-card,"), "lost card selector list");
assert!(output.contains("border-radius:var(--rp-radius-surface)!important"), "lost radius declaration");
assert!(output.contains("border-radius:var(--rp-radius-surface) !important"), "lost radius declaration");
}
}
+53 -23
View File
@@ -1,7 +1,32 @@
use super::{CompiledService, SymlinkPolicy};
use std::{fs::File, io, path::Path};
use std::{
fs::File,
io,
path::{Path, PathBuf},
};
#[cfg(unix)]
use std::ffi::CString;
use std::{ffi::CString, sync::Arc};
/// Only filesystem state crosses into the blocking pool. Cloning an entire
/// CompiledService here would also copy MIME, rewrite, PHP and security rules
/// for every file or candidate opened during a request.
pub(super) struct FilesystemRoot {
root: PathBuf,
symlink_policy: SymlinkPolicy,
#[cfg(unix)]
root_handle: Arc<File>,
}
impl FilesystemRoot {
pub(super) fn for_service(service: &CompiledService) -> Self {
Self {
root: service.root.clone(),
symlink_policy: service.symlink_policy,
#[cfg(unix)]
root_handle: service.root_handle.clone(),
}
}
}
#[derive(Debug)]
pub(super) struct Opened {
@@ -54,7 +79,7 @@ pub(super) fn path_components(path: &str) -> io::Result<Vec<String>> {
}
#[cfg(unix)]
pub(super) fn secure_open_sync(service: &CompiledService, components: &[String]) -> io::Result<Opened> {
pub(super) fn secure_open_sync(root: &FilesystemRoot, components: &[String]) -> io::Result<Opened> {
use std::os::unix::io::{AsRawFd, FromRawFd};
if components.is_empty() {
@@ -66,7 +91,7 @@ pub(super) fn secure_open_sync(service: &CompiledService, components: &[String])
let dot = cstring_component(".")?;
let fd = unsafe {
libc::openat(
service.root_handle.as_raw_fd(),
root.root_handle.as_raw_fd(),
dot.as_ptr(),
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW,
)
@@ -78,7 +103,10 @@ pub(super) fn secure_open_sync(service: &CompiledService, components: &[String])
let metadata = current.metadata()?;
return Ok(Opened { file: current, metadata });
}
let mut current = service.root_handle.try_clone()?;
// Borrow the already-owned root descriptor for the first openat. Opening a
// child does not change its directory offset, so a dup/close pair buys no
// isolation. Empty paths still open "." above for independent readdir state.
let mut current: Option<File> = None;
for (index, component) in components.iter().enumerate() {
let name = cstring_component(component)?;
let intermediate = index + 1 != components.len();
@@ -86,7 +114,7 @@ pub(super) fn secure_open_sync(service: &CompiledService, components: &[String])
// explicit Linux-only opt-in: the opened descriptor is checked through
// /proc/self/fd after each resolution step before it can be used further.
let mut flags = libc::O_RDONLY | libc::O_CLOEXEC;
if service.symlink_policy == SymlinkPolicy::Deny {
if root.symlink_policy == SymlinkPolicy::Deny {
flags |= libc::O_NOFOLLOW;
}
if intermediate {
@@ -95,13 +123,14 @@ pub(super) fn secure_open_sync(service: &CompiledService, components: &[String])
// Avoid blocking on special files. Only regular files/directories are served.
flags |= libc::O_NONBLOCK;
}
let fd = unsafe { libc::openat(current.as_raw_fd(), name.as_ptr(), flags) };
let parent = current.as_ref().unwrap_or(root.root_handle.as_ref());
let fd = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags) };
if fd < 0 {
return Err(io::Error::last_os_error());
}
if service.symlink_policy == SymlinkPolicy::InsideRoot {
if root.symlink_policy == SymlinkPolicy::InsideRoot {
#[cfg(target_os = "linux")]
verify_linux_fd_beneath_root(service, fd)?;
verify_linux_fd_beneath_root(root, fd)?;
#[cfg(not(target_os = "linux"))]
{
unsafe { libc::close(fd) };
@@ -111,8 +140,9 @@ pub(super) fn secure_open_sync(service: &CompiledService, components: &[String])
));
}
}
current = unsafe { File::from_raw_fd(fd) };
current = Some(unsafe { File::from_raw_fd(fd) });
}
let current = current.expect("nonempty path opened its final component");
let metadata = current.metadata()?;
if !metadata.is_file() && !metadata.is_dir() {
return Err(io::Error::new(
@@ -124,7 +154,7 @@ pub(super) fn secure_open_sync(service: &CompiledService, components: &[String])
}
#[cfg(target_os = "linux")]
fn verify_linux_fd_beneath_root(service: &CompiledService, fd: libc::c_int) -> io::Result<()> {
fn verify_linux_fd_beneath_root(root: &FilesystemRoot, fd: libc::c_int) -> io::Result<()> {
let target = match std::fs::read_link(format!("/proc/self/fd/{fd}")) {
Ok(target) => target,
Err(error) => {
@@ -132,7 +162,7 @@ fn verify_linux_fd_beneath_root(service: &CompiledService, fd: libc::c_int) -> i
return Err(error);
}
};
if target.starts_with(&service.root) {
if target.starts_with(&root.root) {
Ok(())
} else {
unsafe { libc::close(fd) };
@@ -144,13 +174,13 @@ fn verify_linux_fd_beneath_root(service: &CompiledService, fd: libc::c_int) -> i
}
#[cfg(not(unix))]
pub(super) fn secure_open_sync(service: &CompiledService, components: &[String]) -> io::Result<Opened> {
let mut candidate = service.root.clone();
pub(super) fn secure_open_sync(root: &FilesystemRoot, components: &[String]) -> io::Result<Opened> {
let mut candidate = root.root.clone();
for component in components {
candidate.push(component);
}
let canonical = std::fs::canonicalize(&candidate)?;
if !canonical.starts_with(&service.root) {
if !canonical.starts_with(&root.root) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"path escaped document root",
@@ -169,13 +199,13 @@ pub(super) fn secure_open_sync(service: &CompiledService, components: &[String])
#[cfg(unix)]
pub(super) fn list_directory_sync(
service: &CompiledService,
root: &FilesystemRoot,
components: &[String],
) -> io::Result<Vec<DirectoryEntry>> {
use std::ffi::CStr;
use std::os::unix::io::IntoRawFd;
let opened = secure_open_sync(service, components)?;
let opened = secure_open_sync(root, components)?;
if !opened.metadata.is_dir() {
return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory"));
}
@@ -206,7 +236,7 @@ pub(super) fn list_directory_sync(
// Re-open through the root fd using the configured symlink policy. The
// default omits every symlink; Linux inside_root may include a target only
// after the opened descriptor has been proven to remain below the root.
let Ok(child_opened) = secure_open_sync(service, &child) else {
let Ok(child_opened) = secure_open_sync(root, &child) else {
continue;
};
entries.push(DirectoryEntry {
@@ -226,19 +256,19 @@ pub(super) fn list_directory_sync(
#[cfg(not(unix))]
pub(super) fn list_directory_sync(
service: &CompiledService,
root: &FilesystemRoot,
components: &[String],
) -> io::Result<Vec<DirectoryEntry>> {
let opened = secure_open_sync(service, components)?;
let opened = secure_open_sync(root, components)?;
if !opened.metadata.is_dir() {
return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory"));
}
let mut path = service.root.clone();
let mut path = root.root.clone();
for component in components {
path.push(component);
}
let canonical = std::fs::canonicalize(path)?;
if !canonical.starts_with(&service.root) {
if !canonical.starts_with(&root.root) {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "path escaped document root"));
}
let mut entries = Vec::new();
@@ -249,7 +279,7 @@ pub(super) fn list_directory_sync(
};
let mut child = components.to_vec();
child.push(name.clone());
let Ok(child_opened) = secure_open_sync(service, &child) else {
let Ok(child_opened) = secure_open_sync(root, &child) else {
continue;
};
entries.push(DirectoryEntry {
+3
View File
@@ -1,3 +1,6 @@
include!("htaccess/model.rs");
include!("htaccess/parse.rs");
include!("htaccess/rewrite.rs");
#[cfg(test)]
mod tests;
+4 -1
View File
@@ -228,7 +228,10 @@ fn supported_response_header(name: &HeaderName) -> bool {
fn parse_duration(value: &str) -> Result<Duration> {
let value = value.trim().trim_matches('"').trim();
if let Some(seconds) = value.strip_prefix('A').or_else(|| value.strip_prefix('a')) {
// The long form also starts with 'a'; only numeric suffixes use A<seconds>.
if let Some(seconds) = value.strip_prefix('A').or_else(|| value.strip_prefix('a'))
.filter(|seconds| !seconds.is_empty() && seconds.bytes().all(|byte| byte.is_ascii_digit()))
{
let seconds: u64 = seconds.parse().context("invalid Expires duration")?;
return Ok(Duration::from_secs(seconds));
}
+48
View File
@@ -0,0 +1,48 @@
use super::parse_duration;
use std::time::Duration;
#[test]
fn expires_duration_accepts_compact_and_access_based_forms() {
for (value, seconds) in [
("A3600", 3600),
("a60", 60),
("A0", 0),
("\"A3600\"", 3600),
("access plus 1 hour", 3600),
("\"access plus 1 hour\"", 3600),
(" ACCESS PLUS 2 HOURS ", 7200),
("access plus 3 seconds", 3),
("access plus 2 minutes", 120),
("access plus 1 day", 86_400),
("access plus 1 week", 604_800),
("access plus 1 month", 2_592_000),
("access plus 1 year", 31_536_000),
] {
assert_eq!(
parse_duration(value).unwrap(),
Duration::from_secs(seconds),
"{value}"
);
}
}
#[test]
fn expires_duration_keeps_invalid_and_unsupported_forms_rejected() {
for value in [
"",
"A",
"A1x",
"A-1",
"A18446744073709551616",
"M3600",
"access",
"access plus",
"access plus two hours",
"access plus -1 hour",
"access minus 1 hour",
"modification plus 1 hour",
"access plus 1 fortnight",
] {
assert!(parse_duration(value).is_err(), "accepted {value:?}");
}
}
+1
View File
@@ -1,2 +1,3 @@
include!("response/headers.rs");
mod file_body;
include!("response/file.rs");
+19 -5
View File
@@ -1,3 +1,12 @@
// Tokio's file reads dispatch blocking work. Larger, bounded chunks avoid one
// dispatch/copy/body-accounting pass per 4 KiB for ordinary static responses.
// Size small files and ranges to their payload so they do not reserve 64 KiB.
const FILE_STREAM_CAPACITY: usize = 64 * 1024;
pub(super) fn file_stream_capacity(body_length: u64) -> usize {
body_length.clamp(1, FILE_STREAM_CAPACITY as u64) as usize
}
pub(super) async fn file_response(
service: &CompiledService,
opened: Opened,
@@ -96,7 +105,7 @@ async fn file_response_inner(
let mut response = Response::new(Body::empty());
*response.status_mut() = status;
let boundary = multipart.then(|| format!("rustproxy-{:016x}", rand::random::<u64>()));
if let Some(boundary) = &boundary {
let response_length = if let Some(boundary) = &boundary {
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_str(&format!("multipart/byteranges; boundary={boundary}")).unwrap(),
@@ -118,13 +127,16 @@ async fn file_response_inner(
CONTENT_LENGTH,
HeaderValue::from_str(&total.to_string()).unwrap(),
);
total
} else {
response.headers_mut().insert(CONTENT_TYPE, mime.clone());
response.headers_mut().insert(
CONTENT_LENGTH,
HeaderValue::from_str(&body_length.to_string()).unwrap(),
);
}
body_length
};
let stream_capacity = file_stream_capacity(response_length);
response
.headers_mut()
.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
@@ -184,7 +196,9 @@ async fn file_response_inner(
.write_all(format!("--{boundary}--\r\n").as_bytes())
.await;
});
*response.body_mut() = Body::from_stream(ReaderStream::new(reader));
*response.body_mut() = Body::new(file_body::FileBody::new(
ReaderStream::with_capacity(reader, stream_capacity), response_length,
));
return response;
}
if start != 0 {
@@ -195,8 +209,8 @@ async fn file_response_inner(
);
}
}
let stream = ReaderStream::new(file.take(body_length));
*response.body_mut() = Body::from_stream(stream);
let stream = ReaderStream::with_capacity(file.take(body_length), stream_capacity);
*response.body_mut() = Body::new(file_body::FileBody::new(stream, body_length));
response
}
+77
View File
@@ -0,0 +1,77 @@
//! Exact-length, data-only local file delivery.
//!
//! Hyper may stop polling after Content-Length bytes. ReaderStream alone only
//! learns EOF on the next read, leaving cache fills and body accounting open.
//! These streams come from a bounded local file read (or our multipart writer),
//! so their validated byte count is also a trustworthy end-of-stream boundary.
//! Never use this wrapper for arbitrary upstream HTTP bodies with trailers.
use bytes::Bytes;
use futures_util::Stream;
use http_body::{Body, Frame, SizeHint};
use std::{io, pin::Pin, task::{Context, Poll}};
pub(super) struct FileBody<S> {
stream: Option<S>,
remaining: u64,
}
impl<S> FileBody<S> {
pub(super) fn new(stream: S, length: u64) -> Self {
Self { stream: (length != 0).then_some(stream), remaining: length }
}
}
impl<S> Body for FileBody<S>
where
S: Stream<Item = Result<Bytes, io::Error>> + Unpin,
{
type Data = Bytes;
type Error = io::Error;
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Bytes>, io::Error>>> {
let this = self.get_mut();
let Some(stream) = this.stream.as_mut() else { return Poll::Ready(None) };
match Pin::new(stream).poll_next(cx) {
Poll::Ready(Some(Ok(data))) => {
if data.len() as u64 > this.remaining {
this.stream = None;
this.remaining = 0;
return Poll::Ready(Some(Err(io::Error::new(
io::ErrorKind::InvalidData, "local file body exceeded its declared length",
))));
}
this.remaining -= data.len() as u64;
if this.remaining == 0 {
// Drop the file/duplex reader now. No additional EOF read
// or caller poll is needed to release it or finish a fill.
this.stream = None;
}
Poll::Ready(Some(Ok(Frame::data(data))))
}
Poll::Ready(None) => {
this.stream = None;
this.remaining = 0;
Poll::Ready(Some(Err(io::Error::new(
io::ErrorKind::UnexpectedEof, "local file body ended before its declared length",
))))
}
Poll::Ready(Some(Err(error))) => {
this.stream = None;
this.remaining = 0;
Poll::Ready(Some(Err(error)))
}
Poll::Pending => Poll::Pending,
}
}
fn is_end_stream(&self) -> bool { self.stream.is_none() }
fn size_hint(&self) -> SizeHint { SizeHint::with_exact(self.remaining) }
}
#[cfg(test)]
mod tests;
+120
View File
@@ -0,0 +1,120 @@
use super::*;
use crate::cache::{self, Cache, Settings};
use axum::{body::Body as AxumBody, response::Response};
use futures_util::stream;
use http::{HeaderMap, HeaderValue, StatusCode};
use http_body_util::BodyExt;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio_util::io::ReaderStream;
#[tokio::test]
async fn final_file_frame_is_terminal_without_an_extra_eof_poll() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("payload");
tokio::fs::write(&path, b"abcdef").await.unwrap();
let file = tokio::fs::File::open(path).await.unwrap();
let mut body = FileBody::new(ReaderStream::with_capacity(file.take(6), 3), 6);
assert_eq!(body.size_hint().exact(), Some(6));
assert!(!body.is_end_stream());
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abc");
assert_eq!(body.size_hint().exact(), Some(3));
assert!(!body.is_end_stream());
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "def");
assert!(body.is_end_stream(), "the final data frame must end the body before another poll");
assert_eq!(body.size_hint().exact(), Some(0));
assert!(body.stream.is_none(), "the reader/file is released at the length boundary");
assert!(body.frame().await.is_none());
}
#[tokio::test]
async fn short_file_and_local_stream_errors_never_become_successful_eof() {
let mut short = FileBody::new(stream::iter([Ok(Bytes::from_static(b"abc"))]), 6);
assert_eq!(short.frame().await.unwrap().unwrap().into_data().unwrap(), "abc");
assert_eq!(short.frame().await.unwrap().unwrap_err().kind(), io::ErrorKind::UnexpectedEof);
assert!(short.is_end_stream());
assert!(short.frame().await.is_none());
let mut failed = FileBody::new(stream::iter([Err(io::Error::other("read failed"))]), 6);
assert_eq!(failed.frame().await.unwrap().unwrap_err().to_string(), "read failed");
assert!(failed.is_end_stream());
let mut oversized = FileBody::new(stream::iter([Ok(Bytes::from_static(b"abcdef"))]), 3);
assert_eq!(oversized.frame().await.unwrap().unwrap_err().kind(), io::ErrorKind::InvalidData);
assert!(oversized.is_end_stream());
}
#[tokio::test]
async fn file_shrunk_after_open_returns_unexpected_eof() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("payload");
tokio::fs::write(&path, b"abcdef").await.unwrap();
let file = tokio::fs::File::open(&path).await.unwrap();
let expected = file.metadata().await.unwrap().len();
std::fs::OpenOptions::new().write(true).open(path).unwrap().set_len(3).unwrap();
let mut body = FileBody::new(ReaderStream::with_capacity(file.take(expected), 3), expected);
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abc");
assert_eq!(body.frame().await.unwrap().unwrap_err().kind(), io::ErrorKind::UnexpectedEof);
assert!(body.is_end_stream());
assert!(body.stream.is_none());
}
#[tokio::test]
async fn zero_length_file_is_terminal_without_polling_the_reader() {
let mut body = FileBody::new(stream::pending::<Result<Bytes, io::Error>>(), 0);
assert!(body.is_end_stream());
assert_eq!(body.size_hint().exact(), Some(0));
assert!(body.frame().await.is_none());
}
fn settings() -> Settings { Settings { enabled: true, ..Default::default() } }
fn cacheable(body: AxumBody, length: u64) -> Response {
let mut response = Response::new(body);
response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=600"));
response.headers_mut().insert("content-length", length.to_string().parse().unwrap());
response
}
#[tokio::test]
async fn fully_delivered_file_fills_cache_before_consumer_drops_without_polling_none() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("payload");
tokio::fs::write(&path, b"abcdef").await.unwrap();
let file = tokio::fs::File::open(path).await.unwrap();
let body = FileBody::new(ReaderStream::with_capacity(file.take(6), 6), 6);
let cache = Cache::new(1);
let settings = settings();
let ticket = cache.ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new()).unwrap();
let response = cacheable(AxumBody::new(body), 6);
let plan = cache::plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
let mut body = cache.wrap(response, ticket.clone(), plan, &settings).into_body();
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abcdef");
// Reproduce HTTP/1 Content-Length completion: do not ask for another frame.
drop(body);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().rejected_fills, 0);
assert_eq!(cache.view().fills_active, 0);
let hit = cache.get(&ticket).unwrap();
assert_eq!(hit.response.into_body().collect().await.unwrap().to_bytes(), "abcdef");
}
#[tokio::test]
async fn incomplete_or_cancelled_file_cannot_fill_cache() {
for short in [false, true] {
let cache = Cache::new(1);
let settings = settings();
let ticket = cache.ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new()).unwrap();
let chunks = if short { vec![Ok(Bytes::from_static(b"abc"))] }
else { vec![Ok(Bytes::from_static(b"abc")), Ok(Bytes::from_static(b"def"))] };
let response = cacheable(AxumBody::new(FileBody::new(stream::iter(chunks), 6)), 6);
let plan = cache::plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
let mut body = cache.wrap(response, ticket.clone(), plan, &settings).into_body();
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abc");
if short { assert!(body.frame().await.unwrap().is_err()); }
drop(body);
assert!(cache.get(&ticket).is_none());
assert_eq!(cache.view().rejected_fills, 1);
assert_eq!(cache.view().fills_active, 0);
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
use super::{
filesystem::{path_components, secure_open_sync, Opened},
filesystem::{path_components, secure_open_sync, FilesystemRoot, Opened},
response::accepts_content_encoding,
CompiledService,
};
@@ -9,8 +9,8 @@ use http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode};
use std::io;
pub(super) async fn open_components(service: &CompiledService, components: Vec<String>) -> io::Result<Opened> {
let service = service.clone();
tokio::task::spawn_blocking(move || secure_open_sync(&service, &components))
let root = FilesystemRoot::for_service(service);
tokio::task::spawn_blocking(move || secure_open_sync(&root, &components))
.await
.map_err(|_| io::Error::other("Web Server filesystem task failed"))?
}
+3 -3
View File
@@ -2,7 +2,7 @@ use super::{
fastcgi::{
method_not_allowed_response, php_for_path, php_response, protected_script_without_handler,
},
filesystem::{list_directory_sync, path_components},
filesystem::{list_directory_sync, path_components, FilesystemRoot},
htaccess,
response::{
add_service_headers, builtin_error_response, directory_listing, directory_redirect,
@@ -345,10 +345,10 @@ async fn serve_inner(
) {
return builtin_error_response(StatusCode::FORBIDDEN, &method);
}
let service_for_list = service.clone();
let root = FilesystemRoot::for_service(service);
let components_for_list = components.clone();
let mut entries = match tokio::task::spawn_blocking(move || {
list_directory_sync(&service_for_list, &components_for_list)
list_directory_sync(&root, &components_for_list)
})
.await
{
+456
View File
@@ -1769,3 +1769,459 @@ async fn php_metrics_are_separate_from_static_and_record_capacity() {
assert_eq!(php.max_concurrent, 2);
assert_eq!(php.permits.available_permits(), 2);
}
#[tokio::test]
async fn static_streaming_keeps_large_payloads_intact_in_bounded_frames() {
let tmp = TempDir::new().unwrap();
let service = service(tmp.path(), false);
let mut accounted = 0u64;
for length in [
0usize,
1,
1040,
16 * 1024 + 3,
64 * 1024,
64 * 1024 + 1,
1024 * 1024,
] {
let expected: Vec<u8> = (0..length).map(|index| (index % 251) as u8).collect();
std::fs::write(tmp.path().join("asset.bin"), &expected).unwrap();
let response =
serve_request(&service, &request(Method::GET, "/asset.bin"), "/asset.bin").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[CONTENT_LENGTH], length.to_string());
let mut body = response.into_body();
let mut received = Vec::new();
let mut frames = 0usize;
while let Some(frame) = body.frame().await {
let data = frame.unwrap().into_data().unwrap();
assert!(
data.len() <= 64 * 1024,
"static frame exceeded the memory bound"
);
received.extend_from_slice(&data);
frames += 1;
}
assert_eq!(received, expected);
if length >= 64 * 1024 {
// A normal regular file must not regress to one dispatched read and
// body-accounting pass for every 4 KiB of its representation.
assert!(frames < length.div_ceil(4 * 1024));
}
accounted += length as u64;
assert_eq!(service.web_metrics.view()["response_bytes"], accounted);
}
}
#[tokio::test]
async fn large_static_ranges_keep_offsets_validators_and_head_semantics() {
let tmp = TempDir::new().unwrap();
let expected: Vec<u8> = (0..256 * 1024 + 31)
.map(|index| (index % 251) as u8)
.collect();
std::fs::write(tmp.path().join("asset.bin"), &expected).unwrap();
let service = service(tmp.path(), false);
let initial = serve_request(&service, &request(Method::HEAD, "/asset.bin"), "/asset.bin").await;
let etag = initial.headers()[ETAG].clone();
assert!(initial
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
for (range, start, end) in [
("bytes=0-0", 0, 1),
("bytes=65530-131089", 65530, 131090),
("bytes=-65543", expected.len() - 65543, expected.len()),
("bytes=131065-", 131065, expected.len()),
] {
for method in [Method::GET, Method::HEAD] {
let mut requested = request(method.clone(), "/asset.bin");
requested
.headers_mut()
.insert(RANGE, range.parse().unwrap());
requested.headers_mut().insert(IF_RANGE, etag.clone());
let response = serve_request(&service, &requested, "/asset.bin").await;
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
response.headers()[CONTENT_LENGTH],
(end - start).to_string()
);
assert_eq!(
response.headers()[CONTENT_RANGE],
format!("bytes {start}-{}/{}", end - 1, expected.len())
);
assert_eq!(response.headers()[ETAG], etag);
let data = response.into_body().collect().await.unwrap().to_bytes();
if method == Method::HEAD {
assert!(data.is_empty());
} else {
assert_eq!(&data[..], &expected[start..end]);
}
}
}
let mut changed = request(Method::GET, "/asset.bin");
changed
.headers_mut()
.insert(RANGE, "bytes=65530-131089".parse().unwrap());
changed
.headers_mut()
.insert(IF_RANGE, "\"changed\"".parse().unwrap());
let response = serve_request(&service, &changed, "/asset.bin").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
expected
);
let mut conditional = request(Method::GET, "/asset.bin");
conditional
.headers_mut()
.insert(RANGE, "bytes=65530-131089".parse().unwrap());
conditional.headers_mut().insert(IF_NONE_MATCH, etag);
let response = serve_request(&service, &conditional, "/asset.bin").await;
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
assert!(response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
let mut invalid = request(Method::GET, "/asset.bin");
invalid
.headers_mut()
.insert(RANGE, "bytes=999999-".parse().unwrap());
let response = serve_request(&service, &invalid, "/asset.bin").await;
assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(
response.headers()[CONTENT_RANGE],
format!("bytes */{}", expected.len())
);
assert!(response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
}
#[tokio::test]
async fn large_precompressed_and_multipart_representations_remain_exact() {
use std::io::Write;
let tmp = TempDir::new().unwrap();
let plain: Vec<u8> = (0..512 * 1024)
.map(|index| b'a' + (index % 26) as u8)
.collect();
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::none());
encoder.write_all(&plain).unwrap();
let compressed = encoder.finish().unwrap();
std::fs::write(tmp.path().join("asset.css"), &plain).unwrap();
std::fs::write(tmp.path().join("asset.css.gz"), &compressed).unwrap();
let service = Service {
id: "large-compressed".into(),
name: "Large compressed".into(),
document_root: tmp.path().to_path_buf(),
precompressed: Precompressed {
enabled: true,
brotli: false,
gzip: true,
},
..Default::default()
}
.compile()
.unwrap();
let mut requested = request(Method::GET, "/asset.css");
requested
.headers_mut()
.insert(http::header::ACCEPT_ENCODING, "gzip".parse().unwrap());
let response = serve_request(&service, &requested, "/asset.css").await;
assert_eq!(response.headers()[CONTENT_ENCODING], "gzip");
assert_eq!(response.headers()[CONTENT_TYPE], "text/css; charset=utf-8");
assert_eq!(response.headers()[VARY], "Accept-Encoding");
assert_eq!(
response.headers()[CONTENT_LENGTH],
compressed.len().to_string()
);
let etag = response.headers()[ETAG].clone();
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
compressed
);
*requested.method_mut() = Method::HEAD;
let response = serve_request(&service, &requested, "/asset.css").await;
assert_eq!(response.headers()[CONTENT_ENCODING], "gzip");
assert_eq!(
response.headers()[CONTENT_LENGTH],
compressed.len().to_string()
);
assert!(response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
*requested.method_mut() = Method::GET;
requested.headers_mut().insert(IF_NONE_MATCH, etag);
let response = serve_request(&service, &requested, "/asset.css").await;
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
assert_eq!(response.headers()[CONTENT_ENCODING], "gzip");
assert!(response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
requested.headers_mut().remove(IF_NONE_MATCH);
requested
.headers_mut()
.insert(RANGE, "bytes=65530-131089".parse().unwrap());
let response = serve_request(&service, &requested, "/asset.css").await;
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(response.headers()[CONTENT_ENCODING], "gzip");
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
compressed[65530..131090]
);
requested
.headers_mut()
.insert(RANGE, "bytes=65520-131124,262100-458800".parse().unwrap());
let response = serve_request(&service, &requested, "/asset.css").await;
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert!(!response.headers().contains_key(CONTENT_ENCODING));
let content_type = response.headers()[CONTENT_TYPE].to_str().unwrap();
let boundary = content_type
.strip_prefix("multipart/byteranges; boundary=")
.unwrap();
let mut expected = Vec::new();
for (start, end) in [(65520, 131124), (262100, 458800)] {
write!(&mut expected, "--{boundary}\r\nContent-Type: text/css; charset=utf-8\r\nContent-Range: bytes {start}-{end}/{}\r\n\r\n", plain.len()).unwrap();
expected.extend_from_slice(&plain[start..=end]);
expected.extend_from_slice(b"\r\n");
}
write!(&mut expected, "--{boundary}--\r\n").unwrap();
assert_eq!(
response.headers()[CONTENT_LENGTH],
expected.len().to_string()
);
let multipart_length = expected.len();
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
expected
);
*requested.method_mut() = Method::HEAD;
let response = serve_request(&service, &requested, "/asset.css").await;
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert!(!response.headers().contains_key(CONTENT_ENCODING));
assert_eq!(
response.headers()[CONTENT_LENGTH],
multipart_length.to_string()
);
assert!(response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
}
#[cfg(unix)]
#[test]
fn filesystem_context_owns_root_and_preserves_independent_directory_offsets() {
use std::io::Read;
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("nested")).unwrap();
std::fs::write(tmp.path().join("top.txt"), b"top").unwrap();
std::fs::write(tmp.path().join("nested/file.txt"), b"nested").unwrap();
let service = service(tmp.path(), true);
let root = filesystem::FilesystemRoot::for_service(&service);
drop(service);
for _ in 0..3 {
let components = filesystem::path_components("/nested/file.txt").unwrap();
let mut opened = filesystem::secure_open_sync(&root, &components).unwrap();
let mut contents = String::new();
opened.file.read_to_string(&mut contents).unwrap();
assert_eq!(contents, "nested");
let entries = filesystem::list_directory_sync(&root, &[]).unwrap();
assert_eq!(
entries
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>(),
["nested", "top.txt"]
);
let entries = filesystem::list_directory_sync(&root, &["nested".into()]).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "file.txt");
}
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn filesystem_context_keeps_symlink_policy_on_intermediate_components() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("safe")).unwrap();
std::fs::write(tmp.path().join("safe/asset.txt"), b"safe").unwrap();
std::fs::write(outside.path().join("asset.txt"), b"secret").unwrap();
symlink(tmp.path().join("safe"), tmp.path().join("inside-link")).unwrap();
symlink(outside.path(), tmp.path().join("outside-link")).unwrap();
for policy in [SymlinkPolicy::Deny, SymlinkPolicy::InsideRoot] {
let service = Service {
id: "links".into(),
name: "Links".into(),
document_root: tmp.path().to_path_buf(),
directory_listing: true,
symlink_policy: policy,
..Default::default()
}
.compile()
.unwrap();
let response = serve_request(
&service,
&request(Method::GET, "/inside-link/asset.txt"),
"/inside-link/asset.txt",
)
.await;
if policy == SymlinkPolicy::InsideRoot {
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
"safe"
);
} else {
assert_ne!(response.status(), StatusCode::OK);
}
let response = serve_request(
&service,
&request(Method::GET, "/outside-link/asset.txt"),
"/outside-link/asset.txt",
)
.await;
assert_ne!(response.status(), StatusCode::OK);
assert!(!response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.windows(6)
.any(|part| part == b"secret"));
for _ in 0..3 {
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let listing = std::str::from_utf8(&body).unwrap();
assert!(listing.contains("safe/"));
assert!(!listing.contains("outside-link"));
assert_eq!(
listing.contains("inside-link"),
policy == SymlinkPolicy::InsideRoot
);
}
}
}
#[cfg(unix)]
#[tokio::test]
async fn borrowed_root_descriptor_stays_anchored_after_root_path_replacement() {
let tmp = TempDir::new().unwrap();
let configured_root = tmp.path().join("site");
std::fs::create_dir_all(configured_root.join("nested")).unwrap();
std::fs::write(configured_root.join("nested/asset.txt"), b"original").unwrap();
let service = service(&configured_root, false);
std::fs::rename(&configured_root, tmp.path().join("moved-site")).unwrap();
std::fs::create_dir_all(configured_root.join("nested")).unwrap();
std::fs::write(
configured_root.join("nested/asset.txt"),
b"replacement-secret",
)
.unwrap();
for _ in 0..3 {
let response = serve_request(
&service,
&request(Method::GET, "/nested/asset.txt"),
"/nested/asset.txt",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
"original"
);
}
}
#[cfg(target_os = "linux")]
fn open_descriptors_for(path: &Path) -> usize {
std::fs::read_dir("/proc/self/fd")
.unwrap()
.filter_map(|entry| std::fs::read_link(entry.ok()?.path()).ok())
.filter(|target| target == path)
.count()
}
#[cfg(target_os = "linux")]
async fn wait_for_file_descriptors_to_close(path: &Path) {
tokio::time::timeout(Duration::from_secs(5), async {
while open_descriptors_for(path) != 0 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("cancelled static response retained its file descriptor");
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn cancelled_static_and_multipart_bodies_release_files_and_count_only_emitted_bytes() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("asset.bin");
std::fs::write(&path, vec![b'x'; 1024 * 1024]).unwrap();
let service = service(tmp.path(), false);
let unpolled = serve_request(&service, &request(Method::GET, "/asset.bin"), "/asset.bin").await;
drop(unpolled);
wait_for_file_descriptors_to_close(&path).await;
assert_eq!(service.web_metrics.view()["response_bytes"], 0);
let mut accounted = 0u64;
for range in [None, Some("bytes=0-400000,600000-1000000")] {
let mut requested = request(Method::GET, "/asset.bin");
if let Some(range) = range {
requested
.headers_mut()
.insert(RANGE, range.parse().unwrap());
}
let mut body = serve_request(&service, &requested, "/asset.bin")
.await
.into_body();
let first = body.frame().await.unwrap().unwrap().into_data().unwrap();
assert!(!first.is_empty());
assert!(first.len() <= 64 * 1024);
accounted += first.len() as u64;
assert_eq!(service.web_metrics.view()["response_bytes"], accounted);
// The multipart writer may fill its bounded duplex buffer in the
// background. Neither it nor a pending file read counts unsent bytes.
tokio::task::yield_now().await;
assert_eq!(service.web_metrics.view()["response_bytes"], accounted);
drop(body);
wait_for_file_descriptors_to_close(&path).await;
assert_eq!(service.web_metrics.view()["response_bytes"], accounted);
}
}