v0.33.8
This commit is contained in:
1 parent
6b937aaf85
commit
87c8393d86
79 files changed
+6727
-297
No files matched your search
@@ -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