共享临时目录是其他用户也可能写入的命名空间,并不意味着里面每个文件都公开可读;内容访问仍受文件权限约束。真正危险的假设,是以为选定一个路径,就已经拥有这个名字对应的对象。应原子地创建新对象,并继续使用返回的句柄,而不是信任一个可预测名字,或先检查它不存在、稍后再打开。
名字归属与内容访问是两回事
Linux 的 /tmp 常用模式 1777,即宽松的目录写入、搜索权限,加上 sticky 位。sticky 限制其他用户删除或重命名目录项,却不让现有文件自动保密,也不提前保留尚未创建的名字。文件属主、目录属主、能力及其他访问控制仍然重要。应检查实际环境,不能假定所有操作系统都采用同一路径、挂载方式和权限。
普通写入打开可能跟随预先存在的符号链接。O_CREAT|O_EXCL 拒绝已经存在的最后一个路径名称,包括符号链接。O_NOFOLLOW 则拒绝最后一段是符号链接的情况,但单独使用时不会拒绝既有普通文件,也不保护所有祖先目录。这些是不同契约。应使用可信父目录和适当的临时文件接口,不能把一个标志当成通用路径安全方案。
创建模式也只在真正新建文件时生效。以 O_CREAT 和模式 0600 打开已有文件,不会收紧它原先的权限。随机名字降低碰撞概率,但安全性质来自排他创建,而不只是字符串看起来难以猜测。只生成名字的接口仍留下一段空隙,其他进程可以在真正创建之前占用该名字,因此先检查再创建也不能消除竞争窗口。
位置设置不等于隔离
Python 的 tempfile.mkstemp 安全地创建并打开文件,调用方负责关闭和删除。TemporaryDirectory 提供受限目录和正常退出时的清理。TMPDIR 影响位置选择,却不会自己创建私有目录,也不验证隔离策略。Python 会缓存默认目录选择;如果操作必须使用某个位置,应明确传入 dir=,不要依赖运行中修改环境变量来重新选择。
systemd 的 PrivateTmp= 可以给服务单独的临时文件命名空间,但它改变共享行为,并有平台与 unit 条件,不等同于设置一个环境变量。服务分别使用独立命名空间时,不能依赖跨服务的 /tmp 路径通信。下方本地实验不会启用或验证 systemd 隔离,也不能据此宣称部署已经具有相同隔离边界。
tmpfs 随使用量分配空间,在可用且被允许时可以使用交换空间。没有指定大小时,默认容量上限是物理内存的一半;这是上限,不是挂载时预留的内存。数据分配额度和 inode 数量都可能分别阻止后续工作。/tmp 也不一定挂在 tmpfs 上,因此容量监控应依据实际挂载与负载,不能把目录名字直接当成存储类型。
坏掉的样子
可预测名称可能把写入导向别处,也可能复用权限不符合预期的对象。只在成功路径清理,会在普通异常之后遗留文件。上下文管理器和 finally 能帮助这些路径,但无法保证在 SIGKILL 或机器崩溃后执行。应设计遗留文件清理,避免删除其他进程仍在使用的内容,也不要让临时存储承载唯一的持久副本。
共享容量上限可以让一个写入方影响另一个。创建和后续写入可能在不同阶段失败,成功创建空文件并没有为最终内容保留空间。需要限制暂存用量,并按数据寿命与大小选择文件系统。持久应用状态目录不是一次性构建输出的通用去处;位置、清理责任与存续要求应一起确定,而不是空间不足时随意迁移。
快速验证
把程序保存为 temp_contract.py,在 Linux 上使用 Python 3.11 或更新版本执行 python3 temp_contract.py。它在新建私有目录中,以同一个身份对合成字节执行真实文件系统操作,不修改现有权限、系统配置或其他用户的文件。所有被创建的对象都属于本次临时样本。
from pathlib import Path
from tempfile import TemporaryDirectory, mkstemp
import errno
import os
import stat
with TemporaryDirectory(prefix="temp-contract-") as directory:
root = Path(directory)
target = root / "target"
target.write_text("original")
predictable = root / "predictable"
predictable.symlink_to(target)
with predictable.open("w") as stream:
stream.write("followed")
assert target.read_text() == "followed"
for flags, expected in [
(os.O_WRONLY | os.O_CREAT | os.O_EXCL, errno.EEXIST),
(os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, errno.ELOOP),
]:
try:
fd = os.open(predictable, flags, 0o600)
except OSError as error:
assert error.errno == expected, error
else:
os.close(fd)
raise AssertionError("pre-existing symlink was accepted")
assert target.read_text() == "followed"
before = stat.S_IMODE(target.stat().st_mode)
fd = os.open(target, os.O_WRONLY | os.O_CREAT, 0o600)
try:
assert stat.S_IMODE(os.fstat(fd).st_mode) == before
finally:
os.close(fd)
fd, name = mkstemp(prefix="owned-", dir=root)
try:
mode = stat.S_IMODE(os.fstat(fd).st_mode)
assert mode & 0o077 == 0
assert os.write(fd, b"synthetic data") == 14
os.lseek(fd, 0, os.SEEK_SET)
assert os.read(fd, 14) == b"synthetic data"
finally:
os.close(fd)
os.unlink(name)
assert not Path(name).exists()
print("ordinary open followed link; EXCL and NOFOLLOW rejected it")
print("existing mode preserved:", oct(before), "; temporary mode:", oct(mode))
assert not root.exists()
print("descriptor round trip and normal-exit cleanup passed")
普通打开先改变符号链接指向的文件。之后两个带保护标志的打开分别返回预期错误,并保持目标内容不变。再次打开现有目标时,其测得的权限保持原样;实验刻意不假定进程采用哪种 umask。安全创建的临时文件没有组和其他用户权限位,通过返回的描述符完成写入、定位与读取的完整往返。
在 Python 3.12.14 上,全部断言通过,目标模式为 0644,临时文件模式为 0600。正常结束后,显式创建的临时文件和外围样本目录都被移除。这里验证真实打开语义和正常清理,没有验证跨用户攻击、并发竞争、突然终止、tmpfs 耗尽、网络文件系统行为或命名空间隔离。那些性质需要在真正依赖其保证的环境中分别测试,不能从单身份本地样本推导出来。
来源
- Linux open(2),O_CREAT 模式应用、O_EXCL 及末段 O_NOFOLLOW 语义,已核验 2026-10-15。
- Python 3.12 tempfile,mkstemp、TemporaryDirectory、默认位置缓存与清理边界,已核验 2026-10-15。
- Linux tmpfs 文档,动态分配、交换空间及独立的容量和 inode 上限,已核验 2026-10-15。
- systemd.exec(5),PrivateTmp= 共享边界与实现前提,已核验 2026-10-15。
- Linux unlink(2),目录写入搜索要求与 sticky 所有权限制,已核验 2026-10-15。