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

No files matched your search

+456
View File
@@ -1769,3 +1769,459 @@ async fn php_metrics_are_separate_from_static_and_record_capacity() {
assert_eq!(php.max_concurrent, 2);
assert_eq!(php.permits.available_permits(), 2);
}
#[tokio::test]
async fn static_streaming_keeps_large_payloads_intact_in_bounded_frames() {
let tmp = TempDir::new().unwrap();
let service = service(tmp.path(), false);
let mut accounted = 0u64;
for length in [
0usize,
1,
1040,
16 * 1024 + 3,
64 * 1024,
64 * 1024 + 1,
1024 * 1024,
] {
let expected: Vec<u8> = (0..length).map(|index| (index % 251) as u8).collect();
std::fs::write(tmp.path().join("asset.bin"), &expected).unwrap();
let response =
serve_request(&service, &request(Method::GET, "/asset.bin"), "/asset.bin").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[CONTENT_LENGTH], length.to_string());
let mut body = response.into_body();
let mut received = Vec::new();
let mut frames = 0usize;
while let Some(frame) = body.frame().await {
let data = frame.unwrap().into_data().unwrap();
assert!(
data.len() <= 64 * 1024,
"static frame exceeded the memory bound"
);
received.extend_from_slice(&data);
frames += 1;
}
assert_eq!(received, expected);
if length >= 64 * 1024 {
// A normal regular file must not regress to one dispatched read and
// body-accounting pass for every 4 KiB of its representation.
assert!(frames < length.div_ceil(4 * 1024));
}
accounted += length as u64;
assert_eq!(service.web_metrics.view()["response_bytes"], accounted);
}
}
#[tokio::test]
async fn large_static_ranges_keep_offsets_validators_and_head_semantics() {
let tmp = TempDir::new().unwrap();
let expected: Vec<u8> = (0..256 * 1024 + 31)
.map(|index| (index % 251) as u8)
.collect();
std::fs::write(tmp.path().join("asset.bin"), &expected).unwrap();
let service = service(tmp.path(), false);
let initial = serve_request(&service, &request(Method::HEAD, "/asset.bin"), "/asset.bin").await;
let etag = initial.headers()[ETAG].clone();
assert!(initial
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
for (range, start, end) in [
("bytes=0-0", 0, 1),
("bytes=65530-131089", 65530, 131090),
("bytes=-65543", expected.len() - 65543, expected.len()),
("bytes=131065-", 131065, expected.len()),
] {
for method in [Method::GET, Method::HEAD] {
let mut requested = request(method.clone(), "/asset.bin");
requested
.headers_mut()
.insert(RANGE, range.parse().unwrap());
requested.headers_mut().insert(IF_RANGE, etag.clone());
let response = serve_request(&service, &requested, "/asset.bin").await;
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
response.headers()[CONTENT_LENGTH],
(end - start).to_string()
);
assert_eq!(
response.headers()[CONTENT_RANGE],
format!("bytes {start}-{}/{}", end - 1, expected.len())
);
assert_eq!(response.headers()[ETAG], etag);
let data = response.into_body().collect().await.unwrap().to_bytes();
if method == Method::HEAD {
assert!(data.is_empty());
} else {
assert_eq!(&data[..], &expected[start..end]);
}
}
}
let mut changed = request(Method::GET, "/asset.bin");
changed
.headers_mut()
.insert(RANGE, "bytes=65530-131089".parse().unwrap());
changed
.headers_mut()
.insert(IF_RANGE, "\"changed\"".parse().unwrap());
let response = serve_request(&service, &changed, "/asset.bin").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
expected
);
let mut conditional = request(Method::GET, "/asset.bin");
conditional
.headers_mut()
.insert(RANGE, "bytes=65530-131089".parse().unwrap());
conditional.headers_mut().insert(IF_NONE_MATCH, etag);
let response = serve_request(&service, &conditional, "/asset.bin").await;
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
assert!(response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
let mut invalid = request(Method::GET, "/asset.bin");
invalid
.headers_mut()
.insert(RANGE, "bytes=999999-".parse().unwrap());
let response = serve_request(&service, &invalid, "/asset.bin").await;
assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(
response.headers()[CONTENT_RANGE],
format!("bytes */{}", expected.len())
);
assert!(response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
}
#[tokio::test]
async fn large_precompressed_and_multipart_representations_remain_exact() {
use std::io::Write;
let tmp = TempDir::new().unwrap();
let plain: Vec<u8> = (0..512 * 1024)
.map(|index| b'a' + (index % 26) as u8)
.collect();
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::none());
encoder.write_all(&plain).unwrap();
let compressed = encoder.finish().unwrap();
std::fs::write(tmp.path().join("asset.css"), &plain).unwrap();
std::fs::write(tmp.path().join("asset.css.gz"), &compressed).unwrap();
let service = Service {
id: "large-compressed".into(),
name: "Large compressed".into(),
document_root: tmp.path().to_path_buf(),
precompressed: Precompressed {
enabled: true,
brotli: false,
gzip: true,
},
..Default::default()
}
.compile()
.unwrap();
let mut requested = request(Method::GET, "/asset.css");
requested
.headers_mut()
.insert(http::header::ACCEPT_ENCODING, "gzip".parse().unwrap());
let response = serve_request(&service, &requested, "/asset.css").await;
assert_eq!(response.headers()[CONTENT_ENCODING], "gzip");
assert_eq!(response.headers()[CONTENT_TYPE], "text/css; charset=utf-8");
assert_eq!(response.headers()[VARY], "Accept-Encoding");
assert_eq!(
response.headers()[CONTENT_LENGTH],
compressed.len().to_string()
);
let etag = response.headers()[ETAG].clone();
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
compressed
);
*requested.method_mut() = Method::HEAD;
let response = serve_request(&service, &requested, "/asset.css").await;
assert_eq!(response.headers()[CONTENT_ENCODING], "gzip");
assert_eq!(
response.headers()[CONTENT_LENGTH],
compressed.len().to_string()
);
assert!(response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
*requested.method_mut() = Method::GET;
requested.headers_mut().insert(IF_NONE_MATCH, etag);
let response = serve_request(&service, &requested, "/asset.css").await;
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
assert_eq!(response.headers()[CONTENT_ENCODING], "gzip");
assert!(response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
requested.headers_mut().remove(IF_NONE_MATCH);
requested
.headers_mut()
.insert(RANGE, "bytes=65530-131089".parse().unwrap());
let response = serve_request(&service, &requested, "/asset.css").await;
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(response.headers()[CONTENT_ENCODING], "gzip");
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
compressed[65530..131090]
);
requested
.headers_mut()
.insert(RANGE, "bytes=65520-131124,262100-458800".parse().unwrap());
let response = serve_request(&service, &requested, "/asset.css").await;
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert!(!response.headers().contains_key(CONTENT_ENCODING));
let content_type = response.headers()[CONTENT_TYPE].to_str().unwrap();
let boundary = content_type
.strip_prefix("multipart/byteranges; boundary=")
.unwrap();
let mut expected = Vec::new();
for (start, end) in [(65520, 131124), (262100, 458800)] {
write!(&mut expected, "--{boundary}\r\nContent-Type: text/css; charset=utf-8\r\nContent-Range: bytes {start}-{end}/{}\r\n\r\n", plain.len()).unwrap();
expected.extend_from_slice(&plain[start..=end]);
expected.extend_from_slice(b"\r\n");
}
write!(&mut expected, "--{boundary}--\r\n").unwrap();
assert_eq!(
response.headers()[CONTENT_LENGTH],
expected.len().to_string()
);
let multipart_length = expected.len();
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
expected
);
*requested.method_mut() = Method::HEAD;
let response = serve_request(&service, &requested, "/asset.css").await;
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert!(!response.headers().contains_key(CONTENT_ENCODING));
assert_eq!(
response.headers()[CONTENT_LENGTH],
multipart_length.to_string()
);
assert!(response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
}
#[cfg(unix)]
#[test]
fn filesystem_context_owns_root_and_preserves_independent_directory_offsets() {
use std::io::Read;
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("nested")).unwrap();
std::fs::write(tmp.path().join("top.txt"), b"top").unwrap();
std::fs::write(tmp.path().join("nested/file.txt"), b"nested").unwrap();
let service = service(tmp.path(), true);
let root = filesystem::FilesystemRoot::for_service(&service);
drop(service);
for _ in 0..3 {
let components = filesystem::path_components("/nested/file.txt").unwrap();
let mut opened = filesystem::secure_open_sync(&root, &components).unwrap();
let mut contents = String::new();
opened.file.read_to_string(&mut contents).unwrap();
assert_eq!(contents, "nested");
let entries = filesystem::list_directory_sync(&root, &[]).unwrap();
assert_eq!(
entries
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>(),
["nested", "top.txt"]
);
let entries = filesystem::list_directory_sync(&root, &["nested".into()]).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "file.txt");
}
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn filesystem_context_keeps_symlink_policy_on_intermediate_components() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("safe")).unwrap();
std::fs::write(tmp.path().join("safe/asset.txt"), b"safe").unwrap();
std::fs::write(outside.path().join("asset.txt"), b"secret").unwrap();
symlink(tmp.path().join("safe"), tmp.path().join("inside-link")).unwrap();
symlink(outside.path(), tmp.path().join("outside-link")).unwrap();
for policy in [SymlinkPolicy::Deny, SymlinkPolicy::InsideRoot] {
let service = Service {
id: "links".into(),
name: "Links".into(),
document_root: tmp.path().to_path_buf(),
directory_listing: true,
symlink_policy: policy,
..Default::default()
}
.compile()
.unwrap();
let response = serve_request(
&service,
&request(Method::GET, "/inside-link/asset.txt"),
"/inside-link/asset.txt",
)
.await;
if policy == SymlinkPolicy::InsideRoot {
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
"safe"
);
} else {
assert_ne!(response.status(), StatusCode::OK);
}
let response = serve_request(
&service,
&request(Method::GET, "/outside-link/asset.txt"),
"/outside-link/asset.txt",
)
.await;
assert_ne!(response.status(), StatusCode::OK);
assert!(!response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.windows(6)
.any(|part| part == b"secret"));
for _ in 0..3 {
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let listing = std::str::from_utf8(&body).unwrap();
assert!(listing.contains("safe/"));
assert!(!listing.contains("outside-link"));
assert_eq!(
listing.contains("inside-link"),
policy == SymlinkPolicy::InsideRoot
);
}
}
}
#[cfg(unix)]
#[tokio::test]
async fn borrowed_root_descriptor_stays_anchored_after_root_path_replacement() {
let tmp = TempDir::new().unwrap();
let configured_root = tmp.path().join("site");
std::fs::create_dir_all(configured_root.join("nested")).unwrap();
std::fs::write(configured_root.join("nested/asset.txt"), b"original").unwrap();
let service = service(&configured_root, false);
std::fs::rename(&configured_root, tmp.path().join("moved-site")).unwrap();
std::fs::create_dir_all(configured_root.join("nested")).unwrap();
std::fs::write(
configured_root.join("nested/asset.txt"),
b"replacement-secret",
)
.unwrap();
for _ in 0..3 {
let response = serve_request(
&service,
&request(Method::GET, "/nested/asset.txt"),
"/nested/asset.txt",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
"original"
);
}
}
#[cfg(target_os = "linux")]
fn open_descriptors_for(path: &Path) -> usize {
std::fs::read_dir("/proc/self/fd")
.unwrap()
.filter_map(|entry| std::fs::read_link(entry.ok()?.path()).ok())
.filter(|target| target == path)
.count()
}
#[cfg(target_os = "linux")]
async fn wait_for_file_descriptors_to_close(path: &Path) {
tokio::time::timeout(Duration::from_secs(5), async {
while open_descriptors_for(path) != 0 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("cancelled static response retained its file descriptor");
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn cancelled_static_and_multipart_bodies_release_files_and_count_only_emitted_bytes() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("asset.bin");
std::fs::write(&path, vec![b'x'; 1024 * 1024]).unwrap();
let service = service(tmp.path(), false);
let unpolled = serve_request(&service, &request(Method::GET, "/asset.bin"), "/asset.bin").await;
drop(unpolled);
wait_for_file_descriptors_to_close(&path).await;
assert_eq!(service.web_metrics.view()["response_bytes"], 0);
let mut accounted = 0u64;
for range in [None, Some("bytes=0-400000,600000-1000000")] {
let mut requested = request(Method::GET, "/asset.bin");
if let Some(range) = range {
requested
.headers_mut()
.insert(RANGE, range.parse().unwrap());
}
let mut body = serve_request(&service, &requested, "/asset.bin")
.await
.into_body();
let first = body.frame().await.unwrap().unwrap().into_data().unwrap();
assert!(!first.is_empty());
assert!(first.len() <= 64 * 1024);
accounted += first.len() as u64;
assert_eq!(service.web_metrics.view()["response_bytes"], accounted);
// The multipart writer may fill its bounded duplex buffer in the
// background. Neither it nor a pending file read counts unsent bytes.
tokio::task::yield_now().await;
assert_eq!(service.web_metrics.view()["response_bytes"], accounted);
drop(body);
wait_for_file_descriptors_to_close(&path).await;
assert_eq!(service.web_metrics.view()["response_bytes"], accounted);
}
}