os — 文件、权限与目录
os 是把操作系统那套东西(文件描述符、权限位、环境变量、进程、信号)映射成 Go API 的薄封装。用得最多的两块是文件读写和目录管理;外部命令与优雅关停的内容已经单独放在 os-exec.md,路径的纯字符串运算放在 path-filepath.md,本文只讲 os 本身。
它值得单独一篇的理由有两个。一是权限与错误语义在各平台不一致:同一个 Chmod(0o644) 在 Windows 上只影响只读位,ReadFile 传目录在 Windows 上报的是 Incorrect function;这些不能靠"Linux 直觉"推。二是**“把用户给的路径拼到基础目录下面"这件事,标准库直到最近才给出正确答案**:filepath.IsLocal 是 Go 1.20 加入、os.Root 是 Go 1.24 加入(GOROOT api/go1.20.txt/api/go1.24.txt 可查),而网上和旧文档里流传的 strings.HasPrefix 写法是有缺口的。本文里的错误文本、权限表现、返回布尔值全部在 go1.27.0 / Windows 11 上实测,Linux 侧无法在本机复现的部分会明确标注。
1. 打开文件:标志位与它们的真实组合语义
f, err := os.OpenFile("log.txt", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
defer f.Close()os 的标志位与 POSIX 同名,两个便捷函数其实就是固定组合(包文档原文):
os.Open(name) // = OpenFile(name, os.O_RDONLY, 0)
os.Create(name) // = OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)注意:
os.Create带O_TRUNC,文件存在就直接清空。要追加必须自己写OpenFile加O_APPEND;想"不存在才创建、存在就失败"则用O_CREATE|O_EXCL。
几个标志位的组合行为,实测(go1.27.0):
// 原子创建:已存在时失败
_, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
// err = open ...: The file exists. errors.Is(err, fs.ErrExist) = true
// O_APPEND|O_TRUNC 同时给出:截断先生效,最终内容就是本次写入
// 原内容 "X",以 O_CREATE|O_WRONLY|O_APPEND|O_TRUNC 打开后写 "Y" -> 文件内容是 "Y"O_EXCL 必须和 O_CREATE 一起用才有"检查+创建"的原子含义,这是它作为锁文件(lock.pid)依据的基础;单独 O_EXCL 在不同平台上行为不一致,别写。
写入用的 Close 不能随手忽略:缓冲与元数据错误可能只在 Close 时报出来。
f, err := os.Create("out.tmp")
if err != nil {
return err
}
closed := false
defer func() {
if cerr := f.Close(); cerr != nil && !closed {
err = cerr // 别让写了一半的文件冒充成功
}
}()
// ... 写入只读文件的 defer f.Close() 忽略错误是安全的。
f.Sync() 把内核页缓存推到持久设备;写完"临时文件 + os.Rename 到目标位置"是原子替换文件的标准做法(实测 Windows 上 Rename 覆盖已存在的同名文件成功,内容变成新文件;Linux 上 POSIX rename 同样是原子替换。跨文件系统/跨分区会失败,此时退回"复制 + 删除”)。
2. 权限:FileMode 的两副面孔
os.FileMode 是 32 位:低 9 位是 Unix 的 rwxrwxrwx,高几位是 Go 的类型/特殊标志(os.ModeDir、os.ModeSymlink、os.ModeSetuid、os.ModeSticky、os.ModeDevice、os.ModeNamedPipe 等)。这带来一个容易踩的细节:fs.FileMode(0o777) 的打印结果和 Perm() 不同。
st, _ := os.Stat("x.txt")
fmt.Println(st.Mode()) // -rw-rw-rw- (含类型位时可能显示 d / l 等)
fmt.Println(st.Mode().Perm()) // 438 = 0o666 (只取低 9 位)
fmt.Println(st.Mode().IsRegular())2.1 Unix 侧的 umask
创建时的权限是"请求权限"与进程 umask 的与运算:实际 = 请求 &^ umask。
umask 022 时:请求 0666 -> 实际 0644;请求 0777 -> 实际 0755
umask 002 时:请求 0666 -> 实际 06640644(数据文件)、0755(可执行/目录)、0600(密钥)、0700(~/.ssh)是日常四档。特殊位 04000 setuid、02000 setgid、01000 sticky(/tmp 那种"只有所有者能删")在 Go 里分别是 os.ModeSetuid/os.ModeSetgid/os.ModeSticky,需要按位或到 FileMode 上(0o4755 这种八进制字面量直接给 os.FileMode(0o4755) 也可以,但低 9 位之外的部分在 Perm() 里看不到)。
注意:umask 是进程级状态,不是"文件系统的属性"。同一份代码在不同容器基础镜像里落出的权限可能不同;需要确定权限时,创建后显式
os.Chmod一次,不要指望传进去的 mode 参数。
2.2 Windows 侧:只有只读位
Windows 没有 Unix 权限模型,Go 的做法是映射:Chmod 只根据写位设置"只读"属性,Stat 回来的 mode 也只反映这一点。实测(go1.27.0):
os.WriteFile(w, data, 0o644)
os.Chmod(w, 0o444) // -> Stat 显示 -r--r--r--
os.WriteFile(w, more, 0o600) // err = open ...: Access is denied.
os.Chmod(w, 0o777) // -> Stat 显示 -rw-rw-rw-(没有执行位,Windows 不提供)两条结论:0o444 之后文件真的写不了(只读属性生效),而 0o755 与 0o644 在 Windows 上看不出差别(可执行位不存在)。另外 os.WriteFile 对已存在的文件不会修改权限——实测对一个 0o666 的 perm.txt 调 WriteFile(..., 0o600),之后 Stat().Mode().Perm() 仍是 -rw-rw-rw-;mode 参数只在创建新文件时生效。
3. 便捷 API:能一行就别写五行
Go 1.16 加的 ReadFile/WriteFile/ReadDir/MkdirTemp/CreateTemp/CopyFS(1.23) 把最常见的形状收成了一行,实现里就是"打开 + 循环 + 关闭",不会比你手写的更神秘。
data, err := os.ReadFile("config.yaml") // 小文件直读;大文件请用 bufio 或分片
err := os.WriteFile("out.json", data, 0o644) // 已存在则截断,但不改权限(见 §2.2)
entries, err := os.ReadDir("docs") // 按文件名排序返回 []fs.DirEntry几处实测到的细节:
os.ReadFile传目录不会给你目录列表,Windows 上报read <path>: Incorrect function.,错误类型是*fs.PathError(Op:"read",Err是syscall.Errno)。判目录要用os.Stat+Mode().IsDir(),或errors.Is(err, ...)——注意 Windows 的这个错误不能匹配os.ErrInvalid。os.CreateTemp(dir, pattern):pattern 里没有*时,随机数字追加在末尾(实测noprefix->noprefix1289865624);pattern 含路径分隔符直接失败,报createtemp <x>: pattern contains path separator;目标文件已存在时会自动换名字重试(实测fixed.txt->fixed.txt1557579267),所以它不会覆盖,但你也不能假设名字。用完os.Remove(f.Name())清理,测试里用t.TempDir()。os.MkdirAll(path, perm):对已存在的目录返回nil且不会改它的权限(实测传 0o700 之后目录仍是-rwxrwxrwx);对已存在的同名文件报路径错误。os.Remove与os.RemoveAll:Remove只能删文件或空目录,非空目录报The directory is not empty.;不存在的目标Remove返回错误(errors.Is(err, os.ErrNotExist)为 true),而RemoveAll对不存在的路径返回nil(幂等,适合清理逻辑)。os.ReadDir的顺序有保证(文档:按文件名排序),f.Readdirnames(-1)/f.Readdir(-1)则是文件系统返回的顺序,不保证排序——实测在 Windows/NTFS 上这次恰好也是有序的,但这不是契约。依赖顺序时永远用ReadDir。os.DirFS(dir)把目录变成一个只读fs.FS(名字必须是fs.ValidPath允许的斜杠形式);os.Root见 §4。
环境变量与进程信息这一组没有太多可讲的,只有两点值得记住:os.Getenv 无法区分"未设置"与"设为空串",要区分就用 os.LookupEnv(实测未设置的键返回 "",false,显式设为空返回 "",true);os.Exit 立即终止进程,不执行任何 defer,清理逻辑必须在调用它之前完成(优雅退出的正确形状见 os-exec.md 的 signal.NotifyContext + Shutdown)。os.DevNull 在 Windows 上是 NUL、在 Unix 上是 /dev/null,os.PathSeparator 是 \ / /,os.PathListSeparator 是 ; / :——这些常量是写跨平台代码时唯一该用的分隔符来源。
4. 路径遍历防护:HasPrefix 是老办法,IsLocal/os.Root 才是新答案
只要代码里有"基础目录 + 用户给的路径"这个形状(文件下载、上传落地、静态资源、解压、插件加载),就存在目录遍历风险。流传最广的写法是这样的:
// 有缺口的写法(Go 1.24 之前常见)
func unsafeRead(baseDir, userPath string) ([]byte, error) {
full := filepath.Join(baseDir, userPath)
if !strings.HasPrefix(full, filepath.Clean(baseDir)) { // 缺口在这里
return nil, fmt.Errorf("invalid path: %s", userPath)
}
return os.ReadFile(full)
}缺口在于前缀比较把兄弟目录也算成"在里面"。baseDir = "/data/uploads",而用户路径落在真实存在的 /data/uploads-secret/key.txt 或 /data/uploadsX/a.txt 上:HasPrefix 做的是纯字符串比较,/data/uploads-secret/... 以 /data/uploads 开头,于是顺利通过检查,读到的却是 base 之外的文件。要修也得写成"base + 分隔符 是前缀 或 整体等于 base",还要先两边 Clean,还要考虑 Windows 的大小写不敏感与 /、\ 混用——每一条都是新的出错点。再补一个实测到的跨平台事实:Windows 上 filepath.Join("/data/uploads", "/data/uploadsX/ok.txt") 的结果是 \data\uploads\data\uploadsX\ok.txt,因为 Windows 不认 /data/... 这种绝对形式,把它当普通片段拼了进去。词法层面的字符串推理不可靠,就别在字符串上做安全判断。
Go 1.24 给了两个正确的工具。
4.1 filepath.IsLocal:判断"这个名字会不会跑出当前目录"
if !filepath.IsLocal(userPath) {
return nil, fmt.Errorf("reject non-local path: %q", userPath)
}
full := filepath.Join(baseDir, userPath) // 此时 Join 的结果保证落在 baseDir 内文档给的契约很硬:IsLocal(p) 为真时,Join(base, p) 一定在 base 内,且 Clean(p) 一定是不含 .. 的无根路径。它只看语法(原文 “using lexical analysis only”),不看文件系统,所以符号链接造成的逃逸它管不了——需要连符号链接一起管就用 os.Root。
实测清单(go1.27.0 / Windows):
"a.txt" true // 普通相对名
"a/b.txt" true // 斜杠可用;Windows 上 "a\b.txt" 同样 true
"sub/../ok.txt" true // 抵消后仍在内
"a/" "a//b" true // 尾部分隔符、重复分隔符都不算逃逸
"./x" "a/./b" true
"." true // 允许指向目录本身
".." "../x" false // 逃逸
"" false // 空名不是合法局部路径
"/" "\x" "/etc/passwd" `C:\x` false // 绝对路径一律拒绝
"a/../../b" false
"NUL" "com1" "con" false // Windows 保留名("nul.txt"、"com1.txt" 是 true)
`\\?\C:\x` `\\.\COM1` `\\srv\share\x` false // NT 命名空间与 UNC注意:
IsLocal不检测 NUL 字节(实测IsLocal("\x00")返回true),也不判断文件是否存在——它只回答"这个名字在词法上是否局部"。所以IsLocal之后仍然要处理底层错误(把 NUL 传进系统调用得到的是invalid argument)。
4.2 os.Root:把"只能在这棵子树里活动"做成对象
filepath.IsLocal 要求每个读标点都记得检查;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") // 只能在 root 内创建
sub, err := root.OpenRoot("sub") // 再往下取一个更小的 root
fsys := root.FS() // 作为只读 fs.FS 交给别的库
err = root.WriteFile("a/b.txt", data, 0o644)实测它的越界行为(前提:root 之外确实存在 out.bin):
_, err := root.ReadFile("../out.bin")
// err = 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
// errors.Is(err, os.ErrPermission) = false
_, err = root.Open("") // openat : empty path
_, err = root.ReadFile("C:/Windows/win.ini") // openat ...: path escapes from parent两条要记住:能抵消且最终仍在 root 内的名字(sub/../out.bin)会正常返回"文件不存在"(它先按词法落回 root 内再查找);而真正越界的错误是一段自定义文本,常见哨兵错误全都匹配不上,所以按"非 nil 即拒绝"处理,别试图分类。
对比 os.DirFS:fs.ReadFile(os.DirFS(dir), "../out.bin") 报 readfile ../out.bin: invalid argument(fs.FS 契约要求名字满足 fs.ValidPath),这个能匹配 os.ErrInvalid。DirFS 只读、拒绝 .. 与绝对名,但它是词法检查;Root 能读写删建,平台允许时用目录句柄约束,符号链接也别想把它带出去。
Root 文档里几条边界值得抄进设计笔记:符号链接会被跟随,但链接目标不能出 root,且链接本身不能是绝对的;Root 不禁止跨越文件系统边界、bind mount、/proc 这类特殊文件;Windows 上保留设备名(NUL、COM1)被拒;Unix 上 Root.Chmod/Chown/Chtimes 存在竞态(目标在操作期间被换成符号链接时,可能作用在链接上);Root 的方法可多 goroutine 并发使用。
本机是 Windows 且未开启开发者模式,创建符号链接会失败(os.Symlink 报 A required privilege is not held by the client.),因此"符号链接逃逸被 Root 挡住"这一条只能引用文档契约,没有本地实测样本。在 Linux 上验证只需 ln -s /etc/passwd root/link 再 root.ReadFile("link")——按文档应当失败。
4.3 os.CopyFS:批量复制与它的两个坑
err := os.CopyFS(dstDir, os.DirFS(srcDir)) // 已有 *os.Root 时:os.CopyFS(dstDir, root.FS())实测与文档一致的语义:
- 文件按 0o666(再受 umask 收窄)、目录按 0o777 创建;实测 Windows 上落出
-rw-rw-rw-与drwxrwxrwx。它不保留源权限位,要精确复制请逐文件Stat+Chmod,或走archive/tar。 - 不覆盖已存在的文件:同一对目录第二次复制报
open <dst>/a.txt: The file exists.,且errors.Is(err, fs.ErrExist)为 true。想覆盖得先删目标。 - 源不存在时错误是
GetFileAttributesEx .: The system cannot find the file specified.(Windows 文本,errors.Is(err, os.ErrNotExist)为 true)。 - 一个真实事故形状:目标目录不要放在源目录里面。最初把
dst写成src/out时,os.CopyFS(dst, os.DirFS(src))会把刚创建的out继续当作源的一部分复制,两分钟里目录涨到 8.6 MB 仍未结束,最后手动终止进程。文档的措辞是"运行期间新加入 fsys 的文件不保证被复制(包括 dir 是 fsys 子目录的情形)",请把它读成"这种写法行为未定义"。
5. 目录与遍历
entries, err := os.ReadDir("docs") // 按文件名排序
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info() // 已由 ReadDir 缓存,不额外发起系统调用
_ = info
}递归遍历(filepath.WalkDir、SkipDir、回调的 err 参数)属于路径运算那一半,写在 path-filepath.md。os 侧只需记住三件事:ReadDir 排序而 File.Readdirnames/Readdir 不保证顺序(§3);os.Stat 跟随符号链接、os.Lstat 看链接本身;os.Readlink 返回的可能是相对目标。
6. 陷阱汇总
| # | 现象 | 原因与对策 |
|---|---|---|
| 1 | os.Create 之后原内容没了 |
它带 O_TRUNC(§1)。追加用 OpenFile + O_APPEND |
| 2 | O_EXCL 没起作用 |
必须与 O_CREATE 同用;实测已存在报 The file exists.,errors.Is(err, fs.ErrExist) |
| 3 | Windows 上 Chmod(0o755) 看不出效果 |
Windows 只有只读位(§2.2);0o444 会让后续写入报 Access is denied. |
| 4 | WriteFile 之后权限没变 |
mode 参数只在创建新文件时生效;已存在文件需显式 Chmod(实测) |
| 5 | ReadFile 报 Incorrect function. |
传进去的是目录(Windows 文本)。先 Stat 判 IsDir |
| 6 | 临时文件名对不上 | CreateTemp 自动加随机后缀、同名会换名重试;pattern 含分隔符直接报错(实测) |
| 7 | RemoveAll 返回 nil 就以为删掉了 |
路径不存在时它也返回 nil(实测)。要确认就再 Lstat |
| 8 | 遍历目录顺序不稳定 | 用了 File.Readdirnames/Readdir;换 os.ReadDir(保证排序) |
| 9 | HasPrefix(full, base) 挡住了 ../ 却没挡住 /data/uploads-secret |
纯字符串前缀比较含兄弟目录。用 filepath.IsLocal 或 os.Root(§4) |
| 10 | os.Root 的越界错误被判成"文件不存在" |
它是自定义文本 path escapes from parent,不匹配 ErrInvalid/ErrNotExist(实测)。非 nil 即拒绝 |
| 11 | CopyFS 复制不完或目录暴涨 |
目标在源之内会自我递归复制;它也不覆盖已存在文件(fs.ErrExist)(§4.3) |
| 12 | 上传文件名直接落盘 | 名字来自客户端,可能带路径、保留名、NUL。先 IsLocal,再用 Root.WriteFile,最好换成自己生成的名字 |
| 13 | os.Exit 之后 defer 没跑 |
设计如此(§3)。清理写在 Exit 之前,或用 signal.NotifyContext 走正常返回 |
7. 实战建议
能用 os.Root 就别只用 filepath.IsLocal。 后者要求每个读标点都记得检查,前者把约束做进对象,新代码不容易漏。典型形状是启动时 OpenRoot(uploadDir) 一次,把 *os.Root 传给下游;只读场景用 root.FS() 交给别的库,落地用 os.CopyFS(dst, root.FS())。
“用户上传的文件名不重要”。 落地时自己生成文件名(Go 1.27 的 uuid 包,见 ../01-基础核心包/uuid.md;或 os.CreateTemp),原始名存进数据库或 sidecar 元数据。路径遍历、保留名、超长名、编码怪名一次性消失,比在文件名上打补丁便宜得多。
测试里永远用 t.TempDir():自动清理、路径独立,天然避免测试串扰。需要断言权限的测试在 Windows 上要跳过或只判只读位,否则得到平台相关结果(§2.2)。
“写临时文件 + Rename 原子替换"是配置与缓存文件的默认姿势:写 target.tmp-<random> → Close(必要时 Sync)→ os.Rename 到 target。实测 Windows 上 Rename 覆盖同名文件成功,Linux 的 rename 同样是原子替换;跨分区会失败,这时退回"复制 + 删除"并说清失去原子性的风险。
大文件不要 ReadFile。 它一次性读进内存,几十 MB 的日志足以打爆容器内存上限。流式处理走 bufio.md,防御性只读前若干字节用 io.LimitReader(见 io-interfaces.md)。
相关阅读:path-filepath.md(Join/Clean/Rel/WalkDir,以及 IsLocal 的语法基础)、os-exec.md(子进程与优雅关停)、bufio.md(大文件流式读写)、compress-archive.md(解压落地时的 zip-slip 防护用的正是本文这两个工具)、../07-附录/常见陷阱汇总.md。