NormalizePath 之后目录无限 307,文件 404
主线位置:作用点主线的第一个时刻。
NormalizePath只在call时改写 URI,不回重定向;ServeDir在call之后自己决定回 307 还是 404。两层各自对尾斜杠有一套主意,放在一起就会互相抵消。NormalizePath的写法见 小型改写层。
flowchart TB a["GET /static/guide"] --> sd1["ServeDir:是目录,307 到 /static/guide/"] sd1 --> b["浏览器 GET /static/guide/"] b --> np["NormalizePath trim:改成 /static/guide"] np --> sd2["ServeDir:又是没有斜杠的目录,307"] sd2 --> b
先排除最常见的一种
规划时这一页的标题是「NormalizePathLayer 不生效,/foo/ 仍然 404」:在 axum 里用 Router::layer 套上时,路由已经匹配完了,改 URI 为时已晚,要用 Layer::layer(router) 包在 Router 外面。这件事 axum 卷 /foo/ 返回 404 已经完整跑过,这里不重复。下面看包对了以后,和 ServeDir 一起用时出的事。
复现
一个目录 guide/(里面有 index.html)和一个文件 a.txt,用 nest_service("/static", ServeDir) 挂上。walk 手动跟随重定向,最多 5 跳,打印每一跳:
tower-http = { version = "=0.7.1", features = ["fs", "normalize-path"] }
axum = { version = "=0.8.9", default-features = false }
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt", "fs"] }
use axum::body::Body;
use axum::extract::Request;
use axum::http::header;
use axum::response::Response;
use axum::Router;
use tower::{Layer, Service, ServiceExt};
use tower_http::normalize_path::NormalizePathLayer;
use tower_http::services::ServeDir;
// 手动跟随重定向,最多 5 跳
async fn walk<S>(name: &str, svc: S, start: &str)
where
S: Service<Request, Response = Response> + Clone,
S::Error: std::fmt::Debug,
{
let mut uri = start.to_owned();
let mut hops = Vec::new();
for _ in 0..5 {
let res = svc.clone().oneshot(Request::get(&uri).body(Body::empty()).unwrap()).await.unwrap();
let status = res.status().as_u16();
match res.headers().get(header::LOCATION) {
Some(loc) if res.status().is_redirection() => {
hops.push(format!("{uri} {status}"));
uri = loc.to_str().unwrap().to_owned();
}
_ => {
hops.push(format!("{uri} {status}"));
break;
}
}
}
println!("{name:<34} {}", hops.join(" -> "));
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let root = std::env::temp_dir().join("p07-normalize-root");
tokio::fs::create_dir_all(root.join("guide")).await.unwrap();
tokio::fs::write(root.join("guide/index.html"), "guide").await.unwrap();
tokio::fs::write(root.join("a.txt"), "a").await.unwrap();
let site = |dir: ServeDir| Router::new().nest_service("/static", dir);
let plain = || ServeDir::new(&root).redirect_path_prefix("/static");
walk("ServeDir only, no prefix", site(ServeDir::new(&root)), "/static/guide").await;
walk("ServeDir only", site(plain()), "/static/guide").await;
walk("ServeDir only", site(plain()), "/static/a.txt/").await;
walk("trim wraps Router", NormalizePathLayer::trim_trailing_slash().layer(site(plain())), "/static/guide").await;
walk("trim wraps Router", NormalizePathLayer::trim_trailing_slash().layer(site(plain())), "/static/a.txt/").await;
walk(
"trim + redirect_to_trailing_slash(false)",
NormalizePathLayer::trim_trailing_slash().layer(site(plain().redirect_to_trailing_slash(false))),
"/static/guide",
)
.await;
walk("append wraps Router", NormalizePathLayer::append_trailing_slash().layer(site(plain())), "/static/a.txt").await;
walk("append wraps Router", NormalizePathLayer::append_trailing_slash().layer(site(plain())), "/static/guide").await;
tokio::fs::remove_dir_all(&root).await.unwrap();
}
M2 Max、rustc 1.99.0,跑 3 次输出相同:
ServeDir only, no prefix /static/guide 307 -> /guide/ 404
ServeDir only /static/guide 307 -> /static/guide/ 200
ServeDir only /static/a.txt/ 404
trim wraps Router /static/guide 307 -> /static/guide/ 307 -> /static/guide/ 307 -> /static/guide/ 307 -> /static/guide/ 307
trim wraps Router /static/a.txt/ 200
trim + redirect_to_trailing_slash(false) /static/guide 200
append wraps Router /static/a.txt 404
append wraps Router /static/guide 200
三件事
一、去尾斜杠遇上目录:死循环。 第四行。ServeDir 看到不带斜杠的目录,回 307 到带斜杠的地址(open_file.rs 第 441 行 那个分支);浏览器跟过去,NormalizePath 又把斜杠去掉,ServeDir 再回 307。浏览器跳若干次后报「重定向次数过多」。服务端日志里只有一串 307,每一个单看都正确。
0.7.1 的 redirect_to_trailing_slash(false)(#728)解决这个:不跳,直接在 /static/guide 上回 index.html(第六行)。0.7.0 以前没有这个开关,只能不让 NormalizePath 罩住 ServeDir。
二、加尾斜杠遇上文件:404。 倒数第二行。append_trailing_slash 不区分文件和目录,/static/a.txt 被改成 /static/a.txt/;0.7.0 的 #678 让 ServeDir 对带尾斜杠的文件路径回 404(open_file.rs 第 423 行),第三行单独请求 /static/a.txt/ 也是 404。0.6.11 里带尾斜杠的文件路径照样回 200(升到 0.7.1 会怎样 里两个版本对照跑过),所以这个组合在 0.6 能用,升级后所有静态文件一起 404,CHANGELOG 把 #678 标成了 breaking。
三、nest_service 下的目录跳转丢前缀。 第一行。axum 的 nest_service 把 /static 剥掉再交给 ServeDir,ServeDir 只看到 /guide,于是跳到 /guide/,落在挂载点之外,404。0.7.0 的 redirect_path_prefix(#486)把前缀补回去(第二行)。上面一、二两件事的实验都加了这个前缀,否则第一跳就已经跳出挂载点,看不到循环。
修法
- 静态文件不要放在
NormalizePath下面。NormalizePath适合 API:调用方只要别 404,不在乎规范地址(axum 卷那一页的比较表)。 - 一定要罩住时:去尾斜杠配
ServeDir::redirect_to_trailing_slash(false);不要用加尾斜杠模式罩文件。 - 用
nest_service挂ServeDir时配redirect_path_prefix,值和挂载路径相同。 - 面向浏览器的页面要一个规范地址,用一个回 308 的中间件,而不是
NormalizePath。它和ServeDir的目录跳转方向相反时同样会循环,本站正是这种组合,见 升到 0.7.1 会怎样。
小结
NormalizePath必须包在Router外面才生效(axum 卷);包在外面后,它和ServeDir对尾斜杠的处理会冲突。- 去尾斜杠 + 目录 = 无限 307,0.7.1 用
redirect_to_trailing_slash(false)解开;加尾斜杠 + 0.7 的文件 = 404(#678)。 nest_service下ServeDir的目录跳转会丢掉挂载前缀,0.7.0 起用redirect_path_prefix补上。