Skip to content
Go
os — 文件、权限与目录

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 -> 实际 0664

0644(数据文件)、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。