use mini_static::Server;
use std::fs;
use std::time::Duration;
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
#[test]
fn server_new_canonicalizes_root() {
let root = TempDir::new().unwrap();
let result = Server::new(root.path());
assert!(
result.is_ok(),
"Server::new should succeed with a valid root"
);
}
#[test]
fn server_new_fails_with_invalid_root() {
let result = Server::new(std::path::Path::new(
"/nonexistent/path/that/does/not/exist",
));
assert!(result.is_err(), "Server::new should fail with invalid root");
}
#[test]
fn server_multiple_resolves_without_root_recanonical() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("file1.txt"), b"content1").unwrap();
fs::write(root.path().join("file2.txt"), b"content2").unwrap();
let server = Server::new(root.path()).unwrap();
let res1 = server.resolve("/file1.txt");
let res2 = server.resolve("/file2.txt");
let res3 = server.resolve("/file1.txt");
assert!(res1.is_ok());
assert!(res2.is_ok());
assert!(res3.is_ok());
}
#[test]
fn server_rejects_traversal_on_multiple_requests() {
let root = TempDir::new().unwrap();
let server = Server::new(root.path()).unwrap();
let res1 = server.resolve("/../etc/passwd");
let res2 = server.resolve("/../../etc/passwd");
let res3 = server.resolve("/../etc/passwd");
assert!(res1.is_err());
assert!(res2.is_err());
assert!(res3.is_err());
}
#[test]
fn server_resolve_with_canonical_root_direct() {
let root = TempDir::new().unwrap();
let test_file = root.path().join("test.txt");
fs::write(&test_file, b"content").unwrap();
let root_canon = root.path().canonicalize().unwrap();
let result = mini_static::resolve_with_canonical_root(&root_canon, "/test.txt");
assert!(result.is_ok());
}
#[test]
fn server_multiple_requests_same_effectiveness() {
let root = TempDir::new().unwrap();
fs::create_dir(root.path().join("subdir")).unwrap();
fs::write(root.path().join("subdir/index.html"), b"<html></html>").unwrap();
fs::write(root.path().join("file.txt"), b"content").unwrap();
let server = Server::new(root.path()).unwrap();
let dir_with_index = server.resolve("/subdir");
let file = server.resolve("/file.txt");
let missing = server.resolve("/missing.txt");
let traversal = server.resolve("/../etc/passwd");
assert!(
dir_with_index.is_ok(),
"directory with index should resolve"
);
assert!(file.is_ok(), "regular file should resolve");
assert!(missing.is_err(), "missing file should error");
assert!(traversal.is_err(), "traversal should error");
}
#[tokio::test]
async fn server_header_read_timeout_closes_idle_connection() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("test.txt"), b"hello world").unwrap();
let server = Server::new(root.path()).unwrap();
let header_timeout = Duration::from_millis(100);
let (port, _handle) = server.run(header_timeout).await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut stream = TcpStream::connect(&addr).await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
let mut buf = vec![0u8; 1024];
match stream.read(&mut buf).await {
Ok(n) => assert_eq!(n, 0, "read from timed-out connection should return EOF"),
Err(e) => panic!("read from timed-out connection failed: {}", e),
}
let mut new_stream = TcpStream::connect(&addr).await.unwrap();
new_stream
.write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut buf = vec![0u8; 1024];
let n = new_stream.read(&mut buf).await.unwrap();
let response = String::from_utf8_lossy(&buf[..n]);
assert!(
response.contains("HTTP/1.1 200"),
"new connection should work and return 200, got: {}",
response
);
}
#[tokio::test]
async fn server_run_ephemeral_binds_to_loopback() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("index.html"), b"<html>hello</html>").unwrap();
let server = Server::new(root.path()).unwrap();
let (port, _handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut stream = TcpStream::connect(&addr).await.unwrap();
stream
.write_all(b"GET /index.html HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
stream.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"run_ephemeral should be accessible on loopback, got: {}",
response
);
assert!(
response.contains("hello"),
"response should contain file content, got: {}",
response
);
}
#[tokio::test]
async fn server_streams_large_files_efficiently() {
let root = TempDir::new().unwrap();
let large_content = vec![42u8; 1024 * 1024];
fs::write(root.path().join("large.bin"), &large_content).unwrap();
let server = Server::new(root.path()).unwrap();
let (port, _handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut stream = TcpStream::connect(&addr).await.unwrap();
stream
.write_all(b"GET /large.bin HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
stream.read_to_end(&mut response_data).await.unwrap();
let response_str = String::from_utf8_lossy(&response_data);
let header_part = response_str.split("\r\n\r\n").next().unwrap_or("");
let headers_lowercase = header_part.to_lowercase();
assert!(
response_str.contains("HTTP/1.1 200"),
"should return 200 OK"
);
assert!(
headers_lowercase.contains("content-length: 1048576"),
"Content-Length should be exactly 1MB, got headers: {}",
header_part
);
if let Some(body_start) = response_data.windows(4).position(|w| w == b"\r\n\r\n") {
let body = &response_data[body_start + 4..];
assert_eq!(body.len(), 1048576, "body should be exactly 1MB");
assert!(
body.iter().all(|&b| b == 42),
"file content should be preserved through streaming"
);
} else {
panic!("could not find body separator in response");
}
}
#[tokio::test]
async fn server_serves_non_ascii_filenames() {
let root = TempDir::new().unwrap();
let filename = "café.txt";
let content = "hello from café";
fs::write(root.path().join(filename), content).unwrap();
let server = Server::new(root.path()).unwrap();
let (port, _handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let encoded_path = "/caf%C3%A9.txt".to_string();
let request = format!(
"GET {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
encoded_path
);
let mut stream = TcpStream::connect(&addr).await.unwrap();
stream.write_all(request.as_bytes()).await.unwrap();
let mut response_data = Vec::new();
stream.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"non-ASCII filename should resolve correctly, got: {}",
response
);
assert!(
response.contains("hello from café"),
"response should contain file content with non-ASCII chars"
);
}
#[tokio::test]
async fn server_non_ascii_filenames_still_block_traversal() {
let root = TempDir::new().unwrap();
let subdir = root.path().join("subdir");
fs::create_dir(&subdir).unwrap();
fs::write(root.path().join("secret.txt"), b"secret").unwrap();
fs::write(subdir.join("public.txt"), b"public").unwrap();
let server = Server::new(&subdir).unwrap();
let (port, _handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let traversal_attempts = vec![
"/../secret.txt", "/%2E%2E/secret.txt", ];
for attempt in traversal_attempts {
let request = format!(
"GET {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
attempt
);
let mut stream = TcpStream::connect(&addr).await.unwrap();
stream.write_all(request.as_bytes()).await.unwrap();
let mut response_data = Vec::new();
stream.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 404"),
"traversal attempt {} should return 404, got: {}",
attempt,
response
);
assert!(
!response.contains("secret"),
"traversal attempt {} should not leak file content",
attempt
);
}
}
#[tokio::test]
async fn server_with_max_connections_bounds_concurrent_connections() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("test.txt"), b"content").unwrap();
let header_timeout = Duration::from_millis(150);
let server = Server::new(root.path()).unwrap().with_max_connections(1);
let (port, _handle) = server.run(header_timeout).await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let _blocking_conn = TcpStream::connect(&addr).await.unwrap();
let mut conn_b = TcpStream::connect(&addr).await.unwrap();
conn_b
.write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut buf = vec![0u8; 1024];
let early_read = tokio::time::timeout(Duration::from_millis(60), conn_b.read(&mut buf)).await;
assert!(
early_read.is_err(),
"connection B should still be waiting for a permit while A holds the only slot"
);
let n = tokio::time::timeout(Duration::from_millis(500), conn_b.read(&mut buf))
.await
.expect("connection B should be served once A's slot frees up")
.unwrap();
let response = String::from_utf8_lossy(&buf[..n]);
assert!(
response.contains("HTTP/1.1 200"),
"connection B should succeed after A's slot is released, got: {}",
response
);
}
#[tokio::test]
async fn server_shutdown_drains_in_flight_and_stops_accepting() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("test.txt"), b"content").unwrap();
let server = Server::new(root.path()).unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200") && response.contains("content"),
"request before shutdown should succeed normally, got: {}",
response
);
tokio::time::timeout(Duration::from_secs(2), handle.shutdown())
.await
.expect("shutdown() should complete promptly, not hang");
let reconnect = TcpStream::connect(&addr).await;
assert!(
reconnect.is_err(),
"server should stop accepting new connections after shutdown"
);
}
#[tokio::test]
async fn server_honors_if_none_match_returns_304() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("test.txt"), b"file content").unwrap();
let server = Server::new(root.path()).unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"first request should return 200, got: {}",
response
);
assert!(
response.to_lowercase().contains("etag:"),
"should have ETag header, got: {}",
response
);
let etag = response
.lines()
.find(|line| line.to_lowercase().starts_with("etag:"))
.and_then(|line| line.split(": ").nth(1))
.expect("should have ETag header");
let mut conn = TcpStream::connect(&addr).await.unwrap();
let request = format!(
"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nIf-None-Match: {}\r\nConnection: close\r\n\r\n",
etag
);
conn.write_all(request.as_bytes()).await.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 304"),
"matching If-None-Match should return 304, got: {}",
response
);
assert!(
!response.contains("file content"),
"304 should have no body"
);
handle.shutdown().await;
}
#[tokio::test]
async fn server_returns_200_on_if_none_match_mismatch() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("test.txt"), b"file content").unwrap();
let server = Server::new(root.path()).unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nIf-None-Match: \"wrong-etag\"\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"non-matching If-None-Match should return 200, got: {}",
response
);
assert!(response.contains("file content"), "200 should have body");
handle.shutdown().await;
}
#[tokio::test]
async fn no_cache_header_present_on_200_and_304_responses() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("test.txt"), b"file content").unwrap();
let server = Server::new(root.path()).unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"expected 200, got: {}",
response
);
assert!(
response.to_lowercase().contains("cache-control: no-cache"),
"200 response should carry Cache-Control: no-cache, got: {}",
response
);
let etag = response
.lines()
.find(|line| line.to_lowercase().starts_with("etag:"))
.and_then(|line| line.split(": ").nth(1))
.expect("should have ETag header")
.to_string();
let mut conn = TcpStream::connect(&addr).await.unwrap();
let request = format!(
"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nIf-None-Match: {}\r\nConnection: close\r\n\r\n",
etag
);
conn.write_all(request.as_bytes()).await.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 304"),
"expected 304, got: {}",
response
);
assert!(
response.to_lowercase().contains("cache-control: no-cache"),
"304 response should carry Cache-Control: no-cache, got: {}",
response
);
handle.shutdown().await;
}
#[tokio::test]
async fn precompressed_sidecar_served_when_accept_encoding_matches() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("app.js"), b"console.log('plain');").unwrap();
fs::write(root.path().join("app.js.gz"), b"gzip-sidecar-bytes").unwrap();
let server = Server::new(root.path()).unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /app.js HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"expected 200, got: {}",
response
);
assert!(
response.to_lowercase().contains("content-encoding: gzip"),
"should carry Content-Encoding: gzip, got: {}",
response
);
assert!(
response.contains("gzip-sidecar-bytes"),
"should serve the sidecar's bytes, got: {}",
response
);
assert!(
!response.contains("console.log('plain');"),
"should not serve the plain file's bytes when a sidecar is chosen, got: {}",
response
);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /app.js HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"expected 200, got: {}",
response
);
assert!(
!response.to_lowercase().contains("content-encoding:"),
"plain request should carry no Content-Encoding, got: {}",
response
);
assert!(
response.contains("console.log('plain');"),
"should serve the plain file's bytes unchanged, got: {}",
response
);
handle.shutdown().await;
}
#[tokio::test]
async fn etag_reflects_the_served_sidecar_variant() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("app.js"), b"console.log('plain');").unwrap();
fs::write(root.path().join("app.js.gz"), b"gzip-sidecar-bytes").unwrap();
let server = Server::new(root.path()).unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /app.js HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
let gzip_etag = response
.lines()
.find(|line| line.to_lowercase().starts_with("etag:"))
.and_then(|line| line.split(": ").nth(1))
.expect("should have ETag header")
.to_string();
let mut conn = TcpStream::connect(&addr).await.unwrap();
let request = format!(
"GET /app.js HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nIf-None-Match: {}\r\nConnection: close\r\n\r\n",
gzip_etag
);
conn.write_all(request.as_bytes()).await.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 304"),
"matching gzip-variant ETag should 304, got: {}",
response
);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /app.js HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
let plain_etag = response
.lines()
.find(|line| line.to_lowercase().starts_with("etag:"))
.and_then(|line| line.split(": ").nth(1))
.expect("should have ETag header")
.to_string();
assert_ne!(
gzip_etag.trim(),
plain_etag.trim(),
"gzip and plain variants should have different ETags"
);
handle.shutdown().await;
}