mod common;
use std::fs;
use hyper::{Method, Request, StatusCode};
use mini_static::Server;
use tempfile::TempDir;
#[derive(Debug, PartialEq, Eq)]
struct Observed {
status: StatusCode,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
#[derive(Debug, Clone, Copy)]
enum Entry {
HandleRequest,
Respond,
}
fn router_segments(path: &str) -> Vec<String> {
path.trim_start_matches('/')
.split('/')
.filter(|segment| !segment.is_empty())
.map(|segment| {
percent_encoding::percent_decode_str(segment)
.decode_utf8_lossy()
.into_owned()
})
.collect()
}
async fn observe(
server: &Server,
entry: Entry,
method: &Method,
path: &str,
extra: &[(&str, &str)],
) -> Observed {
let response = match entry {
Entry::HandleRequest => common::request_with_headers(server, method, path, extra).await,
Entry::Respond => {
let mut builder = Request::builder().method(method.clone()).uri(path);
for (name, value) in extra {
builder = builder.header(*name, *value);
}
let request = builder.body(()).unwrap();
server.respond(&request, &router_segments(path)).await
}
};
let status = response.status();
let mut headers: Vec<(String, String)> = response
.headers()
.iter()
.map(|(name, value)| {
(
name.as_str().to_string(),
String::from_utf8_lossy(value.as_bytes()).into_owned(),
)
})
.collect();
headers.sort();
let body = common::body_bytes(response).await.to_vec();
Observed { status, headers, body }
}
struct Case {
label: &'static str,
method: Method,
path: &'static str,
headers: Vec<(&'static str, &'static str)>,
}
fn case(label: &'static str, method: Method, path: &'static str) -> Case {
Case { label, method, path, headers: Vec::new() }
}
fn case_with(
label: &'static str,
method: Method,
path: &'static str,
headers: Vec<(&'static str, &'static str)>,
) -> Case {
Case { label, method, path, headers }
}
fn fixture() -> TempDir {
let root = TempDir::new().unwrap();
fs::write(root.path().join("index.html"), b"<html>home</html>").unwrap();
fs::write(root.path().join("app.css"), b"body{color:red}").unwrap();
fs::write(root.path().join(".env"), b"SECRET=1").unwrap();
fs::create_dir(root.path().join("docs")).unwrap();
fs::write(root.path().join("docs/index.html"), b"<html>docs</html>").unwrap();
fs::write(root.path().join("big.bin"), vec![b'x'; 128 * 1024]).unwrap();
root
}
fn pair(root: &TempDir) -> (Server, Server) {
let uncached = Server::new(root.path()).unwrap();
let cached = Server::new(root.path())
.unwrap()
.with_content_cache(8 << 20)
.expect("no live-reload configured");
(uncached, cached)
}
#[tokio::test]
async fn a_cached_response_is_identical_to_an_uncached_one() {
let root = fixture();
let (uncached, cached) = pair(&root);
let cases = vec![
case("a small file", Method::GET, "/app.css"),
case("the root index", Method::GET, "/"),
case("a directory with a trailing slash", Method::GET, "/docs/"),
case("a directory without one, which redirects", Method::GET, "/docs"),
case("an explicit index", Method::GET, "/index.html"),
case("a file above the inline threshold", Method::GET, "/big.bin"),
case("a HEAD", Method::HEAD, "/app.css"),
case("a HEAD of a large file", Method::HEAD, "/big.bin"),
case("a hidden file", Method::GET, "/.env"),
case("a path that does not exist", Method::GET, "/missing.css"),
case("a traversal attempt", Method::GET, "/../etc/passwd"),
case("an encoded separator", Method::GET, "/docs%2Findex.html"),
case("a method the engine does not serve", Method::DELETE, "/app.css"),
case_with("a satisfiable range", Method::GET, "/big.bin", vec![("range", "bytes=100-199")]),
case_with(
"an unsatisfiable range",
Method::GET,
"/app.css",
vec![("range", "bytes=9999-99999")],
),
case_with("a suffix range", Method::GET, "/big.bin", vec![("range", "bytes=-50")]),
case_with(
"a multi-range, which is ignored",
Method::GET,
"/big.bin",
vec![("range", "bytes=0-9,20-29")],
),
];
for Case { label, method, path, headers } in cases {
for entry in [Entry::HandleRequest, Entry::Respond] {
let from_disk = observe(&uncached, entry, &method, path, &headers).await;
let from_memory = observe(&cached, entry, &method, path, &headers).await;
assert_eq!(
from_disk, from_memory,
"cached and uncached responses differ for {label} ({method} {path}) via {entry:?}"
);
}
}
}
#[tokio::test]
async fn a_conditional_request_matches_between_cached_and_uncached() {
let root = fixture();
let (uncached, cached) = pair(&root);
let etag = {
let response = common::get(&uncached, "/app.css").await;
response
.headers()
.get("etag")
.expect("a 200 carries an ETag")
.to_str()
.unwrap()
.to_string()
};
let conditional = [("if-none-match", etag.as_str())];
let from_disk =
observe(&uncached, Entry::Respond, &Method::GET, "/app.css", &conditional).await;
let from_memory = observe(&cached, Entry::Respond, &Method::GET, "/app.css", &conditional).await;
assert_eq!(from_disk.status, StatusCode::NOT_MODIFIED, "expected a 304 from disk");
assert_eq!(
from_disk, from_memory,
"a cached 304 must match an uncached one, ETag included — the ETag is derived from the \
stored metadata precisely so it cannot drift"
);
}
#[tokio::test]
async fn the_cache_is_the_thing_answering() {
let root = fixture();
let cached = Server::new(root.path())
.unwrap()
.with_content_cache(8 << 20)
.expect("no live-reload configured");
fs::remove_file(root.path().join("app.css")).unwrap();
let response = common::get(&cached, "/app.css").await;
assert_eq!(
response.status(),
StatusCode::OK,
"the cache did not answer — the request reached a disk that no longer has the file"
);
assert_eq!(&common::body_bytes(response).await[..], b"body{color:red}");
}
#[tokio::test]
async fn the_directory_index_is_answered_from_the_cache() {
let root = fixture();
let cached = Server::new(root.path())
.unwrap()
.with_content_cache(8 << 20)
.expect("no live-reload configured");
fs::remove_file(root.path().join("index.html")).unwrap();
fs::remove_file(root.path().join("docs/index.html")).unwrap();
for (path, expected) in [("/", &b"<html>home</html>"[..]), ("/docs/", &b"<html>docs</html>"[..])] {
let response = common::get(&cached, path).await;
assert_eq!(
response.status(),
StatusCode::OK,
"{path} was not answered from the cache once the index was gone from disk"
);
assert_eq!(&common::body_bytes(response).await[..], expected, "{path}");
}
}
#[tokio::test]
async fn a_file_with_a_sidecar_is_served_from_disk_with_its_encoding() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("app.css"), b"body{}").unwrap();
fs::write(root.path().join("app.css.br"), b"BROTLI").unwrap();
let cached = Server::new(root.path())
.unwrap()
.with_content_cache(8 << 20)
.expect("no live-reload configured");
let response =
common::request_with_headers(&cached, &Method::GET, "/app.css", &[("accept-encoding", "br")])
.await;
assert_eq!(
response.headers().get("content-encoding").map(|v| v.to_str().unwrap()),
Some("br"),
"the sidecar must still be served; declining to cache it must not lose the encoding"
);
assert_eq!(&common::body_bytes(response).await[..], b"BROTLI");
}
#[tokio::test]
async fn a_cached_root_serves_variants_without_touching_the_filesystem() {
let outer = TempDir::new().unwrap();
let root = outer.path().join("www");
fs::create_dir(&root).unwrap();
fs::write(root.join("app.css"), b"body{color:red}").unwrap();
fs::write(root.join("app.css.br"), b"BROTLI-BYTES").unwrap();
fs::write(root.join("app.css.gz"), b"GZIP-BYTES").unwrap();
let cached = Server::new(&root)
.unwrap()
.with_content_cache(8 << 20)
.expect("no live-reload configured");
fs::rename(&root, outer.path().join("moved-away")).unwrap();
for (accept, expected_encoding, expected_body) in [
("br, gzip", Some("br"), &b"BROTLI-BYTES"[..]),
("gzip", Some("gzip"), &b"GZIP-BYTES"[..]),
("identity", None, &b"body{color:red}"[..]),
] {
let response = common::request_with_headers(
&cached,
&Method::GET,
"/app.css",
&[("accept-encoding", accept)],
)
.await;
assert_eq!(
response.status(),
StatusCode::OK,
"accept-encoding: {accept} did not serve from memory once the root was moved"
);
assert_eq!(
response.headers().get("content-encoding").map(|v| v.to_str().unwrap()),
expected_encoding,
"wrong Content-Encoding for accept-encoding: {accept}"
);
assert_eq!(&common::body_bytes(response).await[..], expected_body, "for {accept}");
}
}
#[tokio::test]
async fn a_cached_variant_honours_quality_values_like_the_disk_path() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("app.css"), b"plain").unwrap();
fs::write(root.path().join("app.css.br"), b"BROTLI").unwrap();
fs::write(root.path().join("app.css.gz"), b"GZIP").unwrap();
let (uncached, cached) = (
Server::new(root.path()).unwrap(),
Server::new(root.path())
.unwrap()
.with_content_cache(8 << 20)
.expect("no live-reload configured"),
);
for accept in ["br;q=0.5, gzip", "gzip;q=1.0, br;q=0.1", "br, gzip"] {
let from_disk =
observe(&uncached, Entry::Respond, &Method::GET, "/app.css", &[("accept-encoding", accept)])
.await;
let from_memory =
observe(&cached, Entry::Respond, &Method::GET, "/app.css", &[("accept-encoding", accept)])
.await;
assert_eq!(
from_disk, from_memory,
"cached and uncached negotiation differ for accept-encoding: {accept}"
);
}
}
#[tokio::test]
async fn a_cached_file_whose_variant_was_not_cached_still_serves_the_variant() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("app.css"), vec![b'x'; 100]).unwrap();
fs::write(root.path().join("app.css.br"), vec![b'b'; 100]).unwrap();
let (uncached, cached) = (
Server::new(root.path()).unwrap(),
Server::new(root.path())
.unwrap()
.with_content_cache(150)
.expect("no live-reload configured"),
);
let accept = [("accept-encoding", "br")];
let from_disk = observe(&uncached, Entry::Respond, &Method::GET, "/app.css", &accept).await;
let from_memory = observe(&cached, Entry::Respond, &Method::GET, "/app.css", &accept).await;
assert_eq!(
from_disk.headers, from_memory.headers,
"a partially cached file must still negotiate its encoding the same way"
);
assert_eq!(from_disk.body, from_memory.body, "the variant's bytes must be served");
}
#[tokio::test]
async fn the_clients_quality_preference_decides_the_encoding() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("app.css"), b"plain").unwrap();
fs::write(root.path().join("app.css.br"), b"BROTLI").unwrap();
fs::write(root.path().join("app.css.gz"), b"GZIP").unwrap();
for server in [
Server::new(root.path()).unwrap(),
Server::new(root.path())
.unwrap()
.with_content_cache(8 << 20)
.expect("no live-reload configured"),
] {
let response = common::request_with_headers(
&server,
&Method::GET,
"/app.css",
&[("accept-encoding", "br;q=0.5, gzip")],
)
.await;
assert_eq!(
response.headers().get("content-encoding").map(|v| v.to_str().unwrap()),
Some("gzip"),
"the server preferred its own order over the client's stated q values"
);
assert_eq!(&common::body_bytes(response).await[..], b"GZIP");
}
}