功能说明
压缩包解压是路径穿越的高发场景:条目名可以写成绝对路径或以多个上级目录开头,天真的实现会把文件写到目标目录之外,甚至覆盖系统文件。这个防护函数在真正创建文件之前检查每个条目,只允许层层向下的相对路径。
main 用五组典型条目演示:正常文件、包内回退、越界回退、绝对路径和连续越界。允许包内回退是因为 docs/../notes.txt 最终仍落在目标目录内,属于合法整理;只要任何一步会爬到目标之外就拒绝。
关键代码
Path::components 把条目名拆成普通段、当前目录段、上级段和根前缀。深度计数与 output.pop 同步,遇到上级段时先确认深度大于零,再弹出一段。绝对路径与平台前缀直接拒绝。最后用 starts_with 按路径组件比较,确保拼接结果仍在目标目录中,避免相似前缀绕过。
使用注意
符号链接条目是另一类风险:即使路径合法,链接也可能指向外部,解压后写入会跟随链接。实际使用 tar 库时应禁止或单独校验链接目标,并优先使用不跟随链接的创建标志。该函数不处理 Windows 设备名和以点结尾的文件名。目标目录本身应使用规范化后的绝对路径。检查与写入之间仍有竞态,高安全场景应直接在解压库中启用路径校验。
验证方式
reports/2026.csv 与 docs/../notes.txt 被允许,输出最终路径;以两个点开头、绝对路径以及 a/../../b 都被拒绝并给出原因。可以加入空名称与只含当前目录的条目,确认前者被拒绝、后者指向目标目录本身。再构造一个符号链接条目,提醒需要额外的链接检查。
代码示例
use std::path::{Component, Path, PathBuf};
fn safe_entry_path(destination: &Path, entry_name: &str) -> Result<PathBuf, String> {
let entry = Path::new(entry_name);
if entry.is_absolute() {
return Err(format!("absolute entry rejected: {entry_name}"));
}
let mut output = destination.to_path_buf();
let mut depth = 0usize;
for component in entry.components() {
match component {
Component::Normal(part) => {
output.push(part);
depth += 1;
}
Component::CurDir => {}
Component::ParentDir => {
if depth == 0 {
return Err(format!("entry escapes destination: {entry_name}"));
}
output.pop();
depth -= 1;
}
_ => return Err(format!("unsupported component in {entry_name}")),
}
}
if !output.starts_with(destination) {
return Err(format!("resolved entry escapes destination: {entry_name}"));
}
Ok(output)
}
fn main() {
let destination = Path::new("/var/lib/app/uploads");
let entries = [
"reports/2026.csv",
"docs/../notes.txt",
"../etc/cron.d/job",
"/etc/passwd",
"a/../../b",
];
for entry in entries {
match safe_entry_path(destination, entry) {
Ok(path) => println!("ok {entry:<22} => {}", path.display()),
Err(error) => println!("skip {entry:<22} => {error}"),
}
}
}