ServeDir 与 ServeFile
主线位置:请求主线的末端。
ServeDir不是 handler,而是一个现成的 towerService,通常用nest_service或fallback_service挂进Router,请求经过路由后直接交给它,它自己读文件、自己拼响应。装配时它只是一个配置好的结构体。
flowchart TB
req["请求"] --> meth{"GET 或 HEAD"}
meth -- 否 --> r405["405 Allow: GET,HEAD"]
meth -- 是 --> path["URI 解码成路径"]
path -- "含 .. 等" --> r404["404 或 fallback"]
path --> dir{"是目录吗"}
dir -- "是,无尾斜杠" --> r307["307 到 /dir/"]
dir -- "是,有尾斜杠" --> idx["改成 index.html"]
dir -- 否 --> enc["按 Accept-Encoding 选文件"]
idx --> enc
enc --> cond{"条件请求"}
cond -- 未修改 --> r304["304 或 412"]
cond -- 继续 --> rng{"Range"}
rng -- 单段 --> r206["206"]
rng -- 多段或越界 --> r416["416"]
rng -- 无 --> r200["200,分块读文件"]
本页的源码都在 tower-http/src/services/fs/。ServeDir 要开 tower-http 的 fs feature。本页示例用这一组依赖:
axum = "=0.8.9"
tower-http = { version = "=0.7.1", features = ["fs"] }
tower = { version = "=0.5.3", features = ["util"] }
http-body-util = "0.1"
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
和 不起服务器测路由 一样,示例不开端口,直接对服务 oneshot。所有输出都在 M2 Max(macOS)、rustc 1.99.0、--release 下跑 3 次,结果相同。
一个结构,两种用法
ServeDir 的字段:
pub struct ServeDir<F = DefaultServeDirFallback, B = TokioBackend> {
base: PathBuf,
redirect_path_prefix: String,
buf_chunk_size: usize,
ignore_multi_range_requests: bool,
precompressed_variants: Option<PrecompressedVariants>,
// This is used to specialize implementation for
// single files
variant: ServeVariant,
fallback: Option<F>,
call_fallback_on_method_not_allowed: bool,
backend: B,
}
variant是ServeVariant:Directory带三个开关(append_index_html_on_directories、redirect_to_trailing_slash、html_as_default_extension),SingleFile只带一个提前算好的 MIME。ServeFile就是pub struct ServeFile(ServeDir);,用SingleFile变体复用整条流程,源码注释说是为了「with almost no overhead」。下面讲的预压缩、Range、条件请求对ServeFile同样成立,只是不解析路径。fallback的类型参数默认是DefaultServeDirFallback,它包着一个Infallible,根本造不出来;fallback: None时它只占一个类型位置。backend是 0.7.0 加的Backendtrait(#684),默认的TokioBackend就是tokio::fs::File::open和tokio::fs::metadata。换成别的实现就能从内嵌资源或对象存储里出文件。
ServeDir 的 Service 实现里 Error = Infallible,所以能直接交给 axum 的 nest_service / fallback_service,不需要 HandleErrorLayer。IO 错误去了哪里,见本页最后一节。
从 URI 到文件路径
try_call 先看方法,不是 GET / HEAD 就是 405(带 Allow: GET,HEAD),除非设了 call_fallback_on_method_not_allowed(true) 又有 fallback。然后调 build_and_validate_path,路径穿越的防线全在这里:
let path = requested_path.trim_start_matches('/');
let path_decoded = percent_decode(path.as_ref()).decode_utf8().ok()?;
let path_decoded = Path::new(&*path_decoded);
let mut path_to_file = base_path.to_path_buf();
for component in path_decoded.components() {
match component {
Component::Normal(comp) => {
// protect against paths like `/foo/c:/bar/baz` (#204)
if Path::new(&comp)
.components()
.all(|c| matches!(c, Component::Normal(_)))
{
// ... Windows 上还要拒绝 CON、COM1 这类保留名
path_to_file.push(comp)
} else {
return None;
}
}
Component::CurDir => {}
Component::Prefix(_) | Component::RootDir | Component::ParentDir => {
return None;
}
}
}
Some(path_to_file)
顺序是:去掉开头所有的 /,先百分号解码,再交给标准库的 Path::components 拆段,逐段检查。
- 先解码再拆段,所以
%2e%2e解出..、%2f解出/,都会被拆成独立的段,ParentDir直接返回None。如果先拆段再解码,..%2f..会是一个普通段,解码后才变成穿越。 - 解码结果不是合法 UTF-8 也返回
None。 - 只允许
Normal和CurDir(.,跳过)。ParentDir、RootDir、Windows 的盘符前缀一律拒绝,不做「a/../b化简成b」这种归一化。 - 每个
Normal段再拆一次,要求里面也只有Normal。这一步挡的是 Windows 上的c:这类段(见下面的冷知识)。 base在ServeDir::new里是PathBuf::from(".")再push你给的路径,所以相对路径相对的是进程的当前目录。
None 变成 ResponseFuture::invalid_path:有 fallback 就调 fallback,没有就是空 body 的 404。注意这里不碰文件系统,是「请求本身不合法」的 404;文件不存在的 404 是另一条路,下面 try_call 一节会看到两者的区别。
use axum::{body::Body, http::Request};
use http_body_util::BodyExt;
use std::fs;
use tower::ServiceExt;
use tower_http::services::ServeDir;
async fn show<S>(svc: S, method: &str, uri: &str)
where
S: tower::Service<Request<Body>, Error = std::convert::Infallible>,
S::Response: axum::response::IntoResponse,
{
let req = Request::builder().method(method).uri(uri).body(Body::empty()).unwrap();
let res = axum::response::IntoResponse::into_response(svc.oneshot(req).await.unwrap());
let status = res.status();
let loc = res.headers().get("location").map(|v| format!(" location={}", v.to_str().unwrap())).unwrap_or_default();
let allow = res.headers().get("allow").map(|v| format!(" allow={}", v.to_str().unwrap())).unwrap_or_default();
let body = res.into_body().collect().await.unwrap().to_bytes();
println!("{method:<4} {uri:<28} {status}{loc}{allow} body={:?}", String::from_utf8_lossy(&body));
}
#[tokio::main]
async fn main() {
let _ = fs::remove_dir_all("tmp/paths");
fs::create_dir_all("tmp/paths/site/docs").unwrap();
fs::write("tmp/paths/secret.txt", "SECRET").unwrap();
fs::write("tmp/paths/site/a.txt", "A").unwrap();
fs::write("tmp/paths/site/docs/index.html", "DOCS").unwrap();
fs::write("tmp/paths/site/x\\y.txt", "BACKSLASH").unwrap();
let dir = ServeDir::new("tmp/paths/site");
for uri in [
"/a.txt",
"/../secret.txt",
"/%2e%2e/secret.txt",
"/docs/..%2f..%2fsecret.txt",
"/..%5csecret.txt",
"/x%5cy.txt",
"//a.txt",
"/c:/a.txt",
"/a.txt%00",
"/%ff",
"/a.txt/",
"/docs",
"/docs?v=1",
"/docs/",
] {
show(dir.clone(), "GET", uri).await;
}
show(dir.clone(), "POST", "/a.txt").await;
println!("-- redirect_to_trailing_slash(false)");
show(dir.clone().redirect_to_trailing_slash(false), "GET", "/docs").await;
println!("-- append_index_html_on_directories(false)");
show(dir.clone().append_index_html_on_directories(false), "GET", "/docs/").await;
}
GET /a.txt 200 OK body="A"
GET /../secret.txt 404 Not Found body=""
GET /%2e%2e/secret.txt 404 Not Found body=""
GET /docs/..%2f..%2fsecret.txt 404 Not Found body=""
GET /..%5csecret.txt 404 Not Found body=""
GET /x%5cy.txt 200 OK body="BACKSLASH"
GET //a.txt 200 OK body="A"
GET /c:/a.txt 404 Not Found body=""
GET /a.txt%00 404 Not Found body=""
GET /%ff 404 Not Found body=""
GET /a.txt/ 404 Not Found body=""
GET /docs 307 Temporary Redirect location=/docs/ body=""
GET /docs?v=1 307 Temporary Redirect location=/docs/?v=1 body=""
GET /docs/ 200 OK body="DOCS"
POST /a.txt 405 Method Not Allowed allow=GET,HEAD body=""
-- redirect_to_trailing_slash(false)
GET /docs 200 OK body="DOCS"
-- append_index_html_on_directories(false)
GET /docs/ 404 Not Found body=""
三种穿越写法(字面 ..、%2e%2e、%2f 拼出来的 ..)都是 404,secret.txt 一次也没被读到。其余几行:
//a.txt:开头的斜杠全被去掉,等于/a.txt,不会变成绝对路径。/a.txt%00:解码出 NUL 字节,打开文件时报InvalidInput,is_invalid_filename_error把它归为InvalidFilename,也是 404。/%ff:解码结果不是 UTF-8,build_and_validate_path返回None。/c:/a.txt:在 macOS 上c:是普通文件名,路径合法,只是文件不存在。- 反斜杠:
ServeDir的文档写着「Any segment of the path contains a backslash」会得到 404,但代码里没有针对反斜杠的检查,它依赖Path::components。在 Unix 上反斜杠不是分隔符,..\secret.txt是一个普通的段,所以/..%5csecret.txt并没有逃出根目录,只是去找一个叫..\secret.txt的文件,找不到;而真有一个叫x\y.txt的文件时,/x%5cy.txt照样 200。文档那句话描述的是「反斜杠当分隔符」的平台(Windows)上..\会被拆成ParentDir的结果,在 Unix 上不成立。安全性不受影响:Unix 上反斜杠本来就无法用来穿越目录。
c: 这一段检查来自一个 Windows 上的任意文件读取#204「ServeDir/File: Fix build_and_validate_path to prevent hacker from accessing arbitrary files」的说明写道:「Current version 0.2.0 release has a high risk hidden danger that allowed a hacker to access arbitrary files in server.」例子是服务跑在 Windows 的 D:\any-folder,请求 http://127.0.0.1/anypath/c:/windows/win.ini 就能读到 C 盘的文件。原因是 c: 被当成一个普通段 push 进路径,而在 Windows 上 PathBuf::push 一个带盘符的路径会整个替换掉前面的部分。
修法就是上面那段「每个 Normal 段再拆一次,里面必须全是 Normal」,代码里留着注释 // protect against paths like /foo/c:/bar/baz (#204)。它随 tower-http 0.2.1 发布,CHANGELOG 记为「ServeDir and ServeFile: Fix potential directory traversal attack due to improper path validation on Windows」。同一个函数后来又加了 Windows 保留设备名(CON、COM1 等)的检查,见 0.6.9 的 #663。
目录:重定向还是直接给 index.html
路径合法后,open_file 先调 maybe_redirect_or_append_path,它要先 metadata 一次,看看这个路径是不是目录:
let is_directory = is_dir(path_to_file, backend).await?;
if uri_path.ends_with('/') && uri_path != "/" && is_directory != Some(true) {
return Ok(Some(OpenFileOutput::FileNotFound));
}
// If the path has no extension and doesn't exist as a file, try appending .html
if html_as_default_extension && is_directory.is_none() && path_to_file.extension().is_none() {
path_to_file.set_extension("html");
return Ok(None);
}
if is_directory != Some(true) {
return Ok(None);
}
if !append_index_html_on_directories {
return Ok(Some(OpenFileOutput::FileNotFound));
}
if uri_path.ends_with('/') || !redirect_to_trailing_slash {
path_to_file.push("index.html");
Ok(None)
} else {
let uri = match append_slash_on_path(uri.clone(), redirect_path_prefix) {
Ok(uri) => uri,
Err(err) => return Ok(Some(err)),
};
let location = HeaderValue::from_str(&uri.to_string()).unwrap();
Ok(Some(OpenFileOutput::Redirect { location }))
}
- 不是目录却以
/结尾:404。上面的/a.txt/就是这样。这是 0.7.0 的 #678 加的,标为 breaking,之前尾斜杠会被忽略、照样给出文件。 - 是目录、没有尾斜杠、
redirect_to_trailing_slash为真(默认):返回重定向,状态码在future.rs里写死为307 Temporary Redirect,Location由append_slash_on_path拼出来:在路径后加/,query 原样保留(/docs?v=1→/docs/?v=1)。 - 为什么要先重定向:
/docs下的index.html里写的相对链接(style.css)会被浏览器解析成/style.css,而/docs/下才会解析成/docs/style.css。 redirect_to_trailing_slash(false)是 0.7.1 的新开关(#728,「/dirserves/dir/index.htmldirectly when directory indexes are enabled」),默认仍然重定向。关掉之后/docs直接给出index.html,代价是上面说的相对链接问题。append_index_html_on_directories(false):目录一律 404,ServeDir不会生成目录列表。
/static 下的目录重定向去哪use axum::{Router, body::Body, http::Request};
use std::fs;
use tower::ServiceExt;
use tower_http::services::ServeDir;
#[tokio::main]
async fn main() {
fs::create_dir_all("tmp/quiz/site/docs").unwrap();
fs::write("tmp/quiz/site/docs/index.html", "DOCS").unwrap();
let app = Router::new().nest_service("/static", ServeDir::new("tmp/quiz/site"));
let res = app.oneshot(Request::get("/static/docs").body(Body::empty()).unwrap()).await.unwrap();
println!("{} location={:?}", res.status(), res.headers().get("location"));
}
请求 /static/docs,打印出的 Location 是什么?浏览器跟着它走,会拿到 docs/index.html 吗?
预压缩文件
precompressed_gzip()、precompressed_br()、precompressed_deflate()、precompressed_zstd() 告诉 ServeDir:磁盘上除了 app.js,可能还有构建时压好的 app.js.gz、app.js.br、app.js.zz、app.js.zst。它不压缩任何东西,只负责挑一个文件。
挑选分两步。try_call 里先用 encodings 把 Accept-Encoding 解析成 (Encoding, QValue) 列表,只保留配置过的编码(这个函数和 CompressionLayer 共用,解析规则见 压缩)。然后 open_file_with_fallback 循环:
let (file, encoding) = loop {
// Get the preferred encoding among the negotiated ones.
let encoding = preferred_encoding(&mut path, &negotiated_encoding);
match (backend.open(path.clone()).await, encoding) {
(Ok(file), maybe_encoding) => break (file, maybe_encoding),
(Err(err), Some(encoding))
if err.kind() == io::ErrorKind::NotFound && encoding != Encoding::Identity =>
{
// Remove the extension corresponding to a precompressed file (.gz, .br, .zz)
// to reset the path before the next iteration.
path.set_extension(OsStr::new(""));
// Remove the encoding from the negotiated_encodings since the file doesn't exist
negotiated_encoding
.retain(|(negotiated_encoding, _)| *negotiated_encoding != encoding);
}
(Err(err), _) => return Err(err),
}
};
preferred_encoding 取 q 值最高的编码(q 值相同按 zstd > br > gzip > deflate),在文件名后面拼上扩展名去打开;不存在就把这个编码从列表里删掉、扩展名去掉,再试下一个;列表空了就打开原文件。所以只有部分文件有 .br 也没关系,缺的会退回 .gz 或原文件,代价是每缺一个变体多一次 open 系统调用。HEAD 请求走的是同样逻辑的 file_metadata_with_fallback,只取 metadata 不打开文件。
Content-Type 在选文件之前就按原始路径算好了(mime_guess 猜 app.js 而不是 app.js.gz),响应只额外加上 Content-Encoding。下面用内容是 gz / br 的假文件看选中了哪一个:
use axum::{body::Body, http::Request};
use http_body_util::BodyExt;
use std::fs;
use tower::ServiceExt;
use tower_http::services::ServeDir;
async fn show(dir: &ServeDir, uri: &str, accept: Option<&str>) {
let mut req = Request::get(uri);
if let Some(a) = accept {
req = req.header("accept-encoding", a);
}
let res = dir.clone().oneshot(req.body(Body::empty()).unwrap()).await.unwrap();
let h = |name: &str| res.headers().get(name).map(|v| v.to_str().unwrap().to_owned()).unwrap_or("-".into());
let line = format!(
"{:<8} {:<26} {} enc={} vary={} len={}",
uri,
format!("{accept:?}"),
res.status().as_u16(),
h("content-encoding"),
h("vary"),
h("content-length"),
);
let body = res.into_body().collect().await.unwrap().to_bytes();
println!("{line} body={:?}", String::from_utf8_lossy(&body));
}
#[tokio::main]
async fn main() {
let _ = fs::remove_dir_all("tmp/enc");
fs::create_dir_all("tmp/enc").unwrap();
// fake "compressed" files: the content only shows which file was picked
fs::write("tmp/enc/app.js", "plain").unwrap();
fs::write("tmp/enc/app.js.gz", "gz").unwrap();
fs::write("tmp/enc/app.js.br", "br").unwrap();
fs::write("tmp/enc/only.js", "plain").unwrap();
fs::write("tmp/enc/only.js.gz", "gz").unwrap();
let dir = ServeDir::new("tmp/enc").precompressed_gzip().precompressed_br();
for accept in [
None,
Some("gzip"),
Some("gzip, br"),
Some("br;q=0.5, gzip"),
Some("br;q=0"),
Some("zstd"),
Some("*"),
Some("gzip;q=0.5000"),
] {
show(&dir, "/app.js", accept).await;
}
show(&dir, "/only.js", Some("br, gzip")).await;
println!("-- no precompressed_* configured");
show(&ServeDir::new("tmp/enc"), "/app.js", Some("gzip, br")).await;
}
/app.js None 200 enc=- vary=accept-encoding len=5 body="plain"
/app.js Some("gzip") 200 enc=gzip vary=accept-encoding len=2 body="gz"
/app.js Some("gzip, br") 200 enc=br vary=accept-encoding len=2 body="br"
/app.js Some("br;q=0.5, gzip") 200 enc=gzip vary=accept-encoding len=2 body="gz"
/app.js Some("br;q=0") 200 enc=- vary=accept-encoding len=5 body="plain"
/app.js Some("zstd") 200 enc=- vary=accept-encoding len=5 body="plain"
/app.js Some("*") 200 enc=- vary=accept-encoding len=5 body="plain"
/app.js Some("gzip;q=0.5000") 200 enc=- vary=accept-encoding len=5 body="plain"
/only.js Some("br, gzip") 200 enc=gzip vary=accept-encoding len=2 body="gz"
-- no precompressed_* configured
/app.js Some("gzip, br") 200 enc=- vary=- len=5 body="plain"
gzip, br两个 q 值都是 1,选 br:同分时按编码的优先级。br;q=0:q=0 表示「不接受」,被过滤掉,给原文件。zstd:没配precompressed_zstd(),解析时就被丢掉了。*:ServeDir不认通配符(encodings只认具体的编码名),按「什么都没说」处理,给原文件。CompressionLayer从 0.7.0 起认通配符,两者在这一点上不一致。gzip;q=0.5000:q 值最多 3 位小数,多一位就解析失败,这一项整个被丢掉。/only.js要 br 优先,没有.br文件,退回.gz。- 只要配置了预压缩,每个响应都带
Vary: accept-encoding,包括给原文件的那些,因为同一个 URL 换个Accept-Encoding就可能得到不同的字节,缓存要按这个头区分。这是 0.7.0 的 #692 补上的;没配置预压缩时没有Vary。
Range 请求
try_parse_range 用 http-range-header 0.4.2 解析 Range 头:
let header_value = maybe_range_ref?;
let parsed = match http_range_header::parse_range_header(header_value) {
Ok(parsed) => parsed,
Err(_) => return Some(Err(RangeError::Unsatisfiable)),
};
if parsed.ranges.len() > 1 {
// ServeDir/ServeFile do not support multipart responses. Optionally ignore
// the Range header before validate() runs semantic and overlap checks.
return if ignore_multi_range_requests {
None
} else {
Some(Err(RangeError::MultipleRangesNotSupported))
};
}
Some(
parsed
.validate(file_size)
.map_err(|_| RangeError::Unsatisfiable)
.and_then(|mut ranges| ranges.pop().ok_or(RangeError::Unsatisfiable)),
)
只支持单段。多段要用 multipart/byteranges 响应体,ServeDir 不生成这种格式,默认回 416;0.7.1 起可以 ignore_multi_range_requests(true)(#727),把多段请求当成没有 Range,回完整的 200。格式错误的 Range 头和越界的单段请求,不管开不开这个开关,都是 416。
单段合法时,GET 先 seek 到起点,build_response 用 AsyncReadBody::with_capacity_limited 包一层 take(range_size),只读这一段;响应是 206,带 Content-Range: bytes start-end/size。416 响应带 Content-Range: bytes */size,并且去掉 Content-Type 和 Content-Encoding(0.7.1 的 #727:错误响应不该带着它不是的那个表示的元数据)。
条件请求:ETag 与 Last-Modified
每个成功打开的文件都从 metadata 生成两个校验器:Last-Modified 是修改时间,ETag 由 ETag::from_metadata 用「修改时间的秒数.纳秒-文件大小」拼成,全是十六进制:
let value = format!(
"\"{:x}.{:08x}-{:x}\"",
duration.as_secs(),
duration.subsec_nanos(),
size
);
ETag 是 0.7.0 加的(#691),之前只有 Last-Modified。它是强 ETag,但内容并没有参与计算:文件改了内容却保持大小和修改时间不变,ETag 就不变。注释也说明了格式会变:「Changing this format is a cache-busting event for all clients」。预压缩时这两个值来自实际打开的那个文件(比如 .gz)。
Preconditions::check 按 RFC 9110 §13.2.2 的顺序判断,注释写得很清楚:
/// Precedence order:
/// 1. If-Match (strong comparison) → 412 on failure
/// 2. If-Unmodified-Since (only if If-Match absent) → 412 on failure
/// 3. If-None-Match (weak comparison) → 304 on failure (for GET/HEAD)
/// 4. If-Modified-Since (only if If-None-Match absent) → 304 on failure
检查发生在打开文件之后、读任何字节之前。IfModifiedSince::is_modified 是 self.0 < last_modified.0,HTTP 日期只精确到秒,修改时间的纳秒部分被截掉。
下面把文件的修改时间钉在 2026-01-01 00:00:00.123456789 UTC,让 ETag 每次都一样:
use axum::{body::Body, http::Request};
use http_body_util::BodyExt;
use std::{
fs,
time::{Duration, SystemTime},
};
use tower::ServiceExt;
use tower_http::services::ServeDir;
async fn show(dir: &ServeDir, method: &str, headers: &[(&str, &str)]) {
let mut req = Request::builder().method(method).uri("/digits.txt");
for (k, v) in headers {
req = req.header(*k, *v);
}
let res = dir.clone().oneshot(req.body(Body::empty()).unwrap()).await.unwrap();
let h = |name: &str| res.headers().get(name).map(|v| v.to_str().unwrap().to_owned()).unwrap_or("-".into());
let line = format!(
"{method:<4} {:<34} {} range={} len={} type={}",
format!("{headers:?}").trim_start_matches('[').trim_end_matches(']').to_owned(),
res.status().as_u16(),
h("content-range"),
h("content-length"),
h("content-type"),
);
let body = res.into_body().collect().await.unwrap().to_bytes();
let text = String::from_utf8_lossy(&body);
if text.len() > 40 {
println!("{line} body={} bytes", text.len());
} else {
println!("{line} body={text:?}");
}
}
#[tokio::main]
async fn main() {
let _ = fs::remove_dir_all("tmp/range");
fs::create_dir_all("tmp/range").unwrap();
let digits: String = (0..100).map(|i| char::from(b'0' + (i % 10) as u8)).collect();
fs::write("tmp/range/digits.txt", &digits).unwrap();
// pin mtime so ETag / Last-Modified are the same on every run
let mtime = SystemTime::UNIX_EPOCH + Duration::new(1_767_225_600, 123_456_789);
fs::File::options().write(true).open("tmp/range/digits.txt").unwrap().set_modified(mtime).unwrap();
let dir = ServeDir::new("tmp/range");
let res = dir.clone().oneshot(Request::get("/digits.txt").body(Body::empty()).unwrap()).await.unwrap();
let etag = res.headers()["etag"].to_str().unwrap().to_owned();
let last_modified = res.headers()["last-modified"].to_str().unwrap().to_owned();
println!("etag={etag} last-modified={last_modified} accept-ranges={:?}", res.headers()["accept-ranges"]);
println!("-- Range");
for range in ["bytes=0-9", "bytes=95-", "bytes=-3", "bytes=90-200", "bytes=100-", "bytes=0-1,5-6", "bytes=abc"] {
show(&dir, "GET", &[("range", range)]).await;
}
show(&dir, "HEAD", &[("range", "bytes=0-9")]).await;
println!("-- ignore_multi_range_requests(true)");
let lenient = dir.clone().ignore_multi_range_requests(true);
show(&lenient, "GET", &[("range", "bytes=0-1,5-6")]).await;
show(&lenient, "GET", &[("range", "bytes=5-1,200-300")]).await;
show(&lenient, "GET", &[("range", "bytes=200-")]).await;
println!("-- conditional");
show(&dir, "GET", &[("if-none-match", etag.as_str())]).await;
show(&dir, "GET", &[("if-none-match", "\"other\"")]).await;
show(&dir, "GET", &[("if-modified-since", last_modified.as_str())]).await;
show(&dir, "GET", &[("if-modified-since", "Wed, 31 Dec 2025 23:59:59 GMT")]).await;
show(&dir, "GET", &[("if-none-match", "\"other\""), ("if-modified-since", last_modified.as_str())]).await;
show(&dir, "GET", &[("if-match", "\"other\"")]).await;
show(&dir, "GET", &[("if-unmodified-since", "Wed, 31 Dec 2025 23:59:59 GMT")]).await;
}
etag="6955b900.075bcd15-64" last-modified=Thu, 01 Jan 2026 00:00:00 GMT accept-ranges="bytes"
-- Range
GET ("range", "bytes=0-9") 206 range=bytes 0-9/100 len=10 type=text/plain body="0123456789"
GET ("range", "bytes=95-") 206 range=bytes 95-99/100 len=5 type=text/plain body="56789"
GET ("range", "bytes=-3") 206 range=bytes 97-99/100 len=3 type=text/plain body="789"
GET ("range", "bytes=90-200") 206 range=bytes 90-99/100 len=10 type=text/plain body="0123456789"
GET ("range", "bytes=100-") 416 range=bytes */100 len=- type=- body=""
GET ("range", "bytes=0-1,5-6") 416 range=bytes */100 len=- type=- body="Cannot serve multipart range requests"
GET ("range", "bytes=abc") 416 range=bytes */100 len=- type=- body=""
HEAD ("range", "bytes=0-9") 206 range=bytes 0-9/100 len=10 type=text/plain body=""
-- ignore_multi_range_requests(true)
GET ("range", "bytes=0-1,5-6") 200 range=- len=100 type=text/plain body=100 bytes
GET ("range", "bytes=5-1,200-300") 200 range=- len=100 type=text/plain body=100 bytes
GET ("range", "bytes=200-") 416 range=bytes */100 len=- type=- body=""
-- conditional
GET ("if-none-match", "\"6955b900.075bcd15-64\"") 304 range=- len=- type=- body=""
GET ("if-none-match", "\"other\"") 200 range=- len=100 type=text/plain body=100 bytes
GET ("if-modified-since", "Thu, 01 Jan 2026 00:00:00 GMT") 304 range=- len=- type=- body=""
GET ("if-modified-since", "Wed, 31 Dec 2025 23:59:59 GMT") 200 range=- len=100 type=text/plain body=100 bytes
GET ("if-none-match", "\"other\""), ("if-modified-since", "Thu, 01 Jan 2026 00:00:00 GMT") 200 range=- len=100 type=text/plain body=100 bytes
GET ("if-match", "\"other\"") 412 range=- len=- type=- body=""
GET ("if-unmodified-since", "Wed, 31 Dec 2025 23:59:59 GMT") 412 range=- len=- type=- body=""
- ETag
"6955b900.075bcd15-64":0x6955b900= 1767225600 秒,0x075bcd15= 123456789 纳秒,0x64= 100 字节。 - 每个 200 / 206 响应都带
Accept-Ranges: bytes,告诉客户端可以发Range。 bytes=90-200末尾越界会被截到 99,起点越界(bytes=100-)才是 416。bytes=5-1,200-300每一段都不合法,开了ignore_multi_range_requests照样 200:多段的判断在validate之前,内容根本没被检查。- 倒数第三行:
If-None-Match不匹配、If-Modified-Since等于修改时间,结果是 200。有If-None-Match时If-Modified-Since被整个忽略,和注释里的第 4 条一致。
读文件:默认 64 KiB 一块
打开的文件交给 AsyncReadBody,它包着 tokio-util 0.7.20 的 ReaderStream,每次 poll_frame 读一块,块大小就是 buf_chunk_size,默认值是 mod.rs 里的 DEFAULT_CAPACITY(65536)。文件不会整个读进内存,响应带 Content-Length(文件大小),body 是流。
use axum::{body::Body, http::Request};
use http_body_util::BodyExt;
use tower::ServiceExt;
use tower_http::services::ServeDir;
#[tokio::main]
async fn main() {
std::fs::create_dir_all("tmp/chunks").unwrap();
std::fs::write("tmp/chunks/big.bin", vec![b'x'; 200_000]).unwrap();
for (name, dir) in [
("default", ServeDir::new("tmp/chunks")),
("16 KiB", ServeDir::new("tmp/chunks").with_buf_chunk_size(16 * 1024)),
] {
let req = Request::get("/big.bin").body(Body::empty()).unwrap();
let mut body = dir.oneshot(req).await.unwrap().into_body();
let mut sizes = Vec::new();
while let Some(frame) = body.frame().await {
sizes.push(frame.unwrap().into_data().unwrap().len());
}
println!("{name:<8} frames={} sizes={:?}", sizes.len(), sizes);
}
}
default frames=4 sizes=[65536, 65536, 65536, 3392]
16 KiB frames=13 sizes=[16384, 16384, 16384, 16384, 16384, 16384, 16384, 16384, 16384, 16384, 16384, 16384, 3392]
200 000 字节按 64 KiB 切成 3 个整块加 3392 字节的尾巴。块大小决定每次从文件读多少、交给 hyper 的每一帧有多大(帧怎么编码上线见 body 的编解码);块越大,读的次数越少,每个正在下载的响应占的缓冲越大。
fallback 与 not_found_service
fallback(svc) 在「文件不存在」和「路径不合法」时调用另一个服务,状态码由它决定;not_found_service 是 fallback(SetStatus::new(svc, StatusCode::NOT_FOUND)),强制改成 404。单页应用常用 fallback(ServeFile::new("index.html")),让前端路由的地址都拿到同一个页面。
try_call 在开头就把请求拆了:body 和 extensions 移给将来可能要调用的 fallback,ServeDir 自己只留一个 Empty body 的请求。注释写得直白:「ServeDir doesn't care about the request body but the fallback might. So move out the body and pass it to the fallback」。fallback 服务本身也是先 clone 一份放回去,把已经 poll_ready 过的那一份拿走用(「get the ready fallback and leave a non-ready clone in its place」)。clone 出来的副本不带就绪状态,所以要用的是原来那份,这个套路见 tower 卷 poll_ready must be called first。
try_call 与 IO 错误:0.7.1 的行为变化
ServeDir 有两个入口。Service::call 永远返回响应;try_call 返回 Result<Response, io::Error>,留给想自己决定错误响应的人。call 就是 try_call 再把错误映射一次(Service::call):
let response = result.unwrap_or_else(|err| {
let status = if should_return_not_found(&err) {
StatusCode::NOT_FOUND
} else {
#[cfg(feature = "tracing")]
tracing::error!(error = %err, "Failed to read file");
StatusCode::INTERNAL_SERVER_ERROR
};
// ... 空 body
});
should_return_not_found 认三种:NotFound、PermissionDenied,以及 Unix 上的 ENOTDIR(错误码 20,路径中间某一段其实是文件,比如 /a.txt/x)。其余 IO 错误是 500,开了 tracing feature 时记一条 error 日志。权限不足也回 404 而不是 403,外面分不出「不存在」和「读不了」。
0.7.1 之前,这三种错误在 try_call 内部就被换成了 Ok(404),try_call 的调用方根本看不到它们。现在 future.rs 里没有 fallback 时直接交出错误:
Err(err) => {
if super::should_return_not_found(&err) {
if let Some((mut fallback, request)) = fallback_and_request.take() {
call_fallback(&mut fallback, request)
} else {
break Poll::Ready(Err(err));
}
} else {
break Poll::Ready(Err(err));
}
}
同一个 PR 还让「判断是不是目录」的那次 metadata 不再吞掉错误:is_dir 只把 NotFound 和非法文件名当成「不存在」,其他错误用 ? 向上传。
use axum::{body::Body, http::Request};
use http_body_util::BodyExt;
use std::{fs, os::unix::fs::PermissionsExt};
use tower::ServiceExt;
use tower_http::services::{ServeDir, ServeFile};
fn req(uri: &str) -> Request<Body> {
Request::get(uri).body(Body::empty()).unwrap()
}
#[tokio::main]
async fn main() {
// undo the previous run's chmod so the directory can be removed
let _ = fs::set_permissions("tmp/try/site/locked", fs::Permissions::from_mode(0o755));
let _ = fs::remove_dir_all("tmp/try");
fs::create_dir_all("tmp/try/site/locked").unwrap();
fs::write("tmp/try/site/a.txt", "A").unwrap();
fs::write("tmp/try/site/locked/f.txt", "F").unwrap();
fs::write("tmp/try/site/nope.txt", "N").unwrap();
fs::write("tmp/try/404.html", "custom 404").unwrap();
fs::set_permissions("tmp/try/site/nope.txt", fs::Permissions::from_mode(0o000)).unwrap();
fs::set_permissions("tmp/try/site/locked", fs::Permissions::from_mode(0o000)).unwrap();
let uris = ["/a.txt", "/missing.txt", "/../a.txt", "/..%5ca.txt", "/a.txt/x", "/nope.txt", "/locked/f.txt"];
println!("{:<16} {:<44} {}", "uri", "try_call", "call");
for uri in uris {
let mut dir = ServeDir::new("tmp/try/site");
let tried = match dir.try_call(req(uri)).await {
Ok(res) => format!("Ok({})", res.status()),
Err(err) => format!("Err({:?}, os={:?})", err.kind(), err.raw_os_error()),
};
let called = ServeDir::new("tmp/try/site").oneshot(req(uri)).await.unwrap().status();
println!("{uri:<16} {tried:<44} {called}");
}
println!("-- with not_found_service");
for uri in ["/missing.txt", "/../a.txt", "/locked/f.txt"] {
let mut dir = ServeDir::new("tmp/try/site").not_found_service(ServeFile::new("tmp/try/404.html"));
match dir.try_call(req(uri)).await {
Ok(res) => {
let status = res.status();
let body = res.into_body().collect().await.unwrap().to_bytes();
println!("{uri:<16} Ok({status}) {:?}", String::from_utf8_lossy(&body));
}
Err(err) => println!("{uri:<16} Err({:?})", err.kind()),
}
}
}
以普通用户身份运行(root 不受 chmod 000 限制):
uri try_call call
/a.txt Ok(200 OK) 200 OK
/missing.txt Err(NotFound, os=Some(2)) 404 Not Found
/../a.txt Ok(404 Not Found) 404 Not Found
/..%5ca.txt Err(NotFound, os=Some(2)) 404 Not Found
/a.txt/x Err(NotADirectory, os=Some(20)) 404 Not Found
/nope.txt Err(PermissionDenied, os=Some(13)) 404 Not Found
/locked/f.txt Err(PermissionDenied, os=Some(13)) 404 Not Found
-- with not_found_service
/missing.txt Ok(404 Not Found) "custom 404"
/../a.txt Ok(404 Not Found) "custom 404"
/locked/f.txt Ok(404 Not Found) "custom 404"
- 对
call的使用者(包括 axum 里的nest_service)什么都没变,右边一列全是 404。 try_call里能分出两类 404:/../a.txt是路径不合法,在碰文件系统之前就被拒绝,所以仍是Ok(404);/..%5ca.txt在 macOS 上是合法路径,去文件系统找了,所以是Err(NotFound)。/locked/f.txt的错误来自is_dir那次metadata,正是 #718 让它不再被吞掉。- 配了 fallback,这几种错误仍然交给 fallback,
try_call不返回Err。CHANGELOG 把这一条标为 behavioral change;如果你在 0.7.0 及以前包过try_call、并且把Ok(404)当成「文件不存在」处理,升级后要改成处理Err。
try_call 的文档和行为不一致了三年多try_call 是 0.4.0 的 #283 引入的,那次把 ServeDir 的错误类型改成了 Infallible,文档写「Call the service and get a future that contains any std::io::Error that might have happened.」可是同一个 PR 的 future.rs 在没有 fallback 时把 NotFound / PermissionDenied 直接变成 Ok(not_found())(ENOTDIR 由同一版本的 #331 加进这个分支)。
2023 年 9 月的 issue #412「ServeDir::try_call does not propagate result as documented」(报告时用的是 0.4.4)指出:「When using ServeDir::try_call to request a path that does not exist, it returns Ok(404) instead of an Err」。修复是 #718「Propagate ServeDir::try_call I/O errors」,做法是「Propagate expected filesystem I/O errors from try_call when no fallback is configured」,同时「Let the infallible Service::call implementation convert NotFound, PermissionDenied, and Unix ENOTDIR errors to 404 responses」:把映射从 future.rs 搬到了 Service::call 里,也就是上面那个 should_return_not_found。随 0.7.1 发布。
动脑筋答案
307 Temporary Redirect location=Some("/docs/")
跑 3 次相同。Location 是 /docs/,丢了 /static。Location 是从 ServeDir 看到的 req.uri() 拼出来的,而 nest_service 在把请求交给它之前已经剥掉了前缀,URI 只剩 /docs(剥前缀的过程见 nest、merge 与 fallback)。浏览器跟着跳到 /docs/,那是 Router 里另一个地址,在这个应用里会是 404。
这正是 0.7.0 加 redirect_path_prefix 的原因。#486 的说明写道:「Currently ServeDir assumes that the files are served from /, which breaks trailing slash redirects in some configurations like a reverse proxy ... or a scenario where axum::Router::nest_service is used.」用法是 ServeDir::new(...).redirect_path_prefix("/static") 把这段前缀拼在 Location 前面,结果是 /static/docs/。也可以用 redirect_to_trailing_slash(false) 干脆不重定向。
小结
ServeDir是现成的Service,错误类型是Infallible,ServeFile是它的单文件变体。- 路径防线在
build_and_validate_path:先解码再拆段,只接受普通段,..一律 404;反斜杠在 Unix 上不是分隔符,文档那句「含反斜杠就 404」在 Unix 上不成立。 - 目录无尾斜杠时 307 到带斜杠的地址,query 保留;挂在前缀下要配
redirect_path_prefix;0.7.1 可以用redirect_to_trailing_slash(false)关掉重定向。 - 预压缩只挑文件:按 q 值和 zstd > br > gzip > deflate 依次尝试,缺了就退回;不认
*;配置后所有响应都带Vary: accept-encoding。 - Range 只支持单段,多段默认 416,0.7.1 可以改成忽略;条件请求按 If-Match → If-Unmodified-Since → If-None-Match → If-Modified-Since 的顺序,ETag 由修改时间和大小拼成。
- body 按
buf_chunk_size(默认 65536 字节)分块流出。 - 0.7.1 起
try_call在没有 fallback 时交出NotFound/PermissionDenied/ENOTDIR,call仍把它们变成 404。
0.7.0 的 Backend trait 见 tower-http 卷 ServeDir 的 Backend trait,Redirect 服务见 Redirect 服务,ServeDir 为什么是 Infallible 见 四件套 的冷知识;opaque_body! 和 opaque_future! 两个宏见其中的 四件套。