124 个测试全绿,集成路径却是坏的:一次 CLI 工具的审计与修复

一个 Fedora 上管理 Copr / OBS 第三方源的命令行工具,跑了一遍 pytest:124 个测试全绿,ruff 和 mypy 也没有意见。看起来挺健康。

但实际用起来,copa audit 稳定崩溃、多关键词搜索搜不到东西、正则搜索永远空、标着”高风险”的仓库照样能装。单元测试覆盖的是函数,不是集成路径——这些缺陷没有一个能在单元测试里暴露。

这篇记录那次审计找出的一批真实缺陷、修法,以及中文化和打包时另外两个坑。

一、基线:测试全绿不等于能用

审计的第一步不是读代码,是建基线:通读全部 8 个模块、跑 pytest / ruff / mypy,然后实际执行每一条子命令。

对比很刺眼:

维度 状态
单元测试 124 全绿
ruff / mypy 通过
copa audit 稳定崩溃
多关键词搜索 一条都搜不到
正则搜索 永远空结果
--copr OWNER/PROJECT 前 10 条没命中时失效
高风险仓库 可以安装,且 -y 能绕过确认

结论是:测试覆盖的是「函数行为」,缺的是「命令串起来之后还算不算对」。所以后面补的 24 个回归测试,重点全在集成路径上。

二、按「会不会直接坏」排序的缺陷

① audit 必崩:分页参数不受支持。

CoprBackend.get_builds() 用了 python-copr 不认的分页方式,直接抛异常。修法是改用 pagination={"limit": limit}(或先取列表再本地切片)。

② 多关键词 AND 搜索不生效:被拼成了字面 glob。

关键词被拼成 "ghostty terminal",后端又包成 *ghostty terminal*——这个 glob 要求包名里连续出现带空格的整串,自然一条都没有。修法是逐词查询后合并,再做 AND 过滤。

③ 正则搜索永远空:后端没有服务端正则能力。

原实现把 ^ghost 整个当字面量发出去查。修法是先抽取字面量做查询种子(^ghost → ghost,vim|neovim → vim、neovim),拿到候选后再本地用完整正则过滤。纯元字符的模式要明确报错,而不是静默返回空。

④ --copr OWNER/PROJECT 在前 10 条没命中就失效。

那段处理被包在「搜索结果非空」的 if 里,而搜索结果只取前 10 条。修法是这条路径直接 get_project() 加载指定项目,不走关键词搜索。

⑤ 标着 blocked 的仓库仍可安装,-y 还能绕过确认。

风险等级只是展示,没有在安装路径上强制执行。修法是在列表过滤 + 两条安装路径(普通安装与 --copr 直连)都加防御性拦截,-y 也不能绕过。

⑥ 本地仓库安装失败仍返回退出码 0。

-y 分支和数字选择分支都打印了”Installation failed”,然后 return 0——和 Copr/OBS 路径完全不一致。修法是 return 1。这类”失败但退出码 0”最坑自动化。

⑦ Package.repo 恒为空:三种字段名。

dnf5 的输出字段是 Repository、已安装查询是 From repository、dnf4 是 Repo。原解析只认一种。三种全兼容。

⑧ -y 下仍弹交互,空 stdin 直接 traceback。

两个原因:没给 dnf 传 --assumeyes;以及多处裸 input() 不处理 EOF。修法是该传的传、该跳过的跳过,所有 input() 换成 EOF 安全的包装。

⑨ dnf5 下 OBS 仓库禁用失败。

dnf4 的 --set-disabled 在 dnf5 里不可用。修法是按当前 dnf 版本分派:dnf5 用 config-manager setopt <repo>.enabled=0,dnf4 保留原参数。

⑩ Copr 的 chroot 回退会选错架构。

只按版本回退、没校验架构,x86_64 上会选中 fedora-43-aarch64。修法是加架构校验。

⑪ OBS 项目名可以路径穿越。

项目名被直接拼进文件路径、交给 sudo curl / sudo rm——copa repo remove obs:../../... 能操作任意 *.repo。修法是项目名字符集校验,阻断 ../。这条是安全问题,不是功能问题。

三、配置解析:只捕 TypeError 是不够的

用户手改 config.toml 写错一个字段类型,所有命令都会崩:

1
2
[search]
search = 1 # 本该是子表,写成了整数

原代码对 data[key] 直接调 .items(),且只捕获 TypeError,于是抛的是 AttributeError: 'int' object has no attribute 'items'——而 main() 在每条命令前都会加载配置,等于整个工具不可用。

修法两件事:加 isinstance(data[key], dict) 检查,并捕获 AttributeError;失败时回退默认值而不是中断。手写配置文件永远会有错,工具要容错。

四、退出码与 JSON:失败也要有结构化输出

CLI 工具的两条基本纪律,这个项目原先都没完全做到:

  • 失败必须非零退出码(第 ⑥ 条);
  • --json 模式下失败也要输出 JSON,带 error 字段,而不是直接 return 1 什么都不打印。

后者对脚本调用方很重要——它们解析不了空输出,只会报一个更莫名其妙的错。

顺带统一了「必需的检查 vs 可选的检查」:doctor 原先把”可选 OBS 凭据缺失”和”有 dnf 没 dnf5”都算失败,还总是返回 0;现在区分必需/可选,必需项失败返回 1。audit 也补上读取配置(warn_experimental、block_*)和检查 testing only,网络错误单独报,不再统一报成”项目不存在或已删除”。

五、中文化的陷阱:内部值不能翻

加中英双语时最容易踩的坑不是翻译质量,是把内部值也翻了。

这个工具里,enabled / disabled、风险等级 low / medium / high / blocked、构建状态、配置枚举——这些值会参与字符串比较、会写进 JSON、会被 dnf 输出解析。一旦展示层的中文化漏进这些地方,逻辑会静默失效。

定下的规则是:

  • 源码里的 UI 文案保持英文、用 tr("English message") 包裹,中文集中在 copa/locales/zh_CN.json;
  • 内部枚举、JSON 输出字段、dnf 解析一律保持英文,只在展示层翻译;
  • DnfBackend._run() 强制 LANG=C / LC_ALL=C,测试里也固定 LC_ALL=C;
  • argparse 自动生成的参数错误不翻译(翻了反而不好搜)。

还顺手加了中文确认别名:是 / 是的 / 好 / 确定,否 / 不 / 不是 / 取消——不然中文界面下用户回一个”是”,程序认不出来。

六、打包:新目录不会自动进 wheel / RPM

新增了 copa/locales/ 目录放翻译,但打包时不会自动收录:pyproject.toml 要显式加 package-data,RPM spec 的 %{python3_sitelib}/copa/ 虽然包含子包,数据文件仍要确认。

验证方式很直接:装完 rpm -ql 看有没有 zh_CN.json,或者解 wheel 看一眼。否则就是”本地能显示中文、装完又变英文”。

另外一条:make build 原来依赖预置的源码包,改成一键从工作区生成 tarball 再 rpmbuild,并在打包时排除 build/、dist/、rpms/、srpm/ 与缓存目录——否则源码包会夹带构建产物。

七、发布:CI 该覆盖什么

最后的发布链路覆盖了这些环节:版本一致性校验、ruff + mypy、Python 3.11/3.12/3.13 三档测试、Fedora 43 与 44 的 RPM 构建、SRPM、GitHub Release、以及触发 Copr 构建(Copr 侧两个 chroot 都成功才算完)。

这里有个小判断值得记:本机 dnf5 --refresh repoquery 访问 packages.redhat.com 会超时,但那是本机网络环境的问题——不能据此判断构建失败,要以 Copr API 的结果为准。区分”我的网络”和”构建产物”是发布验收的基本功。

总结

这次审计最大的收获不是修了 18 个问题,而是重新理解了「测试全绿」的含义:

  • 单元测试全绿只能说明函数对,命令串起来之后对不对,得靠真的去跑每一条子命令;
  • 失败要非零退出码、要能输出结构化错误,否则自动化链路会以各种奇怪的方式失败;
  • 中文化只翻展示层,内部枚举和 JSON 字段保持英文,否则逻辑会被翻译静默破坏;
  • 新增资源目录记得进打包清单,否则”本地好的、装完就变”。

一句话:给工具做审计,先跑命令,再看代码。