用 Python 扫描失效的符号链接:区分断链、权限错误与循环链接
文件管理器里能看到链接,Python 却报 FileNotFoundError:移动了数据目录、清理了旧版本,链接仍保留着原来的目标。本文用 Python 标准库写一个只读检查工具,列出目录里的断链,并把“目标缺失”和“无法完成检查”分开报告。
适合会运行终端命令的初级到中级读者。若问题只在定时任务中出现,也要结合 Linux 定时任务的路径与工作目录排查,确认任务检查的确实是同一个目录。
1. 方法:先检查链接本身,再查询目标
- 用
os.walk()遍历真实子目录,设置followlinks=False,不递归进入目录符号链接;onerror收集目录无法列出的错误。 - 用
Path.lstat()确认条目本身是符号链接,再用os.readlink()取得它保存的目标字符串。 - 用
Path.stat()跟随链接查询目标。目标路径不存在,或中间路径应为目录却实际是文件,记为断链;权限不足、循环链接等错误单独记录。
相对目标从链接所在目录开始解释,与启动脚本的工作目录无关。有效的外部目标也算正常,但脚本不会进入链接指向的外部目录。若之前用 Python 大文件排名工具排查过空间,它会跳过符号链接;本工具补充检查这些链接的目标。
这里只验证目标能否解析,不验证目标内容、业务格式或可读性。请检查暂停变更的本地项目目录:扫描不是原子快照,也不会阻止扫描期间的目录替换;实际子目录中的挂载点仍可能被遍历。
2. 准备环境和可重复的样例
需要 Linux、Python 3.9 或更新版本和 bash,无需安装第三方包。本文命令已在 Linux、Python 3.11.4 上验证。
- 在同一个 bash 终端创建独立目录。重复练习时换一个目录名:
python3 --version
mkdir symlink-audit-demo
cd symlink-audit-demo
python3 - <<'PY_DATA'
from pathlib import Path
root = Path("sample")
(root / "nested").mkdir(parents=True)
(root / "data.txt").write_text("ready\n", encoding="utf-8")
(root / "good-file").symlink_to("data.txt")
(root / "good-dir").symlink_to("nested", target_is_directory=True)
(root / "missing-file").symlink_to("gone.txt")
(root / "missing-dir").symlink_to("gone-dir", target_is_directory=True)
PY_DATA
- 将下一节保存为
check_symlinks.py,放在当前的symlink-audit-demo目录中,也可下载完整脚本。样例有四个链接:两个有效,另外两个分别指向缺失的文件和目录。
3. 完整实现:输出 JSON 和退出码
"""Read-only symlink audit for a quiet local directory. Python 3.9+."""
import argparse
import json
import os
from pathlib import Path
import stat
def audit(root):
broken = []
errors = []
symlinks = 0
def record_error(path, exc):
errors.append({"path": str(path), "errno": exc.errno,
"message": str(exc)})
def walk_error(exc):
record_error(exc.filename or root, exc)
for folder, dirs, names in os.walk(root, onerror=walk_error,
followlinks=False):
dirs.sort()
for name in sorted(dirs + names):
path = Path(folder) / name
try:
mode = path.lstat().st_mode
if not stat.S_ISLNK(mode):
continue
symlinks += 1
target = os.readlink(path)
except OSError as exc:
record_error(path, exc)
continue
try:
path.stat()
except (FileNotFoundError, NotADirectoryError):
broken.append({"path": str(path.relative_to(root)),
"target": target})
except OSError as exc:
record_error(path, exc)
return {"root": str(root), "symlinks": symlinks,
"broken": sorted(broken, key=lambda item: item["path"]),
"errors": sorted(errors, key=lambda item: item["path"]),
"complete": not errors}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("directory", type=Path, help="directory to inspect")
args = parser.parse_args(argv)
try:
root = args.directory.resolve(strict=True)
if not stat.S_ISDIR(root.stat().st_mode):
parser.error("directory must refer to a directory")
except (OSError, RuntimeError) as exc:
parser.error(str(exc))
report = audit(root)
print(json.dumps(report, indent=2, sort_keys=True))
if report["errors"]:
return 2
return 1 if report["broken"] else 0
if __name__ == "__main__":
raise SystemExit(main())
根目录会先解析成真实路径,因此传入的根目录本身可以是有效的目录链接。遍历时同时检查 dirs 和 names,才能包含目录链接。报告中的断链路径相对于根目录,错误路径保留绝对位置;JSON 会转义特殊字符,包含空格或换行的名称也不会被当成多条记录。
| 退出码 | 含义 | 后续动作 |
|---|---|---|
0 |
检查完成,没有断链 | 继续后续任务。 |
1 |
检查完成,发现断链 | 核对并恢复目标,或单独评审链接路径。 |
2 |
参数、根目录或扫描错误 | 读取 stderr 或报告中的 errors,修复后重查。 |
扫描错误优先返回 2,即使同时发现断链。complete: true 表示未记录到扫描错误,并不表示没有断链;参数或根目录无效时,错误写入 stderr,不产生 JSON。报告会在内存中保留全部断链和错误,适合项目目录排障。
4. 验证断链与目标恢复
- 运行工具,并保留退出码。报告写在被检查目录之外:
python3 check_symlinks.py --help
if python3 check_symlinks.py sample > report.json; then
scan_rc=0
else
scan_rc=$?
fi
printf 'exit=%s\n' "$scan_rc"
python3 - <<'PY_CHECK'
import json
from pathlib import Path
report = json.loads(Path("report.json").read_text())
assert report["symlinks"] == 4
assert report["complete"] is True
assert report["errors"] == []
assert report["broken"] == [
{"path": "missing-dir", "target": "gone-dir"},
{"path": "missing-file", "target": "gone.txt"},
]
print(f"symlinks={report['symlinks']} broken={len(report['broken'])} complete={report['complete']}")
PY_CHECK
预期输出末尾为:
exit=1
symlinks=4 broken=2 complete=True
- 在这个演示目录恢复两个缺失目标,再运行检查:
printf 'restored\n' > sample/gone.txt
mkdir sample/gone-dir
if python3 check_symlinks.py sample > repaired.json; then
scan_rc=0
else
scan_rc=$?
fi
printf 'exit=%s\n' "$scan_rc"
此时应输出 exit=0,repaired.json 中 broken 和 errors 都是空列表。链接本身没有被删除或重建。真实项目中先核对目标是否应该恢复,不能仅为消除告警而创建空文件。
5. 验证循环链接,再接入自动化
- 在刚才已经恢复目标的样例里添加一对循环链接:
ln -s loop-b sample/loop-a
ln -s loop-a sample/loop-b
if python3 check_symlinks.py sample > loop-report.json; then
scan_rc=0
else
scan_rc=$?
fi
printf 'exit=%s\n' "$scan_rc"
python3 - <<'PY_LOOP'
import errno
import json
from pathlib import Path
report = json.loads(Path("loop-report.json").read_text())
assert report["complete"] is False
assert report["broken"] == []
assert len(report["errors"]) == 2
assert all(item["errno"] == errno.ELOOP for item in report["errors"])
print("loop errors=2 complete=False")
PY_LOOP
预期输出为 exit=2 和 loop errors=2 complete=False。循环解析失败不能伪装成“目录里没有断链”。此外还验证了空目录、无效根目录、相对和绝对目标、Unicode 名称、目标路径中间不是目录,以及低权限子进程遇到的真实 PermissionError。
- 用于自己的批处理时,先建立可写的报告目录,并替换下例的解释器、脚本、数据和报告绝对路径。不要把这些示例路径原样加入定时器:
if /usr/bin/python3 /srv/tools/check_symlinks.py /srv/project/data > /srv/reports/symlinks.json; then
scan_rc=0
else
scan_rc=$?
fi
printf 'symlink audit exit=%s\n' "$scan_rc"
exit "$scan_rc"
这个包装片段将工具的退出码传回调用方;即使开启 set -e,也能通过 if 捕获预期的非零结果。重定向会覆盖旧报告;如果需要保留历史,改用每次不同的报告名。
6. 常见问题与直接处理
| 现象 | 处理方法 |
|---|---|
| 目标文件被移动,出现断链 | 对照 target 检查链接所在目录,恢复正确目标或在单独的维护步骤中重建链接,再运行工具。 |
errors 出现 Permission denied |
使用实际任务账户复查目录的读取和路径各层的执行权限;按项目需求修正权限,不要把这一项当作缺失目标。 |
errors 出现 Too many levels of symbolic links |
检查链接链条,修复循环或过长链条,再扫描。 |
| 目标存在,程序仍读不到内容 | 本工具不测试内容读取权限;使用运行该程序的账户检查目标文件权限和格式。 |
| 目录链接内部的断链没出现 | 工具只检查这个目录链接的目标是否存在。要扫描目标内部,请另一次运行并把目标目录作为根目录。 |
7. 总结
先区分链接本身与目标,再根据退出码处理断链和扫描失败,就能把文件找不到的问题变成可重复的检查。脚本只读元数据,不会自动删除或修复链接。需要进一步检查普通数据文件是否改变时,可以接着使用 SHA-256 数据清单教程。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-broken-symlinks.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。