PAYATHON 2026

Bash 如何避免仅在路径不存在时创建并写入文件的竞态条件?

数据小陈

Bash 的 set -C(set -o noclobber)可以防止 > 覆盖已有普通文件。要做到“任何类型的路径已存在都必须失败”,直接创建需要 open() 的 O_CREAT | O_EXCL 语义。如果可以在 Bash 中调用外部工具,也可以使用临时文件加 ln -T,在不覆盖已有路径的情况下发布结果。

为什么先检查再写入不安全

[[ -e "$file_path" || -L "$file_path" ]] 和后面的重定向是两个独立操作。即使检查准确,其他进程也可能在两者之间创建或替换目标,这就是 TOCTOU(检查与使用之间的竞态)。增加检查次数无法消除这个竞态窗口。

普通重定向在检查文件描述符之前就可能截断已有文件、跟随符号链接,或者打开 FIFO。fstat() 可以检查已经打开的对象类型,却不能撤销这些副作用,也不能单凭文件描述符判断该对象在打开前是否存在。

noclobber 能解决哪些情况

如果只需要避免覆盖普通文件,可以这样写:

(
    set -o noclobber
    output_the_data > "$file_path"
)

子 Shell 将选项变化限制在括号内。如果重定向失败,output_the_data 不会执行;整个命令的非零状态也可能来自数据生成失败,不能一律解释为“文件已存在”。

Bash 重定向文档将 noclobber 的拒绝条件描述为目标已存在且是普通文件,这并不意味着它会拒绝所有已有对象。例如,已有 FIFO 可能使打开操作阻塞,或让输出进入管道。因此,这个方案无法覆盖要求中的所有路径类型。

严格拒绝已有路径:临时文件加硬链接

在提供 GNU Coreutils 且文件系统支持硬链接的环境中,可以先生成完整内容,再用 ln -T 创建目标名称:

create_file() (
    local file_path=$1
    local parent tmp

    case "$file_path" in
        ''|*/)
            printf '目标必须是非空的文件路径,不能以 / 结尾\n' >&2
            exit 1
            ;;
    esac

    parent=$(dirname -- "$file_path") || exit 1
    tmp=$(mktemp -- "$parent/.create.XXXXXXXXXX") || exit 1
    trap 'rm -f -- "$tmp"' EXIT

    # 临时文件已由 mktemp 创建,因此这里有意使用 >|。
    output_the_data >| "$tmp" || exit 1

    # 不使用 -f;目标名称已被占用时失败。
    ln -T -- "$tmp" "$file_path"
)

create_file "$file_path"

这段代码假定目标父目录已经存在,且临时文件及相关目录不会被不可信进程替换。output_the_data 需要在写入失败时返回非零状态。

最后一步创建硬链接时,不会覆盖已存在的目标名称。无论目标是普通文件、目录、符号链接还是悬空符号链接,操作都应失败。并发创建者争用同一个尚不存在的名称时,由文件系统决定谁成功创建,无需预先检查。GNU ln 默认不替换已有文件;-T 则防止它把目标目录或指向目录的符号链接解释成“在里面创建链接”。参见 GNU ln 文档。

成功后,目标名称和临时名称指向同一个普通文件。退出时删除临时名称,目标仍然保留。mktemp 必须实际创建临时文件,不能使用只生成名称的 mktemp -u,否则会重新引入竞态。参见 GNU mktemp 文档。

这个方案有几项限制:

  • 临时文件必须和目标位于同一文件系统,因此示例将其放在目标父目录中。
  • 即使目标已经存在,数据生成仍会先执行,随后链接失败。这个方案保证目标不被覆盖,但不保证生成过程没有运行。
  • 目标继承临时文件的权限。mktemp 默认权限较严格;如需调整,应在建立目标链接前完成。
  • -T 是 GNU 选项,不能假定所有系统都支持。Windows 下的 Bash 环境还需确认工具实现与文件系统能力。

如果必须先占用目标,再开始写入

需要使用类似下面的系统调用:

int fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0600);

O_CREAT | O_EXCL 将目标存在性判断与创建合并为原子操作。目标已存在时打开失败;最终路径分量为符号链接时也失败,无论链接指向哪里。参见 Linux open(2) 文档。

辅助程序可以先执行这个打开操作,再将描述符接到数据生成命令的标准输出。Bash 的 exec {fd}>"$file_path" 可以保存文件描述符,但没有提供直接指定上述完整打开标志的语法;配合 noclobber 仍有前述限制。

原子创建只保证创建发生时没有覆盖已有目标。它不能阻止有权限的其他进程随后删除或替换路径,也不能防止不可信进程替换父目录。直接打开后持续通过同一个描述符写入,可以避免后续重新按路径打开文件;如果整个目录树都存在恶意竞争,则需要目录描述符及更严格的路径解析控制。

备注:内容仅供参考。