Python 怎么设置文件权限?umask 为什么会让 chmod「失效」?
简化版
Unix 文件权限是三组 rwx(属主 / 属组 / 其他人),用八进制表示:0o644 就是 rw-r--r--。Python 里用 os.chmod(path, 0o600) 或 Path(p).chmod(0o600) 设置,用 os.stat(p).st_mode & 0o777 读取,用 stat.filemode(st.st_mode) 得到 -rw-r--r-- 这种可读形式。最容易困惑的是 umask:创建文件时指定的权限不会原样生效,而是被 umask 减掉——公式是 实际权限 = 请求的 mode & ~umask。常见的 umask 是 0o022,所以 os.open(p, O_CREAT, 0o666) 实际得到 0o644(写位被抹掉了)。关键区分:umask 只影响「创建时」的权限,os.chmod 是显式设置、不受 umask 影响——所以要精确控制权限(比如私钥文件必须 0o600),创建后再 chmod 一次,或者用 os.open(..., 0o600) 配合正确的 umask。目录权限的语义和文件不同:x 表示「可以进入 / 可以访问里面的路径」,r 表示「可以列出文件名」,删除文件看的是所在目录的 w+x,而不是文件本身的权限——这就是为什么 /tmp 要加 sticky 位(0o1777):人人可写,但只有属主能删自己的文件。Windows 上没有这套模型:chmod 只能切换只读属性,st_uid/st_gid 恒为 0。核心记忆:实际权限 = mode & ~umask;chmod 不受 umask 影响;删文件看目录权限。
详细版
权限位速查:
| 八进制 | 含义 | 典型用途 |
|---|---|---|
0o644 | rw-r--r-- | 普通文件(属主可写,别人只读) |
0o600 | rw------- | 密钥、token、配置(只有属主能读写) |
0o755 | rwxr-xr-x | 可执行脚本、目录 |
0o700 | rwx------ | 私有目录 |
0o1777 | rwxrwxrwt | sticky 位:/tmp(人人可写,只能删自己的) |
0o2775 | rwxrwsr-x | setgid 目录:新建文件继承属组(团队共享目录) |
0o4755 | rwsr-xr-x | setuid:以文件属主身份执行(危险,尽量避免) |
import os, stat
from pathlib import Path
p = Path("secret.key")
p.write_text("token")
# ① 读权限
st = p.stat()
print(oct(st.st_mode & 0o777)) # 0o644(默认,取决于 umask)
print(stat.filemode(st.st_mode)) # -rw-r--r--
# ② 设权限(★chmod 是显式设置,不受 umask 影响★)
p.chmod(0o600) # 等价 os.chmod(p, 0o600)
print(oct(p.stat().st_mode & 0o777)) # 0o600
# ③ 用 stat 常量拼(可读性更好,但八进制其实更常用)
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR) # = 0o600
# ④ ★umask 的影响:创建时权限会被"减掉"★
print(oct(os.umask(0o022))) # 查看并设置 umask(★返回旧值★,没有只读的接口)
fd = os.open("a.txt", os.O_CREAT | os.O_WRONLY, 0o666)
os.close(fd)
print(oct(os.stat("a.txt").st_mode & 0o777)) # 0o644 ← ★不是 0o666!被 umask 抹掉了 022★
# ⑤ ★创建即安全:原子地创建一个只有属主可读写的文件★
fd = os.open("token.txt", os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
# ★O_EXCL:已存在则失败(防止被预先创建的软链接劫持)★
with os.fdopen(fd, "w") as f:
f.write("secret")
# 若 umask 是 0o077 以外的值,稳妥起见再 chmod 一次:
os.chmod("token.txt", 0o600)
# ⑥ open() 内置函数★不能指定权限★
open("b.txt", "w").close() # 权限恒为 0o666 & ~umask
# → 要控制权限只能用 os.open,或创建后 chmod
# ⑦ 目录权限:删除文件看的是★目录★的 w+x
os.makedirs("d", exist_ok=True)
os.chmod("d", 0o555) # 目录只读(r-xr-xr-x)
# open("d/x.txt", "w") # ✗ PermissionError(目录不可写,建不了新文件)
# 但目录里已有的文件,只要文件本身可写,仍然能改内容——只是不能删除/改名
# ⑧ 属主(需要 root 权限)
# os.chown(p, uid, gid)
# shutil.chown(p, user="app", group="app") # ★支持用户名字符串,更方便★
# ⑨ ★别用 os.access 做安全判断★
# if os.access(p, os.W_OK): open(p, "w") # ✗ TOCTOU + 按真实 uid 判断
# ✓ 直接 try: open(p, "w") except PermissionError:
⚠️ 三个必须记住的点:① umask 是「减法掩码」:
实际权限 = 请求的 mode & ~umask,它只作用于「创建的那一刻」,对之后的os.chmod没有任何影响。所以「我明明指定了 0o666 为什么变成 0o644」的答案永远是 umask(默认 022);而「我 chmod 0o777 为什么真的是 777」也不矛盾——chmod 是显式设置。② 内置的open()无法指定权限,新建文件的权限恒为0o666 & ~umask(目录是0o777 & ~umask);要精确控制必须用os.open(path, flags, mode),且创建敏感文件时要配O_EXCL(防止路径已被攻击者用符号链接占位)。③ umask 是进程级的全局状态且不是线程安全的——os.umask(new)会返回旧值并立刻生效,多线程里临时改 umask 再改回来会互相干扰。稳妥的做法是不去动 umask,创建后显式chmod到目标权限(唯一的窗口风险是创建到 chmod 之间的一瞬间,用os.open(..., 0o600)可以消除)。
完整版教学
一、权限模型:三组 rwx 与八进制
每个文件有 9 个基本权限位,分成三组:
属主(user) 属组(group) 其他人(other)
r w x r w x r w x
4 2 1 4 2 1 4 2 1
0o644 = 6 4 4 = (4+2) (4) (4) = rw- r-- r--
0o755 = 7 5 5 = (4+2+1)(4+1)(4+1) = rwx r-x r-x
0o600 = 6 0 0 = rw- --- ---
★ 一定要写 0o 前缀:chmod(p, 644) 是十进制 644 = 0o1204,权限完全乱套且不报错
判断"我能不能做某事"的顺序(★不是"取最大权限"★):
① 我是属主吗? → 只看 user 那三位(★即使 group 有更多权限也不看★)
② 我在属组里吗? → 只看 group 三位
③ 都不是 → 看 other 三位
★ 推论:0o604(rw----r--)意味着属主可读写、★属组成员反而什么都不能做★、其他人可读
这个"看似矛盾"的配置是合法的,且经常被用作面试题
rwx 对文件 vs 对目录,含义完全不同(★核心考点★):
┌─────┬──────────────────────┬────────────────────────────────────┐
│ 位 │ 对文件 │ 对目录 │
├─────┼──────────────────────┼────────────────────────────────────┤
│ r │ 读取内容 │ ★列出里面有哪些名字★(ls) │
│ w │ 修改内容 │ ★在里面增/删/改名文件★(不是改文件内容)│
│ x │ 作为程序执行 │ ★进入/穿过这个目录★(cd、访问里面的路径)│
└─────┴──────────────────────┴────────────────────────────────────┘
由此推出几个反直觉的事实:
① ★删除文件看的是「文件所在目录」的 w+x,和文件自身权限无关★
→ 一个 0o444 的只读文件,只要你对目录有 w+x,照样能删掉它
② 目录只有 x 没有 r:能访问 dir/known_name.txt,但★不能 ls 列出目录内容★
(常用于"知道文件名才能访问"的目录)
③ 目录只有 r 没有 x:能 ls 看到名字,但★无法读取任何一个文件★(进不去)
④ 目录必须有 x 才能"穿过"——想访问 /a/b/c.txt,对 /a 和 /a/b 都必须有 x
权限模型本身不难,但有三个点必须精确:第一,八进制前缀不能省——chmod(p, 644) 传的是十进制 644(等于 0o1204),权限完全错乱且不报错。第二,权限检查是「按身份归组」而不是「取最大值」:先判断你是不是属主,是就只看 user 那三位(即使 group 位给了更多权限也不看),不是再看属组、最后看 other——所以 0o604 这种「属主可读写、属组成员什么都做不了、其他人可读」的配置是完全合法的。第三,rwx 对目录的含义和对文件完全不同:目录的 r 是「能列出名字」、w 是「能在里面增删改名」、x 是「能进入/穿过」。由此推出最反直觉也最常考的一条:删除一个文件看的是它所在目录的 w+x 权限,和文件自身权限无关——一个 0o444 的只读文件,只要你对目录可写就能删掉它。
二、umask:为什么创建出来的权限总是小一号
公式(★背下来★):
实际权限 = 请求的 mode & ~umask
常见 umask = 0o022(= ----w--w-,即"去掉属组和其他人的写权限")
算例:
open() 内置函数创建文件,请求的 mode 恒为 0o666
0o666 = rw- rw- rw-
umask = 0o022 = --- -w- -w-
结果 = 0o666 & ~0o022 = 0o644 = rw- r-- r-- ★这就是默认 644 的由来★
创建目录 os.mkdir 请求 0o777
0o777 & ~0o022 = 0o755 = rwxr-xr-x ★目录默认 755 的由来★
umask = 0o077(更安全,常用于处理敏感数据的服务)
0o666 & ~0o077 = 0o600 文件只有属主能读写
0o777 & ~0o077 = 0o700 目录只有属主能进
★ 注意 umask 只能"减"权限,不能"加":
os.open(p, O_CREAT, 0o600) 在 umask=0o022 下仍然是 0o600
(因为 0o600 里本来就没有 group/other 的位可减)
→ 所以"指定小权限"总是安全的,"指定大权限"才会被削
umask 的作用范围(★关键区分★):
✓ 影响:open/os.open 创建文件、os.mkdir/makedirs 创建目录、
tempfile 创建临时文件、socket 文件、解压时创建的文件
✗ ★不影响★:os.chmod(显式设置,想给什么就是什么)
已存在文件的权限
shutil.copy(★复制的是源文件的权限★,不走 umask)
→ "为什么 chmod 0o666 生效了但创建时不生效"的答案就在这里
Python 里的读写:
old = os.umask(0o077) # ★设置并返回旧值——没有"只读取"的接口★
... # 想只读取只能:old = os.umask(0o022); os.umask(old)
os.umask(old) # 恢复
★ umask 是★进程级★的全局状态:
- 不是线程安全的(一个线程临时改了,别的线程创建文件也受影响)
- 子进程会继承
- 在多线程服务里临时改 umask 是典型的竞态源
✓ 更稳的做法:不动 umask,创建后显式 chmod;
或用 os.open(path, O_CREAT|O_EXCL|O_WRONLY, 0o600)(指定小权限不会被削)
umask 是这道题的核心考点,公式必须背下来:实际权限 = 请求的 mode & ~umask。默认 umask 是 0o022,而内置 open() 创建文件时请求的 mode 恒为 0o666、os.mkdir 请求 0o777——这就是「文件默认 644、目录默认 755」的由来。有个重要推论:umask 只能减权限、不能加,所以 os.open(p, O_CREAT, 0o600) 在任何常见 umask 下都还是 0o600(里面本来就没有 group/other 的位可减)——指定小权限总是安全的,指定大权限才会被削。作用范围要分清:umask 影响所有「创建」动作(open、mkdir、tempfile、解压),但不影响 os.chmod(显式设置)、不影响已存在的文件、也不影响 shutil.copy(它复制的是源文件权限)。最后一个工程陷阱:umask 是进程级全局状态、不是线程安全的,多线程服务里「临时改 umask 再改回来」会互相干扰——稳妥做法是不动它,改用 os.open(..., 0o600) 或创建后显式 chmod。
三、Python 的权限 API 与「创建即安全」
读权限:
st.st_mode & 0o777 → 0o644(只要权限位)
st.st_mode & 0o7777 → 0o2755(含 setuid/setgid/sticky)
stat.filemode(st.st_mode) → '-rw-r--r--'(含类型字符)
stat.S_IMODE(st.st_mode) → ★官方写法★,等价 & 0o7777
设权限:
os.chmod(path, 0o600) # 显式设置,★覆盖★原有权限
Path(p).chmod(0o600)
os.chmod(p, st.st_mode | stat.S_IXUSR) # ★在原有基础上加一位★(先读再改)
os.chmod(p, st.st_mode & ~stat.S_IWOTH) # 去掉"其他人可写"
os.fchmod(fd, 0o600) # ★对已打开的 fd 操作(无 TOCTOU)★
os.chmod(p, 0o600, follow_symlinks=False) # 部分平台支持 lchmod
设属主(通常需要 root):
os.chown(p, uid, gid) # 要数字 id,-1 表示不变
shutil.chown(p, user="app", group="app") # ★接受用户名/组名字符串,更好用★
★ 创建敏感文件的正确姿势(三种,安全性递增):
① 最差:先创建再改权限
Path("token").write_text(secret) # ← 这一瞬间是 0o644,★别人能读到★
os.chmod("token", 0o600) # 窗口虽短,但确实存在
② 较好:os.open 直接指定权限
fd = os.open("token", os.O_CREAT | os.O_WRONLY, 0o600)
with os.fdopen(fd, "w") as f: f.write(secret)
→ 文件★从诞生起★就是 0o600(前提:umask 不会把它削得更小,那反而更安全)
③ 最好:再加 O_EXCL 防劫持
fd = os.open("token", os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
→ ★O_EXCL:如果路径已存在(包括是个符号链接)就直接失败★
否则攻击者可以预先创建一个指向 /etc/passwd 的软链接,
让你的程序把内容写进去(经典的符号链接攻击)
★ 更完整的模板(临时文件 + 原子改名,兼顾权限和原子性):
import tempfile
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(target)) # ★mkstemp 默认就是 0o600★
with os.fdopen(fd, "w") as f:
f.write(secret)
os.replace(tmp, target) # 原子替换
os.chmod(target, 0o600)
检查权限:★别用 os.access★
✗ os.access(p, os.W_OK)
- 按★真实 uid★检查(setuid 程序里会得出错误结论)
- 有 TOCTOU 窗口
- 官方文档明确说"更推荐直接尝试打开"
✓ try: open(p, "w") except PermissionError: ...
✓ 只是想展示信息(不做决策)时读 st_mode 是可以的
Python 的权限 API 很直接,但有两个写法值得固化。「在原有权限基础上增删某一位」要先读再改:os.chmod(p, st.st_mode | stat.S_IXUSR) 加执行位、& ~stat.S_IWOTH 去掉「其他人可写」——直接写死一个八进制会把其他位一起覆盖掉。创建敏感文件有三档做法:最差的是「先创建再 chmod」(中间有一瞬间是 0o644,别人能读到);较好的是 os.open(path, O_CREAT|O_WRONLY, 0o600) 让文件从诞生起就是 0o600;最好的是再加 O_EXCL——如果路径已存在就直接失败,从而挡住「攻击者预先创建一个指向 /etc/passwd 的软链接、让你的程序把内容写进去」这种经典的符号链接攻击。想同时兼顾权限和原子性,标准模板是 tempfile.mkstemp(默认就是 0o600)+ os.replace。最后重申:os.access() 不要用于安全判断(按真实 uid 检查 + TOCTOU 窗口),要用就直接 try: open() except PermissionError。
四、目录权限与三个特殊位
目录权限的实际效果(配合上一节的语义表):
0o755 rwxr-xr-x 标准目录:属主可增删,其他人可进入和列出
0o700 rwx------ 私有目录:只有属主能进(★放密钥、配置的标准权限★)
0o711 rwx--x--x "知道名字才能访问":别人能进能读指定文件,★但 ls 不出来★
0o555 r-xr-xr-x 只读目录:能进能列,但★谁也不能新建/删除文件★
三个特殊位(在 9 位权限之上,用第 4 位八进制表示):
★ sticky 位(0o1000)—— 只对目录有意义
0o1777 = rwxrwxrwt (最后那个 t 就是 sticky)
效果:目录人人可写,但★只有文件属主(或目录属主、root)才能删除/改名★
经典用途:/tmp、/var/tmp
没有它的话:一个人人可写的目录里,任何人都能删掉别人的文件
★ setgid(0o2000)—— 对目录最有用
0o2775 = rwxrwsr-x
对目录:★在里面新建的文件/子目录自动继承目录的属组★(而不是创建者的默认组)
→ 团队共享目录的标准配置(大家建的文件都属于同一个组)
对可执行文件:以文件所属★组★的身份运行
★ setuid(0o4000)—— 只对可执行文件有意义
0o4755 = rwsr-xr-x
效果:★以文件属主(通常是 root)的身份运行,而不是调用者的身份★
经典例子:/usr/bin/passwd(普通用户改密码需要写 /etc/shadow)
★ 极度危险★:setuid 程序里的任何漏洞都直接变成提权漏洞
→ 现代做法用 capabilities 或 sudo 替代;解压归档时★必须清除 setuid 位★
★ 注意:Linux 对★脚本★(#! 开头)忽略 setuid 位(安全考虑),只对二进制生效
Python 里操作特殊位:
os.chmod(d, 0o1777) # 直接给
os.chmod(d, st.st_mode | stat.S_ISGID) # 加 setgid
os.chmod(f, st.st_mode & ~(stat.S_ISUID | stat.S_ISGID)) # ★清除 setuid/setgid★
bool(st.st_mode & stat.S_ISUID) # 检查
★ 一个安全细节:写入一个 setuid 文件后,内核会自动清除 setuid 位
(防止"先让 root 建个 setuid 文件、再往里写自己的代码")
目录权限的组合能表达一些很实用的语义:0o700 是放密钥和敏感配置的标准权限(只有属主能进),0o711 实现「知道文件名才能访问、但 ls 列不出来」。三个特殊位要各记一句:sticky(0o1000)只对目录有意义——目录人人可写但只有属主能删自己的文件,这就是 /tmp 用 0o1777 的原因;setgid(0o2000)用在目录上让新建文件自动继承目录的属组,是团队共享目录的标准配置;setuid(0o4000)让可执行文件以属主身份运行(/usr/bin/passwd 的原理),但极度危险——setuid 程序里的任何漏洞都直接变成提权漏洞,所以解压不可信归档时必须清除 setuid 位(这正是 tarfile 的 filter="data" 会做的事之一)。两个冷知识:Linux 对脚本忽略 setuid 位(只对二进制生效),以及写入 setuid 文件后内核会自动清除该位。
五、跨平台差异与容器场景
Windows 上这套模型基本不适用:
st_mode 的权限位是"模拟"的:
- 只有★只读属性★真正映射(chmod 去掉 w 位 → 设置只读属性)
- r 和 x 位恒为开启(0o444 / 0o666 之类)
os.chmod(p, 0o600) 和 0o644 在 Windows 上★效果一样★(都是可读写)
st_uid / st_gid 恒为 0;os.chown 不存在
真正的权限模型是 ACL(访问控制列表)→ 需要 pywin32 / win32security 操作
★ 跨平台代码的写法:
if os.name == "posix":
os.chmod(key_file, 0o600)
# Windows 上要么忽略,要么用 ACL 限制到当前用户
★ 常见的连带问题:git 只记录"可执行位"(core.fileMode),
Windows 上 clone 出来的文件权限由 umask 决定 → CI 里脚本"没有执行权限"
WSL / 挂载的差异:
NTFS 挂载到 Linux(drvfs/ntfs-3g):权限往往是挂载参数统一指定的,chmod 无效
FAT/exFAT:完全不支持 Unix 权限
→ 检测方法:chmod 之后再 stat 一次,看是否真的变了
容器里的权限问题(★高频踩坑★):
① 容器内 uid ≠ 宿主机 uid:
容器里以 uid=1000 运行,挂载宿主机目录时宿主机文件属主可能是 uid=0
→ 表现为"容器里写不进去"或"宿主机看到的文件属主是个陌生数字"
✓ 用 --user $(id -u):$(id -g) 或在 Dockerfile 里统一 uid
② 以 root 运行容器写出的文件在宿主机上属于 root
→ CI 里 build 完的产物删不掉
③ Kubernetes 的 fsGroup / runAsUser / securityContext 会改变挂载卷的属组
④ 只读根文件系统(readOnlyRootFilesystem)下只有指定卷可写
实践清单:
密钥/token/私钥文件 0o600(很多工具会★主动拒绝★权限过松的密钥,如 ssh)
配置文件(含密码) 0o600 或 0o640(配合属组)
日志文件 0o640(属组可读,方便运维)
可执行脚本 0o755
共享上传目录 0o2775 + setgid(统一属组)
临时目录 0o1777 + sticky(或直接用 tempfile)
跨平台是这里最容易出事的部分。Windows 上这套权限模型基本不适用:st_mode 的权限位是模拟的,只有「只读属性」真正映射,chmod(p, 0o600) 和 0o644 效果完全一样;st_uid/st_gid 恒为 0,os.chown 不存在——真正的权限模型是 ACL,要用 pywin32 操作。所以跨平台代码通常写成 if os.name == "posix": os.chmod(...)。一个高频连带问题是 git 只记录可执行位,Windows 上 clone 出来的文件权限由 umask 决定,导致 CI 里「脚本没有执行权限」。容器场景的坑更集中:容器内 uid 和宿主机 uid 不一致会导致「容器里写不进去」或「宿主机看到陌生的属主数字」;以 root 运行容器写出的文件在宿主机上属于 root(CI 里产物删不掉);K8s 的 fsGroup/runAsUser 还会改变挂载卷的属组。最后记住那份实践清单,尤其是密钥文件必须 0o600——ssh 等工具会主动拒绝权限过松的密钥文件。
六、常见任务与安全清单
任务 1:递归修改目录树的权限(★文件和目录要分开处理★)
for root, dirs, files in os.walk(top):
for d in dirs: os.chmod(os.path.join(root, d), 0o755) # 目录要 x
for f in files: os.chmod(os.path.join(root, f), 0o644) # 文件不要 x
✗ 千万别对所有东西统一 chmod 0o644 → ★目录失去 x 位后整棵树都进不去了★
✗ 也别统一 0o755 → 所有数据文件都变成"可执行",且放宽了权限
任务 2:安全地写一个密钥文件
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(fd, "w") as f:
f.write(secret)
任务 3:检查是否有"权限过松"的敏感文件(安全巡检)
st = p.stat()
if st.st_mode & (stat.S_IRGRP | stat.S_IROTH):
print(f"⚠ {p} 可被其他用户读取: {stat.filemode(st.st_mode)}")
任务 4:让脚本可执行(保留其他位)
st = os.stat(script)
os.chmod(script, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
任务 5:临时改 umask(★尽量避免,有并发风险★)
import contextlib
@contextlib.contextmanager
def umask_ctx(new):
old = os.umask(new)
try:
yield
finally:
os.umask(old)
★ 仅在单线程的启动阶段使用;多线程服务里请改用 os.open 显式指定 mode
安全清单(写涉及文件权限的代码时逐条检查):
□ 敏感文件是否 0o600?是否★创建时就是★(而不是先建后改)?
□ 创建敏感文件是否用了 O_EXCL(防符号链接劫持)?
□ 是否误用 os.access 做安全判断?(应改为 EAFP)
□ 解压/复制外来文件时是否清除了 setuid/setgid 位?
□ 递归 chmod 时目录和文件是否分开处理?
□ 临时文件是否用 tempfile(默认 0o600)而不是自己拼 /tmp/xxx?
□ 日志/上传目录是否意外给了 other 写权限(0o777 是危险信号)?
□ Windows 上的分支是否处理了(chmod 基本无效)?
最后是可以直接照抄的实践。递归改权限时文件和目录必须分开处理——统一 chmod 0o644 会让目录失去 x 位、整棵树都进不去,统一 0o755 又会把所有数据文件变成可执行且放宽权限。写密钥文件的标准姿势是 os.open(path, O_CREAT|O_EXCL|O_WRONLY, 0o600)。临时改 umask 的上下文管理器可以写,但只适合单线程的启动阶段(多线程服务里会互相干扰,应改用 os.open 显式指定 mode)。收尾的安全清单里最值得反复检查的三条是:敏感文件是否「创建时就是 0o600」而不是先建后改、创建时是否加了 O_EXCL 防符号链接劫持、以及处理外来文件时是否清除了 setuid/setgid 位。
记忆钩子:「权限是三组 rwx(属主/属组/其他人),八进制表示,★写 chmod 时千万别忘 0o 前缀★(644 是十进制、完全错乱还不报错)。判断能不能做某事是『按身份归组』不是『取最大权限』——你是属主就只看 user 那三位,所以 0o604 意味着属组成员反而什么都做不了。★rwx 对目录的含义完全不同:r=能列出名字、w=能在里面增删改名、x=能进入/穿过★,由此得出最反直觉的一条:★删除文件看的是所在目录的 w+x,和文件自身权限无关★(0o444 的只读文件照样能被删),这也是 /tmp 必须加 sticky 位(0o1777)的原因——人人可写但只能删自己的。★umask 是减法掩码:实际权限 = 请求的 mode & ~umask★,默认 0o022 而 open() 请求 0o666、mkdir 请求 0o777,这就是『文件默认 644、目录默认 755』的由来;它★只影响创建那一刻★,对 os.chmod 毫无影响(这解释了『为什么 chmod 生效但创建时不生效』);而且它只能减不能加,所以指定小权限(0o600)永远安全。内置 open() ★无法指定权限★,要控制必须用 os.open(path, O_CREAT|O_EXCL|O_WRONLY, 0o600)——★O_EXCL 是为了防符号链接劫持★(攻击者预先建个指向 /etc/passwd 的软链接),而『先创建再 chmod』中间那一瞬间文件是 0o644、别人读得到。umask 是进程级全局状态、不线程安全,别在多线程里临时改。三个特殊位:sticky 只对目录(/tmp)、setgid 用在目录上让新文件继承属组(团队共享目录)、★setuid 极危险★(解压外来归档必须清除它)。os.access 按真实 uid 检查且有 TOCTOU 窗口,★别用它做安全判断★,直接 try/except。Windows 上整套模型基本无效(chmod 只切换只读属性、uid/gid 恒为 0)。」
七、常见误区与追问
- 误区:
os.chmod(p, 644)就是设置成rw-r--r--。 少了0o前缀就是十进制 644,转成八进制是0o1204——不但权限位完全错乱(0o204是-w----r--),还多带了一个0o1000的 sticky 位,而且 Python 不会报任何错。这是新手最容易犯又最难自查的错误(权限看起来「怪怪的」但程序照常运行)。养成两个习惯可以避免:永远写0o前缀;或者用stat模块的常量拼(stat.S_IRUSR | stat.S_IWUSR),虽然啰嗦但不会写错。检查时也别忘了用stat.filemode(st.st_mode)打印出-rw-r--r--这种人类可读形式来确认。 - 误区:
open(path, "w")创建文件时可以指定权限。 内置open()没有权限参数——它创建文件时使用固定的0o666,再被 umask 削减,所以默认得到0o644。想精确控制只有两条路:①os.open(path, os.O_CREAT | os.O_WRONLY, 0o600)然后用os.fdopen(fd)包装成文件对象(推荐,文件从诞生起就是目标权限);② 先用open()创建、再os.chmod()改——但这中间存在一个窗口期,文件是 0o644,其他用户可以读到内容,写密钥、token 时这个窗口就是漏洞。同理,Path.write_text()、json.dump(open(...))这些便捷写法都无法指定权限。需要同时保证权限和原子性时,标准模板是tempfile.mkstemp()(默认就创建为 0o600)+os.replace()。 - 误区:umask 会影响所有的权限设置,包括 chmod。 umask 只作用于「创建的那一刻」:
open/os.open创建文件、mkdir创建目录、tempfile创建临时文件、解压时创建文件——这些场景下实际权限 = 请求的 mode & ~umask。而os.chmod是显式设置,完全不受 umask 影响,你给0o666就是0o666。搞清这一点就能解释那个经典困惑:「我os.open(p, O_CREAT, 0o666)得到的是 644,但chmod(p, 0o666)却真的变成了 666」。另外shutil.copy也不走 umask——它复制的是源文件的权限,所以从一个0o777的文件复制过来的新文件也是0o777,这在拷贝外来文件时是个隐患。 - 误区:一个文件设成
0o444(只读)就没人能删掉它了。 删除文件检查的是「文件所在目录」的w和x权限,与文件自身权限无关——因为「删除」本质是从目录里移除一个目录项(改的是目录的内容),而不是修改文件本身。所以只要你对目录有写权限,0o444甚至0o000的文件都能被删掉(rm会提示确认,但os.remove()直接就删了)。要真正保护文件不被删除有三条路:① 收紧目录的权限;② 给目录加 sticky 位(0o1777),这样只有文件属主能删自己的文件(/tmp就是这么做的);③ Linux 上用chattr +i设置不可变属性(连 root 都要先去掉这个属性才能删)。反过来的推论同样重要:在一个人人可写的目录里,你的文件是不安全的。 - 误区:用
os.access(p, os.W_OK)检查一下再写文件更稳妥。 两个硬伤。① 它按「真实 uid」而不是「有效 uid」检查权限——这个设计是为了 setuid 程序判断「调用我的那个用户有没有权限」,但在普通程序里会给出与实际操作不一致的结果。② TOCTOU 竞态:检查通过到真正打开之间,文件可能被删除、替换或换成指向别处的符号链接,你的检查等于白做,甚至给了攻击者可乘之机。Python 官方文档明确建议改用「直接尝试打开并捕获异常」(EAFP):try: open(p, "w") except PermissionError:——因为打开这个动作本身是原子的。os.access唯一合理的用途是展示信息(比如给用户列出「哪些文件你能读」),而不是作为操作前的安全判断。 - 追问:umask 的值应该怎么设?为什么默认是 022 而不是更安全的 077? umask 是一个「要减掉哪些权限」的掩码。常见值:
0o022(默认)减掉属组和其他人的写权限,结果是文件 644、目录 755——这个默认值反映了传统 Unix「多用户共享一台机器、文件默认可以互相看」的假设,便于协作。0o077减掉属组和其他人的一切权限,结果是文件 600、目录 700,适合处理敏感数据的服务(很多安全基线要求守护进程用这个值)。0o002只减掉其他人的写权限,配合 setgid 目录用于团队共享。设置方式:shell 里umask 077、systemd 服务里UMask=0077、Python 里os.umask(0o077)(注意它返回旧值、没有只读接口)。但在 Python 程序里更推荐的做法是不改全局 umask(它是进程级状态、不线程安全、还会被子进程继承),而是对确实需要严格权限的文件用os.open(..., 0o600)显式创建——因为 umask 只能减不能加,指定小权限永远不会被削。 - 追问:setuid、setgid、sticky 各是干什么的,为什么解压归档时要清除 setuid? setuid(
0o4000) 让可执行文件以文件属主的身份运行而不是调用者身份——经典例子是/usr/bin/passwd(普通用户要改密码就必须写 root 才能写的/etc/shadow)。setgid(0o2000) 对可执行文件是以属组身份运行,对目录则是「在里面新建的文件自动继承目录的属组」,这是团队共享目录的标准配置(0o2775)。sticky(0o1000) 只对目录有意义:目录人人可写,但只有文件属主(或目录属主、root)能删除和改名,/tmp的0o1777就是它。解压时必须清除 setuid 的原因很直接:归档里可以包含一个属主为 root、带 setuid 位的可执行文件,一旦以 root 解压出来,任何用户执行它都能获得 root 权限——这是完整的本地提权链。所以tarfile的filter="data"会清除 setuid/setgid 位,自己写解压逻辑时也要mode & ~0o7000。两个补充:Linux 对脚本(#!开头)忽略 setuid 位(因为解释器启动过程有竞态风险),以及写入一个 setuid 文件后内核会自动清除该位。 - 追问:Windows 上
os.chmod到底做了什么?跨平台代码怎么写? Windows 没有 Unix 的 rwx 模型,它用的是 ACL(访问控制列表),粒度和语义都完全不同。Python 在 Windows 上把os.chmod映射到「只读属性」这一个开关:如果 mode 里属主的写位(stat.S_IWUSR)为 0 就设置只读属性,否则取消——也就是说chmod(p, 0o600)、0o644、0o777在 Windows 上效果完全相同(都是可读写),0o444和0o000也相同(都是只读)。st_uid/st_gid恒为 0,os.chown根本不存在。跨平台代码的三种处理方式:① 平台分支——if os.name == "posix": os.chmod(f, 0o600),Windows 上要么接受默认、要么用pywin32(win32security)设置 ACL 限制到当前用户;② 依赖目录权限——把敏感文件放进只有当前用户能访问的目录(如%LOCALAPPDATA%);③ 加密而不是靠权限。另外要留意 git 只记录可执行位(core.fileMode),Windows 上 clone 出来的文件权限由 umask 决定,CI 里常见「脚本没有执行权限」的问题就来自这里。
八、加强记忆
Unix 权限是三组 rwx(属主/属组/其他人)的八进制表示——os.chmod(p, 0o600) 设置、st.st_mode & 0o777 读取、stat.filemode() 转成 -rw-r--r--;写的时候千万别忘 0o 前缀(644 是十进制、权限完全错乱还不报错)。判断「能不能做某事」是按身份归组而不是取最大权限:你是属主就只看 user 三位,所以 0o604 意味着属组成员反而什么都做不了。rwx 对目录的含义完全不同:r 是能列出名字、w 是能在里面增删改名、x 是能进入/穿过——由此得出最反直觉的一条:删除文件看的是所在目录的 w+x,和文件自身权限无关(0o444 的只读文件照样能被删),这也是 /tmp 必须加 sticky 位(0o1777) 的原因:人人可写但只能删自己的。umask 是减法掩码:实际权限 = 请求的 mode & ~umask,默认 0o022 而 open() 请求 0o666、mkdir 请求 0o777,这就是「文件默认 644、目录默认 755」的由来;它只影响创建那一刻、对 os.chmod 毫无影响(这解释了「为什么 chmod 生效但创建时不生效」),而且只能减不能加,所以指定小权限(0o600)永远安全。内置 open() 无法指定权限,要精确控制必须用 os.open(path, O_CREAT|O_EXCL|O_WRONLY, 0o600)——O_EXCL 是为了防符号链接劫持(攻击者预先创建指向 /etc/passwd 的软链接),而「先创建再 chmod」中间那一瞬间文件是 0o644、别人读得到;兼顾原子性用 tempfile.mkstemp()(默认 0o600)+ os.replace()。umask 是进程级全局状态、不线程安全,别在多线程里临时改。三个特殊位:sticky 只对目录有意义、setgid 用在目录上让新文件继承属组(团队共享目录 0o2775)、setuid 极危险(以属主身份运行,解压外来归档必须清除它,否则是完整的提权链)。os.access 按真实 uid 检查且有 TOCTOU 窗口,不要用它做安全判断,直接 try/except。最后,Windows 上整套模型基本无效(chmod 只切换只读属性、uid/gid 恒为 0、真正的模型是 ACL),跨平台代码要做平台分支;递归改权限时目录和文件必须分开处理(统一 0o644 会让目录失去 x 位、整棵树都进不去)。