用 Python 实测目录写权限:定位 Permission denied、只读挂载与路径错误
批处理任务计算完才发现输出目录报 Permission denied;换成管理员运行又“好了”,但定时任务依旧失败。本文用 Python 标准库在目标目录实际创建、写入并删除一个小文件,确认当前运行身份此刻能否完成这条写入路径。
这适合初级到中级读者,用于导出、备份和 Hugo 构建前的诊断。如果还需要估算大型产物的空间预算,可配合磁盘空间预检使用。
1. 方法:用实际操作验证写入路径
os.access() 官方文档提醒,权限检查通过后,真实 I/O 仍可能失败。我们的探测直接尝试写入,并记录 errno;它只反映当前账号、当前环境和检查时刻的结果。
顺序是:解析已有目录 → 创建随机命名文件 → 写入 12 字节 → flush → fsync → 关闭并删除。它不会修改已有业务文件,也不会创建缺失目录;它会真实写入临时文件,请对可信、允许测试的目录运行。目录符号链接会解析到实际目标。
2. 准备环境
需要 Linux、Python 3.9 或更新版本,以及运行入口示例所需的 Bash。无需 pip 包,以下示例已在 Linux、Python 3.11.4、普通用户账号下验证。
- 新建演示目录;重复练习时换一个新名字:
python3 --version
mkdir write-probe-demo
cd write-probe-demo
mkdir output
- 把下一节保存为当前目录中的
write_probe.py,也可下载完整脚本。后续命令都在这个演示目录执行。
3. 实现:返回 JSON 和退出码
"""Probe a trusted existing directory by creating a tiny temporary file. Linux."""
import argparse
import errno
import json
import os
from pathlib import Path
import tempfile
def probe(directory):
report = {
"path": os.path.abspath(directory),
"euid": os.geteuid(),
"ok": False,
"stage": "resolve",
}
try:
target = Path(directory).resolve(strict=True)
report["path"] = str(target)
report["stage"] = "create"
with tempfile.NamedTemporaryFile(
mode="w+b", dir=target, prefix=".write-probe-"
) as handle:
report["stage"] = "write"
handle.write(b"write-probe\n")
report["stage"] = "flush"
handle.flush()
report["stage"] = "fsync"
os.fsync(handle.fileno())
report["stage"] = "cleanup"
report.update(ok=True, stage="done")
except (OSError, RuntimeError) as exc:
number = getattr(exc, "errno", None)
report.update(
error=type(exc).__name__,
errno=number,
code=errno.errorcode.get(number, "PATH_ERROR"),
message=str(exc),
)
return report
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("directory", help="existing trusted output directory")
args = parser.parse_args(argv)
report = probe(args.directory)
print(json.dumps(report, ensure_ascii=True))
return 0 if report["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())
NamedTemporaryFile 在指定目录创建可见的临时文件,并在正常关闭时删除。with 也会在普通异常时尝试清理;SIGKILL 或掉电可能留下 .write-probe- 文件。
path 是解析后的路径,euid 是实际执行文件操作的有效用户 ID。stage 记录最近开始的步骤;正常完成为 done。若异常退出时清理又失败,清理异常可能覆盖原始异常,因此仍需结合 message 排查。JSON 统一写到 stdout;命令行用法错误由 argparse 写到 stderr。
| 退出码 | 含义 | 后续处理 |
|---|---|---|
| 0 | 写入、同步与正常清理完成 | 继续尝试启动任务 |
| 1 | 路径或实际文件操作失败 | 查看 code、stage、message |
| 2 | 缺少参数或未知选项 | 修正调用方式 |
4. 运行与验证
- 查看帮助,检查
output,再立即查看退出码:
python3 write_probe.py --help
python3 write_probe.py output
printf 'exit=%s\n' "$?"
成功时 JSON 包含 "ok": true 和 "stage": "done",随后输出 exit=0;绝对路径和 euid 随机器变化。失败时会返回 "ok": false,并包含如 "code": "EACCES" 的错误信息。
- 用一次性测试目录验证正常写入、清理、缺失路径、普通文件路径和无写权限的目录:
python3 - <<'PY_CHECK'
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
script = str(Path("write_probe.py").resolve())
def check(path, status, code=None):
result = subprocess.run([sys.executable, script, str(path)],
capture_output=True, text=True, check=False)
report = json.loads(result.stdout)
assert result.returncode == status, report
assert report["ok"] is (status == 0), report
if code:
assert report["code"] == code, report
return report
with tempfile.TemporaryDirectory(prefix="probe-test-", dir=".") as name:
root = Path(name).resolve()
writable = root / "writable"
writable.mkdir()
check(writable, 0)
assert not list(writable.iterdir())
check(root / "missing", 1, "ENOENT")
regular = root / "regular.txt"
regular.write_text("keep me", encoding="utf-8")
check(regular, 1, "ENOTDIR")
assert regular.read_text(encoding="utf-8") == "keep me"
denied = root / "denied"
denied.mkdir()
denied.chmod(0o500)
try:
if os.geteuid() == 0:
print("SKIP: run as a normal user to test EACCES")
else:
check(denied, 1, "EACCES")
finally:
denied.chmod(0o700)
print("PASS: creation, cleanup, missing path, file path, permission checks")
PY_CHECK
非 root 用户应看到最后的 PASS,且不出现 SKIP。代码只修改自己新建的测试目录权限,并在 finally 恢复;root 可能绕过普通权限位,所以跳过该分支。不要为了测试去修改真实业务目录。
- 模拟只读文件系统、空间不足、配额和同步错误,无需填满磁盘或更改挂载:
python3 - <<'PY_ERRORS'
import errno
from pathlib import Path
from unittest.mock import patch
from write_probe import probe
for code in ("EROFS", "ENOSPC", "EDQUOT"):
number = getattr(errno, code)
with patch("write_probe.tempfile.NamedTemporaryFile",
side_effect=OSError(number, code)):
report = probe("output")
assert report["ok"] is False and report["code"] == code, report
with patch("write_probe.os.fsync", side_effect=OSError(errno.EIO, "I/O error")):
report = probe("output")
assert report["code"] == "EIO" and report["stage"] == "fsync", report
assert not list(Path("output").glob(".write-probe-*"))
print("PASS: simulated disk errors and fsync failure cleanup")
PY_ERRORS
应看到 PASS: simulated disk errors and fsync failure cleanup。这是错误处理逻辑测试,不是实际只读挂载或配额测试;正常与权限失败路径则使用真实文件操作。
5. 接入任务入口
将下面内容保存为演示目录中的 run_demo.sh。只有探测成功才会打印 TASK STARTED:
#!/usr/bin/env bash
set -eu
cd -- "$(dirname -- "$0")"
if python3 write_probe.py output; then
python3 -c 'print("TASK STARTED")'
else
result=$?
printf 'Write probe stopped the task (exit %s)\n' "$result" >&2
exit "$result"
fi
执行:
bash run_demo.sh
接入真实任务时,将打印命令替换为导出或构建命令,使用任务实际输出目录。为便于排错,先创建并确认目标目录,再运行入口。定时任务必须使用同一账号、容器、挂载环境和解释器;工作目录与解释器的设置可参考定时任务路径排错。
6. 排错与边界
先以任务本身的身份检查账号和目录:
id
pwd
ls -ld -- . output
检查父目录是否允许该账号通过,以及输出目录是否允许创建文件。只对本应拥有写权限的账号或组授予必要权限;不要用全目录 chmod 777 或管理员运行掩盖原因。
下面的错误名来自 Python errno 官方文档。错误描述可能因系统语言不同而变化,应优先看 code。
| 错误 | 具体处理 |
|---|---|
ENOENT |
核对 JSON 中的路径和工作目录,确认目标应存在后再创建。 |
ENOTDIR |
传入目录路径,检查某一级路径是否误用了普通文件。 |
EACCES / EPERM |
核对运行账号、目录及父目录权限;权限位正确时继续检查 ACL 和安全策略。 |
EROFS |
确认任务所在环境的挂载是否只读;选择获准写入的卷,chmod 无法解除只读挂载。 |
ENOSPC / EDQUOT |
分别检查可用空间、inode 和用户配额。 |
EIO 或 stage: fsync 失败 |
保留完整报错,检查存储状态与文件系统支持;不要把同步失败当作成功。 |
stage: cleanup 失败 |
检查目录权限是否在运行中变化,并根据报错中的确切文件名处理遗留探测文件。 |
新建文件成功不能证明可覆盖已有文件。 目标文件权限、粘滞位、不可变属性及原子替换还可能限制后续操作。需要替换 JSON 状态文件时,继续参考原子写入教程。
探测也不预留空间,不测试大文件容量或子目录创建。按 os.fsync() 文档先 flush 再同步,只能检验这次文件同步调用;本例没有同步父目录,不能据此推断掉电后的目录项持久性。网络文件系统上的调用可能阻塞,此脚本没有硬超时;即使探测通过,实际任务仍必须处理自己的 OSError。
7. 小结
在真正输出的目录中,以任务相同的运行身份做小型写入探测,可以比查看权限位更直接地定位故障。先验证成功和失败退出码,再接入任务入口;保留真实写入时的错误处理,避免把检查通过当作后续一定成功。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-directory-write-probe.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。