功能说明
用户上传、压缩包解压和模板渲染常把外部提供的相对路径拼到根目录下。直接拼接字符串会让 ../../etc/passwd 这样的输入逃出根目录。这个函数逐段处理路径:普通段追加到结果,当前目录段忽略,上级段必须在已有深度大于零时才能弹出一段,绝对路径和平台前缀直接拒绝。
处理完成后还会拼到根目录并再次检查前缀,形成双保险。main 用五组输入演示合法路径、内部回退、越界回退、绝对路径和连续越界。
关键代码
Path::components 会按平台规则拆分路径,Windows 的反斜杠也能识别。深度计数与 PathBuf::pop 同步,确保每弹出一段都有对应的已压入段;一旦深度为零又遇到上级段立即失败。root.join 之后用 starts_with 比较路径组件而不是字符串前缀,避免相似目录名绕过。错误信息包含原始输入,便于审计。
使用注意
该函数只做词法规范化,不解析符号链接;根目录内若有指向外部的链接,仍需在打开文件时使用 nofollow 或解析真实路径。Windows 保留设备名和结尾点号问题需另行处理。空路径会返回根目录本身,业务上应拒绝。路径长度限制由文件系统决定。安全检查与真正打开文件之间存在竞态,高安全场景应基于文件描述符操作。
验证方式
reports/2026/q1.csv 正常拼接;reports/../notes.txt 规范化后仍位于根目录内,允许通过;以两个点开头的路径、绝对路径 /etc/passwd 以及 a/../../b 都被拒绝,输出 skip 与原因。可以加入双反斜杠、空段和只含当前目录的输入,确认结果仍不越界。
代码示例
use std::path::{Component, Path, PathBuf};
fn normalize_join(root: &Path, untrusted: &str) -> Result<PathBuf, String> {
let candidate = Path::new(untrusted);
if candidate.is_absolute() {
return Err(format!("rejected absolute path: {untrusted}"));
}
let mut depth = 0usize;
let mut normalized = PathBuf::new();
for component in candidate.components() {
match component {
Component::Normal(part) => {
normalized.push(part);
depth += 1;
}
Component::CurDir => {}
Component::ParentDir => {
if depth == 0 {
return Err(format!("path escapes root: {untrusted}"));
}
normalized.pop();
depth -= 1;
}
_ => return Err(format!("unsupported path component in {untrusted}")),
}
}
let joined = root.join(&normalized);
if !joined.starts_with(root) {
return Err(format!("resolved path escapes root: {untrusted}"));
}
Ok(joined)
}
fn main() {
let root = Path::new("/srv/uploads");
let candidates = [
"reports/2026/q1.csv",
"reports/../notes.txt",
"../secrets.env",
"/etc/passwd",
"a/../../b",
];
for candidate in candidates {
match normalize_join(root, candidate) {
Ok(path) => println!("ok {candidate:<22} => {}", path.display()),
Err(error) => println!("skip {candidate:<22} => {error}"),
}
}
}