v0.33.8
This commit is contained in:
1 parent
6b937aaf85
commit
87c8393d86
79 files changed
+6727
-297
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Vendored
+3
-4
@@ -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 {
|
||||
|
||||
Vendored
+7
@@ -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 {
|
||||
|
||||
Vendored
+31
@@ -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();
|
||||
|
||||
Vendored
+9
-2
@@ -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() {
|
||||
|
||||
Vendored
+74
-13
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+17
-6
@@ -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(¤t_ticket(&cache, &one)).is_none());
|
||||
let survivor = cache.get(¤t_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(¤t_ticket(&cache, &three)).is_none());
|
||||
assert!(cache.get(¤t_ticket(&cache, &four)).is_none());
|
||||
assert!(cache.get(¤t_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(¤t_ticket(&cache, &tagged)).is_none());
|
||||
assert!(cache.get(¤t_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(¤t_ticket(&cache, &keyed)).is_none());
|
||||
assert!(cache.get(¤t_ticket(&cache, &two)).is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
include!("htaccess/model.rs");
|
||||
include!("htaccess/parse.rs");
|
||||
include!("htaccess/rewrite.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
include!("response/headers.rs");
|
||||
mod file_body;
|
||||
include!("response/file.rs");
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"))?
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user