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_handle_request_returns_404_for_missing() {
let root = TempDir::new().unwrap();
let server = Server::new(root.path()).unwrap();
let response = server.handle_request("/nonexistent.txt");
assert_eq!(response.status().as_u16(), 404);
assert!(response
.headers()
.get("X-Content-Type-Options")
.is_some());
}
#[test]
fn server_handle_request_returns_404_for_traversal() {
let root = TempDir::new().unwrap();
let server = Server::new(root.path()).unwrap();
let response = server.handle_request("/../etc/passwd");
assert_eq!(response.status().as_u16(), 404);
}
#[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 = format!("/caf%C3%A9.txt");
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 server_honors_if_modified_since_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 metadata = fs::metadata(root.path().join("test.txt")).unwrap();
let mtime = metadata
.modified()
.unwrap()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let mut conn = TcpStream::connect(&addr).await.unwrap();
let request = format!(
"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nIf-Modified-Since: {}\r\nConnection: close\r\n\r\n",
mtime
);
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"),
"If-Modified-Since equal to mtime 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_modified_since_old() {
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 old_time = 0u64; let mut conn = TcpStream::connect(&addr).await.unwrap();
let request = format!(
"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nIf-Modified-Since: {}\r\nConnection: close\r\n\r\n",
old_time
);
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 200"),
"If-Modified-Since in the past should return 200, got: {}",
response
);
assert!(response.contains("file content"), "200 should have body");
handle.shutdown().await;
}