path/filepath — 路径运算与遍历安全
path/filepath 负责"路径字符串的代数":拼接、规范化、取扩展名、算相对路径、按模式匹配、遍历目录树。它按 GOOS 决定分隔符和绝对路径的形状(Windows 用 \ 与盘符,Unix 用 /)。path 是同构的包,只是永远按 / 处理——URL、HTTP 路由、归档内的路径用 path,真实文件系统用 path/filepath,这条边界一旦混用就会出跨平台 bug。
这一章的重点不是函数清单(go doc path/filepath 更齐全),而是两件在真实代码里反复出事的事:“这个路径还在我允许的目录里吗”,以及 WalkDir 里那些返回值的正确用法。除特别说明,本文的布尔值与错误文本都是 go1.27.0 / Windows 11 实测。
1. 核心函数速查
filepath.Join("a", "b", "c") // 拼接并 Clean:一次搞定分隔符
filepath.Clean("a/./b/../c") // "c"(Windows 上同为 "c")
filepath.Base("a/b/c.go") // "c.go"
filepath.Dir("a/b/c.go") // "a\b"
filepath.Ext("a.b.c") // ".c"
filepath.Split("a/b/c.txt") // dir="a/b/", file="c.txt"(dir 保留尾分隔符)
filepath.IsAbs("/a") // Windows: true;Unix: true
filepath.Abs("x/y") // 以当前工作目录为基准
filepath.Rel("a/b/c", "a/b/d/e") // Windows: "..\d\e"
filepath.VolumeName(`C:\a\b`) // `C:`
filepath.FromSlash("a/b") // 斜杠 -> 平台分隔符
filepath.ToSlash(`a\b`) // 平台分隔符 -> 斜杠
filepath.Match("*.go", "main.go") // true
filepath.Glob("data/*.csv") // 匹配的文件名列表
filepath.WalkDir(root, fn) // 遍历目录树
filepath.EvalSymlinks(p) // 解析路径上的符号链接
filepath.SameFile(fi1, fi2) // 是否同一文件(比字符串比较可靠)
filepath.SplitList(pathEnv) // 按 ListSeparator 切开 PATH几条实测边界,写判空逻辑时最容易撞上:
filepath.Base("") // "." ← 不是空串
filepath.Base("/") // `\` (Windows 上是根,Unix 上是 "/")
filepath.Base("a/") // "a"
filepath.Dir("a") // "."
filepath.Ext(".hidden") // ".hidden" ← 点开头没有 basename,整个当扩展名
filepath.Ext("a.") // "."
filepath.Clean("") // "."
filepath.Clean("a//b") // `a\b`
filepath.Join("a", "/b", "c") // `a\b\c`:Join 会剥掉元素的多余分隔符再拼
filepath.Join("a", "") // "a"
filepath.Join("a", "..", "b") // "b" ← Join 隐含 Clean,会做词法抵消陷阱:
filepath.Join里藏了一次Clean,所以Join(base, userInput)会静默抵消../,路径看起来"还在 base 下面",实际指向了别处。这正是HasPrefix校验被绕过的机制之一(下一节展开)。想保留原样拼接的冲动,几乎总是错的。
Rel 只保证词法关系,两个参数都应是 Clean 过的路径;不同根之间它直接报错——实测 filepath.Rel("C:\\a", "D:\\b") 返回 Rel: can't make D:\b relative to C:\a。跨盘符/跨 UNC 主机的相对路径不存在,别去模拟它。
2. 路径遍历防护:filepath.IsLocal 与 os.Root
2.1 旧写法的缺口
手册早期版本给的是这个函数:
// 旧写法:不要再用
func safeReadFile(baseDir, userPath string) ([]byte, error) {
fullPath := filepath.Join(baseDir, filepath.Clean(userPath))
if !strings.HasPrefix(fullPath, filepath.Clean(baseDir)) {
return nil, fmt.Errorf("invalid path: %s", userPath)
}
return os.ReadFile(fullPath)
}它看起来严密,实际有两个洞:
- 前缀比较把兄弟目录也算"在里面"。
baseDir是/data/uploads,而/data/uploads-secret/x.txt、/data/uploadsX/a.txt都以/data/uploads开头。攻击者控制的字符串只要落在这些兄弟目录里,就能通过检查。 - 归一化不对等。比较用的
filepath.Clean(baseDir)与Join结果的分隔符、大小写(Windows 文件系统大小写不敏感)、短文件名(PROGRA~1)形式未必一致。实测还有一条更直接的:Windows 上filepath.Join("/data/uploads", "/data/uploadsX/ok.txt")得到\data\uploads\data\uploadsX\ok.txt——因为 Windows 不认/data/...这种绝对形状,把它当普通片段拼了进去。也就是说,“用 Unix 风格字符串做词法推理"这件事本身就不跨平台。
正确做法靠两个标准件:判断用 filepath.IsLocal(Go 1.20 加入,GOROOT api/go1.20.txt 可查),落地用 os.Root(Go 1.24 才加入)。
2.2 filepath.IsLocal:词法上的"你出不了这个目录”
func safeReadFile(baseDir, userPath string) ([]byte, error) {
if !filepath.IsLocal(userPath) { // 纯词法判断,快且可靠
return nil, fmt.Errorf("not a local path: %q", userPath)
}
return os.ReadFile(filepath.Join(baseDir, userPath))
}文档给的契约很硬:IsLocal(p) 为真 ⇒ Join(base, p) 一定落在 base 内,且 Clean(p) 是无根、不含 .. 的路径。于是"路径还在允许范围内"这句话不再需要你自己实现。
实测取值(go1.27.0 / Windows):
"a.txt" true "a/b.txt" true `a\b.txt` true "sub/../ok.txt" true
"a/" true "a//b" true "./x" true "x" true "." true
".." false "../x" false "" false "/" false `\x` false
"C:\x" false "/etc/passwd" false "a/../../b" false
"NUL" false "com1" false "con" false "nul.txt" true "com1.txt" true
`\\?\C:\x` false `\\.\COM1` false `\\srv\share\x` false三条要点:. 是合法的局部路径(它指向目录本身,Join 后就是 base);Windows 保留名会被拒绝,这是 HasPrefix 那套写法完全没有的防护;IsLocal 不做文件系统查询,不跟随符号链接,也不看路径是否存在,所以它是"输入校验"而不是"访问控制"——真正要挡住符号链接逃逸得上 os.Root。
2.3 os.Root:带边界的文件访问对象
root, err := os.OpenRoot(baseDir) // 打开一次,后续所有操作都被限制在这棵子树
if err != nil {
return err
}
defer root.Close()
data, err := root.ReadFile("sub/in.txt")
f, err := root.Create("out.json")
sub, err := root.OpenRoot("sub") // 继续收窄作用域
entries, err := root.ReadDir(".")
err = fs.WalkDir(root.FS(), ".", func(p string, d fs.DirEntry, err error) error { ... })实测越界行为(root 之外确实存在 out.bin):
_, err := root.ReadFile("../out.bin")
// openat ../out.bin: path escapes from parent
// 结构:&fs.PathError{Op:"openat", Path:"../out.bin", Err: <"path escapes from parent">}
// errors.Is(err, os.ErrInvalid) = false,errors.Is(err, os.ErrNotExist) 也是 false所以这个错误只能按"非 nil 就拒绝"处理,不能用常见哨兵错误分类——这也是它与 fs.FS 契约的差异:os.DirFS(dir) 读到 .. 时报的是 readfile ../out.bin: invalid argument,errors.Is(err, os.ErrInvalid) 为 true(因为 fs.ValidPath 先拒绝了非法名字)。
顺带一条与上传处理直接相关的实测:把 "\x00" 传给 root.ReadFile 得到 openat <NUL>: invalid argument,这一条倒是 os.ErrInvalid(filepath.IsLocal("\x00") 为 true,词法检查放行的怪名字会在系统调用层被拦住)。文档还明确:Root 的方法可多 goroutine 并发使用;它会跟随符号链接,但链接指向根外或链接本身是绝对路径时会被拒。
3. 遍历目录树
filepath.WalkDir 是 Go 1.16 起的默认选择(比老的 Walk 便宜:fs.DirEntry 不预先取 Info)。
err := filepath.WalkDir(".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
// 无法 Stat/ReadDir 的节点:d 可能是 nil,先判 err 再用 d
return err // 中止整棵树
// 或 return nil // 忽略这一个节点,继续遍历
}
if d.IsDir() {
switch d.Name() {
case ".git", "node_modules":
return filepath.SkipDir // 跳过这个目录的剩余内容
}
return nil
}
if filepath.Ext(p) == ".sum" {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
fmt.Println(p, info.Size())
return nil
})几个必须记住的规则:
WalkDir不跟随符号链接:树里的符号链接目录只作为一个条目出现,不会递归进去(文档原文:WalkDir does not follow symbolic links)。所以"遍历 + 读取内容"的组合在符号链接面前不会失控,但也不等于你扫到了全部数据。SkipDir用在目录上跳过该目录剩余内容,用在文件上跳过所在目录的剩余文件(两个方向都能用,别只当目录专用)。filepath.SkipAll则直接结束遍历。- 回调里的
err != nil时d可能为nil:先判错再用d,否则会 panic。这是"遍历别人的目录就崩"的头号原因(权限不足、文件在遍历期间被删)。 - 传给回调的
p是从 root 参数拼出来的相对/绝对路径(root 是"."就是./x/y形状),拿去和别的来源比较前记得Clean。 - 需要
os.Root的安全边界时,遍历用fs.WalkDir(root.FS(), ".", ...):p变成合法fs路径,非法名字在源头就被挡住。
4. Glob 与 Match:** 不存在
m, err := filepath.Match("*.go", "main.go") // true, nil
m, err = filepath.Match("a**c", "abc") // true, nil —— 连续星号折叠成单个 *,不报错(实测)
_, err = filepath.Glob("[") // syntax error in pattern —— 真·坏语法才报 ErrBadPattern(实测)实测结论:
**不是递归通配符,而且不报错。实测filepath.Match("a**c", "abc")返回true, nil——scanChunk把连续的*折叠成一个(a**c与a*c语义相同)。Glob("t/**/*.go")因此等价于"t/*/*.go":恰好匹配一段任意路径分量,实测命中t/nested/x.go,比它深的t/nested/deep/c.go和比它浅的t/y.go都不中。要递归只能WalkDir+ 自己判后缀(doublestar这类第三方库就是补这个能力的),别指望**的"静默降级"替你做事。*拒绝跨越的是filepath.Separator,而它按平台取值:Windows 上/只是普通字符,实测本机Match("a**c", "a/c")为true;path.Match则永远把/当分隔符(源码里 star 重试循环遇到/直接放弃)。同一份"含斜杠模式 + 含斜杠名字"的匹配,在两个平台可以得出相反结论——FS 风格路径(/分隔)要匹配就用path.Match或先filepath.ToSlash归一再比。单*不跨当前平台分隔符这点仍成立:实测Match("a/*/c", "a/b/c/d")为 false、对"a/b/c"为 true。- 语法非法(
[未闭合等)时Glob/Match返回filepath.ErrBadPattern,实测文本syntax error in pattern。两个相关事实:io/fs没有ErrBadPattern(引用它直接编译报undefined: fs.ErrBadPattern);filepath.ErrBadPattern与path.ErrBadPattern是文本相同但不同对象的两个哨兵(实测filepath.Glob的错误== path.ErrBadPattern为 false),跨包用==或errors.Is比对会全军覆没。匹配失败但模式合法时只是false, nil,别用err == nil当"匹配上了"。 Glob只匹配实际存在的文件(它会做目录列举),无匹配时返回空切片、err为 nil;fs.Glob(fsys, pattern)对含..的 pattern 报pattern contains path separator/非法路径类错误(实测:fs.Glob要求 pattern 是合法 FS 路径)。- 需要大小写不敏感匹配(Windows 用户习惯)时标准库不提供,只能两边
strings.EqualFold自己比。
5. EvalSymlinks:解析符号链接,但别指望它做防护
p, err := filepath.EvalSymlinks("t/../t/a.go") // 实测 -> 绝对路径的规范形式,err=nil
_, err = filepath.EvalSymlinks("no-such-path") // 报错(它要求路径存在)它做三件事:解析路径上的每个符号链接、返回规范化结果、要求路径真实存在(不存在就报错)。所以:
- 拿它做"路径是否存在 + 规范形式"的校验可以,但它是TOCTOU 不安全的:检查完成到真正打开之间,别人可以把某个成分换成符号链接。安全边界要靠
os.Root(内核层面的限制),不是靠先EvalSymlinks再Join。 - 返回的是绝对路径,跨平台下可能与原输入的相对形状不同,别把结果直接回显给用户。
- 对 Windows 的 junction/卷挂载点,它同样按符号链接处理,可能返回完全不同的路径。
6. Windows 特殊性(跨平台代码迟早撞上)
- 保留设备名:
NUL、CON、PRN、AUX、COM1–COM9、LPT1–LPT9、CLOCK$作为文件 basename 时创建会失败,且与扩展名无关(nul.txt也算)。实测filepath.IsLocal("com1")与IsLocal("con")为 false,而IsLocal("com1.txt")为 true——也就是说IsLocal拦了裸保留名,带后缀的没拦(因为它按"是否像保留名+扩展"的宽规则处理,跨版本细节不要依赖)。用户提供的文件名要落地时,最省事的办法还是自己生成名字。 - 大小写不敏感:
/a/B与/a/b是同一文件,但filepath.WalkDir返回的名字大小写来自文件系统实际记录。字符串比较路径前要strings.EqualFold或两边统一规范化。 - 路径长度:普通 Win32 API 上限约 260(MAX_PATH),
\\?\前缀可以绕过但有额外限制(必须是绝对路径、不做规范化)。filepath的多数函数在长路径上会失败,遇到就报清楚而不是循环重试。 - 盘符与 UNC:
filepath.VolumeName(\server\share\a)返回\\server\share;相对路径没有"当前盘符的当前目录"这种概念,Go 的Abs按进程 CWD 处理。跨卷时Rel会报错(§1 实测),移动文件也要走"复制 + 删除"而不是os.Rename。 os.PathSeparator在源码里写成字面反斜杠很痛苦:用filepath.Separator/filepath.ListSeparator常量,或让Join代劳,别手写"\\"拼接。
7. path 与 path/filepath 的分工
| 场景 | 用哪个 | 原因 |
|---|---|---|
| 真实文件系统路径 | path/filepath |
分隔符、绝对性、大小写规则按 OS |
| URL、HTTP 路由、归档内路径 | path |
永远 /,跨平台稳定 |
zip/tar 里的成员名 |
path(或 filepath.ToSlash) |
归档规范用 /;archive/zip 写入时也强制 / |
fs.FS 里的名字 |
path.Clean 语义 + fs.ValidPath |
FS 契约不允许 \、..、绝对路径 |
需要把 URL 路径映射到文件系统时,先转成局部名字再拼:name := strings.TrimPrefix(u.Path, "/"),然后 filepath.FromSlash(name) + filepath.IsLocal(name) 检查。直接 filepath.Join(root, u.Path) 会把 .. 抵消掉(§1 的 Join 陷阱),是历史安全通告里反复出现的模式。
8. 陷阱汇总
| # | 现象 | 原因与对策 |
|---|---|---|
| 1 | HasPrefix(full, base) 通过了却读到 base 外 |
前缀比较把兄弟目录算进去;Join 又静默抵消 ..。改用 filepath.IsLocal 或 os.Root(§2) |
| 2 | 用户传 /etc/passwd 时拼出的路径怪 |
Windows 上 Join("/data/x", "/etc/passwd") 把它当普通片段拼接(§2.1)。IsLocal 会先拒绝绝对路径 |
| 3 | Base("")、Ext(".hidden") 结果意外 |
分别是 "." 和 ".hidden"(§1)。判空/取名字要显式处理这两个边界 |
| 4 | 以为 ** 能递归匹配 |
** 静默折叠成 *(实测不报错),Glob("t/**/*.go") 只匹配恰好三层(§4)。递归请 WalkDir;注意 Windows 上 * 还能匹配 / |
| 5 | Match 返回 false 被当成出错 |
合法模式不匹配时 err 为 nil(§4)。用 m && err == nil 判定 |
| 6 | 遍历别人目录时 panic | 回调里先用了 d 再判 err,而 err != nil 时 d 可能为 nil(§3) |
| 7 | 想跳过 node_modules 没生效 |
用 filepath.SkipDir,注意它对文件的作用域是"该目录剩余文件"(§3) |
| 8 | WalkDir 少扫了内容 |
它不跟随符号链接(§3)。树里的链接目录不会进入 |
| 9 | EvalSymlinks 之后 Join 就安全了 |
TOCTOU:检查到打开之间链接可被替换(§5)。用 os.Root 做边界 |
| 10 | Windows 上 os.Create("nul") 失败 |
保留设备名,与扩展名无关(§6) |
| 11 | 路径比较大小写不稳定 | Windows 大小写不敏感而 WalkDir 返回文件系统记录的名字(§6) |
| 12 | Rel 跨盘符崩溃 |
不同卷之间没有相对路径,实测报 Rel: can't make ...(§1) |
| 13 | 归档/URL 里出现 \ |
用了 filepath 处理非文件系统路径(§7)。改用 path 或 ToSlash |
| 14 | filepath.Clean 被当成"安全函数" |
它只做语法整理,会抹掉 .. 但不会告诉你是否越界(§2.1) |
9. 实战建议
输入校验一律 IsLocal,落地一律 os.Root。 这两件工具把"路径安全"从口头约定变成编译器与运行时都帮你的形状:上传目录启动时 OpenRoot 一次,之后所有读写都在受限对象上进行,新增代码没机会忘记检查(详见 os-file.md §4)。需要限制得更细就 root.OpenRoot("sub") 逐层收窄。
不要相信用户给的文件名。 落地时自己生成名字(Go 1.27 的 uuid 包最省事,见 ../01-基础核心包/uuid.md),把原始名存进数据库或元数据。这样保留名、超长名、..、NUL、编码怪名、大小写冲突一起消失。
遍历目录的默认写法是 WalkDir + 先判 err。需要跳过目录时返回 SkipDir;只是"这棵树别再往下走"而不影响其它分支时,SkipDir 用在目录上正合适。给遍历加"最多 N 个文件 / 最多深度 D"的保护,在扫描用户上传目录时能挡住深度嵌套或超大树(配合 ../09-性能与运维 类文档里的限额思路,本手册里对应 ../07-附录/最佳实践清单.md)。
Glob 用来做"少量、浅层"的文件挑选,别把它当 shell 通配。 ** 不存在(§4)、无匹配返回空、错误模式会返回 ErrBadPattern,这三条决定了它在复杂筛选场景不如手写 WalkDir + Match 可控。需要真正的 doublestar 语义时再考虑第三方库(并把它标成非标准库依赖)。
判断"是不是同一个文件"用 filepath.SameFile,它比较的是 inode/文件 ID,能正确处理符号链接与大小写差异带来的"两个字符串指向同一文件"。字符串比较路径只在纯词法场景(比如前缀属于哪个配置目录)里用,而且必须先 Clean。
把 Windows 差异当成一等公民。 只要代码可能在 Windows 上跑(本地开发、CI、桌面工具),就按 §6 的规则写:名字自己生成、不硬编分隔符、跨卷操作不依赖 Rename、大小写比较显式化。“我只在 Linux 部署"从来不是省略这些的理由,因为第一个踩坑的往往是写代码的机器。
相关阅读:os-file.md(os.Root/os.CopyFS/权限模型)、bufio.md(遍历到文件之后的读取)、compress-archive.md(解压时的 zip-slip 防护正是 IsLocal/os.Root 的主场)、fs-embed.md(fs.FS 的名字必须满足 fs.ValidPath)、../07-附录/常见陷阱汇总.md。