Rust in Everything
基于 tower-http 0.7.1

ServeDir 与 ServeFile

主线位置:请求主线的末端。ServeDir 不是 handler,而是一个现成的 tower Service,通常用 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 加的 Backend trait(#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 上反斜杠本来就无法用来穿越目录。
📝NOTE
冷知识: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,「/dir serves /dir/index.html directly when directory indexes are enabled」),默认仍然重定向。关掉之后 /docs 直接给出 index.html,代价是上面说的相对链接问题。
  • append_index_html_on_directories(false):目录一律 404,ServeDir 不会生成目录列表。
❗IMPORTANT
动脑筋:挂在 /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。
📝NOTE
冷知识: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! 两个宏见其中的 四件套。

💬 关联讨论

发起讨论
加载中...