mod common;
use common::{spawn, Server};
use std::path::PathBuf;
use ullun::files::{http_date, serve_file, FileOptions};
use ullun::prelude::*;
const BIG: usize = 200_000;
struct Fixture {
dir: PathBuf,
server: Server,
big: Vec<u8>,
}
fn pattern(n: usize) -> Vec<u8> {
(0..n).map(|i| (i % 251) as u8).collect()
}
async fn fixture(name: &str) -> Fixture {
let dir = std::env::temp_dir().join(format!("ullun-files-{}-{}", name, std::process::id()));
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::create_dir_all(dir.join(".git")).unwrap();
std::fs::write(dir.join("small.txt"), "hello world").unwrap();
std::fs::write(dir.join("big.bin"), pattern(BIG)).unwrap();
std::fs::write(dir.join("my file.txt"), "spaces").unwrap();
std::fs::write(dir.join(".env"), "SECRET=1").unwrap();
std::fs::write(dir.join(".git/config"), "[core]").unwrap();
std::fs::write(dir.join("sub/index.html"), "<h1>sub</h1>").unwrap();
let (small, big_path) = (dir.join("small.txt"), dir.join("big.bin"));
let app = App::new()
.get("/dl/small", move |req: Request| {
let p = small.clone();
async move {
serve_file(
&req,
p,
&FileOptions::new()
.attachment("Übersicht.txt")
.cache_control("no-cache"),
)
.await
}
})
.get("/dl/big", move |req: Request| {
let p = big_path.clone();
async move {
serve_file(
&req,
p,
&FileOptions::new().content_type("application/x-custom"),
)
.await
}
})
.get("/dl/missing", |req: Request| async move {
serve_file(&req, "/definitely/not/here.bin", &FileOptions::new()).await
})
.serve_static("/static", dir.to_str().unwrap());
Fixture {
server: spawn(app).await,
dir,
big: pattern(BIG),
}
}
impl Drop for Fixture {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.dir).ok();
}
}
fn mtime(path: &PathBuf) -> u64 {
std::fs::metadata(path)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[tokio::test]
async fn small_and_large_files_are_served_with_metadata() {
let f = fixture("serve").await;
let small = f.server.get("/dl/small").send().await;
assert_eq!(small.status, 200);
assert_eq!(small.text(), "hello world");
assert_eq!(small.header("content-type"), "text/plain; charset=utf-8");
assert_eq!(small.header("accept-ranges"), "bytes");
assert_eq!(small.header("cache-control"), "no-cache");
assert_eq!(
small.header("last-modified"),
http_date(mtime(&f.dir.join("small.txt")))
);
assert!(small.header("etag").starts_with("W/\""));
assert_eq!(
small.header("content-disposition"),
"attachment; filename=\"_bersicht.txt\"; filename*=UTF-8''%C3%9Cbersicht.txt"
);
let big = f.server.get("/dl/big").send().await;
assert_eq!(big.status, 200);
assert_eq!(big.header("content-length"), BIG.to_string());
assert_eq!(big.header("content-type"), "application/x-custom");
assert_eq!(big.body, f.big);
let head = f.server.request("HEAD", "/dl/big").send().await;
assert_eq!((head.status, head.body.len()), (200, 0));
assert_eq!(head.header("content-length"), BIG.to_string());
assert_eq!(head.header("etag"), big.header("etag"));
let missing = f.server.get("/dl/missing").send().await;
assert_eq!(missing.status, 404);
}
#[tokio::test]
async fn byte_ranges_on_buffered_and_streamed_files() {
let f = fixture("range").await;
let range = |path: &'static str, spec: &'static str| {
let s = f.server;
async move { s.get(path).header("range", spec).send().await }
};
let first = range("/dl/big", "bytes=0-99").await;
assert_eq!(first.status, 206);
assert_eq!(first.header("content-range"), format!("bytes 0-99/{BIG}"));
assert_eq!(first.header("content-length"), "100");
assert_eq!(first.body, f.big[..100]);
let middle = range("/dl/big", "bytes=60000-140000").await;
assert_eq!(middle.status, 206);
assert_eq!(middle.body, f.big[60_000..=140_000]);
let tail = range("/dl/big", "bytes=-1000").await;
assert_eq!(tail.body, f.big[BIG - 1000..]);
assert_eq!(
tail.header("content-range"),
format!("bytes {}-{}/{}", BIG - 1000, BIG - 1, BIG)
);
let open_ended = range("/dl/big", "bytes=199990-").await;
assert_eq!(open_ended.body, f.big[199_990..]);
let small = range("/dl/small", "bytes=6-").await;
assert_eq!((small.status, small.text().as_str()), (206, "world"));
assert_eq!(small.header("content-range"), "bytes 6-10/11");
let beyond = range("/dl/big", "bytes=200000-").await;
assert_eq!(beyond.status, 416);
assert_eq!(beyond.header("content-range"), format!("bytes */{BIG}"));
assert_eq!(range("/dl/small", "bytes=-0").await.status, 416);
assert_eq!(
range("/dl/small", "bytes=0-1,4-5").await.text(),
"hello world"
);
assert_eq!(range("/dl/small", "bytes=abc").await.status, 200);
assert_eq!(range("/dl/small", "pages=1-2").await.status, 200);
let head = f
.server
.request("HEAD", "/dl/big")
.header("range", "bytes=0-9")
.send()
.await;
assert_eq!(
(head.status, head.header("content-length"), head.body.len()),
(206, "10", 0)
);
}
#[tokio::test]
async fn conditional_requests_and_if_range() {
let f = fixture("cond").await;
let first = f.server.get("/dl/big").send().await;
let (etag, modified) = (
first.header("etag").to_string(),
first.header("last-modified").to_string(),
);
let by_tag = f
.server
.get("/dl/big")
.header("if-none-match", &etag)
.send()
.await;
assert_eq!((by_tag.status, by_tag.body.len()), (304, 0));
assert_eq!(by_tag.header("etag"), etag);
assert_eq!(
f.server
.get("/dl/big")
.header("if-none-match", "\"other\"")
.send()
.await
.status,
200
);
let by_date = f
.server
.get("/dl/big")
.header("if-modified-since", &modified)
.send()
.await;
assert_eq!(by_date.status, 304);
let old = http_date(mtime(&f.dir.join("big.bin")) - 3600);
assert_eq!(
f.server
.get("/dl/big")
.header("if-modified-since", &old)
.send()
.await
.status,
200
);
assert_eq!(
f.server
.get("/dl/big")
.header("if-modified-since", "garbage")
.send()
.await
.status,
200
);
let both = f
.server
.get("/dl/big")
.header("if-none-match", "\"other\"")
.header("if-modified-since", &modified)
.send()
.await;
assert_eq!(both.status, 200);
let ok = f
.server
.get("/dl/big")
.header("range", "bytes=0-9")
.header("if-range", &modified)
.send()
.await;
assert_eq!(ok.status, 206);
let stale = f
.server
.get("/dl/big")
.header("range", "bytes=0-9")
.header("if-range", &old)
.send()
.await;
assert_eq!((stale.status, stale.body.len()), (200, BIG));
let weak = f
.server
.get("/dl/big")
.header("range", "bytes=0-9")
.header("if-range", &etag)
.send()
.await;
assert_eq!(weak.status, 200);
}
#[tokio::test]
async fn static_route_serves_ranges_index_and_encoded_names() {
let f = fixture("static").await;
let big = f
.server
.get("/static/big.bin")
.header("range", "bytes=100-199")
.send()
.await;
assert_eq!((big.status, big.body.as_slice()), (206, &f.big[100..200]));
assert_eq!(big.header("cache-control"), "public, max-age=3600");
assert_eq!(
f.server.get("/static/my%20file.txt").send().await.text(),
"spaces"
);
assert_eq!(
f.server.get("/static/sub").send().await.text(),
"<h1>sub</h1>"
);
assert_eq!(
f.server.get("/static/sub/").send().await.text(),
"<h1>sub</h1>"
);
assert_eq!(
f.server
.request("HEAD", "/static/small.txt")
.send()
.await
.header("content-length"),
"11"
);
}
#[tokio::test]
async fn static_route_hides_dotfiles_and_refuses_escapes() {
let f = fixture("hidden").await;
let outside = f
.dir
.parent()
.unwrap()
.join(format!("ullun-outside-{}.txt", std::process::id()));
std::fs::write(&outside, "outside").unwrap();
let outside_name = outside.file_name().unwrap().to_str().unwrap().to_string();
for path in [
"/static/.env".to_string(),
"/static/%2eenv".to_string(),
"/static/.git/config".to_string(),
"/static/sub/../.env".to_string(),
format!("/static/../{outside_name}"),
format!("/static/..%2F{outside_name}"),
format!("/static/%2e%2e/{outside_name}"),
format!("/static/%2e%2e%2f{outside_name}"),
"/static/small.txt%00.png".to_string(),
"/static/sub%5C..%5C..%5C.env".to_string(),
"/static/C:/Windows/win.ini".to_string(),
] {
let res = f.server.get(&path).send().await;
assert_eq!(res.status, 404, "{path}");
assert!(
!res.text().contains("SECRET") && !res.text().contains("outside"),
"{path}"
);
}
assert_eq!(f.server.get("/static/.git").send().await.status, 404);
std::fs::remove_file(outside).ok();
}
#[cfg(unix)]
#[tokio::test]
async fn symlinks_pointing_outside_the_root_are_not_followed() {
let f = fixture("symlink").await;
let target = f
.dir
.parent()
.unwrap()
.join(format!("ullun-link-target-{}.txt", std::process::id()));
std::fs::write(&target, "linked secret").unwrap();
std::os::unix::fs::symlink(&target, f.dir.join("link.txt")).unwrap();
assert_eq!(f.server.get("/static/link.txt").send().await.status, 404);
std::fs::remove_file(target).ok();
}