Skip to content

fix: #540 的七条审计,以及核验它们时挖出的四条 (2026.9.1.1) - #542

Merged
Sunrisepeak merged 11 commits into
mainfrom
fix/issue540-audit-remediation
Aug 31, 2026
Merged

fix: #540 的七条审计,以及核验它们时挖出的四条 (2026.9.1.1)#542
Sunrisepeak merged 11 commits into
mainfrom
fix/issue540-audit-remediation

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

Closes #540.

七条里六条成立,一条判据打偏。核验过程本身挖出四条没有人报过的,其中一条比
原报告的全部七条都严重。完整核验、量化与设计:
.agents/docs/2026-08-31-issue540-seven-audit-findings.md

基线 aef5191(即 issue 审计的那个 commit)。所有读数取自该 commit 的 detached
worktree,不取自工作树。

逐条判定

# 报告内容 判定
1 --profile 帮助说 release,实际 dev 成立(实测)
2 index update <name> 只过滤项目级索引 成立(源码注释自认)
3 退出码 4 缺失于 docs/11 表 判据打偏,而真实缺口更大
4 [features] 静默吞未知键 成立(实测零诊断)
5 std-module 三键被读却报 unsupported 成立(实测三条假警告)
6 cfg(c-abi = ...) 例子两条腿都断 成立,且是一个成文而从未接线的特性
7 docs/13、docs/17 与 #531 相反 成立,而这是那里最轻的一条
未报告 条件轴拒绝 BuildInputs 的两个成员,其中一个被同一条轴的另一套语法接受
未报告 #531 的供给从不检查自己是否成功;不可解析的包名被报成已供给,记号让它永久
未报告 该路径不认 MCPP_OFFLINE,也不认 MCPP_NO_AUTO_INSTALL
未报告 mcpp::target_libc() 报载荷引用,而文档说它是「已解析的 C 库」

最严重的一条(未报告)

$ mcpp build                    # deps = ["definitely-not-a-real-package"]
Provisioning [xlings] deps (definitely-not-a-real-package)
    Finished dev [unoptimized + debuginfo] in 0.12s        ← 构建成功
$ cat .mcpp/.xlings-deps.stamp
definitely-not-a-real-package                              ← 记为已完成
$ mcpp build
    Finished dev in 0.00s                                  ← 连 Provisioning 都不再打印

xlings 报得完全正确(E_NOT_FOUND + {"exitCode":1,"kind":"result"}),call() 也解析
对了并存进 CallResult::exitCode。调用点只测了 if (!r) —— 而 expected 的错误态表示
「这次调用没发生」,能力自身的状态在值里面。正确写法就在同一个文件里:依赖安装路径
写的是 if (r && r->exitCode != 0 && …)

#531 的注释说它修的缺陷是「声明看起来被接受了却什么都没做,这是一个配置键能有的最坏
形态」。没人读结果,它的修法重现了那个形态。

新增能力:[target.'cfg(<层> = "…")'.build]

docs/14 用一整节记载它,连「为什么不能用 feature 选择代替」都论证过;而
cfgpred::Ctx 只由三元组构造,于是每一个这样的段落被静默丢弃,包成功构建在错误
的 C 库配置上。8 处文档如此(中英各 4)。

实现落在目标侧解析与 P1689 扫描之间的空窗 —— build.mcpp 已经在用同一段窗口做同样的
事(把 directive tail 镜像进 packages[0]),所以第二趟合并复用它的
directives::mark + fold_private_tail,不另造机制。

⚠️ 两趟必须不相交,而只靠 matches() 做不到:cfg(any(linux, c-abi="musl")) 的三元组
腿在第一趟就为真,第二趟会再匹配一次而 append() 是追加式的。按是否命名了层归属。
e2e 328 数 -D 的出现次数来守这条 —— 预处理器分不开一个 -D 和两个。

实现它才暴露的一条

谓词是一次比较,而比较有两侧;此前的设计工作从没问过右侧的取值是什么。它在普通 Linux
宿主上是 gnu,而 docs/14 的表一直写着 glibc/musl/picolibc,e2e 296 的文件头也把
期望的报告写作 c-abi glibc (payload)

一个只被打印的值没有拼写纪律,把它提升为用户比较的对象会追溯地强加一条。 现在层名
的是库,请求侧保留三元组的拼写(规范 §3.4),两者经 c_abi_request_satisfied 比较。
Windows 上 -gnu 命名的是 MinGW 形态而其 C 运行时是 UCRT,因此映射按 OS 分叉。

无感升级

记号从项目搬进 registry 是必要的(它记录的是全局效果),但自审时发现:搬迁后每个已供给
的工程读起来都是「未供给」,配上新的自动安装闸,离线首次构建会被拒 —— 一次纯粹由挪
文件造成的回归。

旧记号因此在唯一一处被采信:就是那道闸,因为在那里网络关着,没有别的办法查证。
它绝不被提升进 registry —— 写它的那个版本不读结果,所以它的含义是「尝试过」而不是
「成功了」,别处采信等于把缺陷带过修它的这次升级。e2e 327 第 4、5 条断言守这个。

测试

  • 单元:test_manifest +6(三份词汇表各一正一负),test_targetside +2。
    ⚠️ 每条正向断言都配了否定对照 —— 「没有警告」会被一个把检查整个删掉的解析器满足。
  • e2e:
    • 327 供给失败会报出来 + 两个开关 + 搬迁连续性(5 条断言,前两条不需要网络)。
      ⚠️ 核心断言跨两次调用:为失败而写的记号在写它的那一次里不可见。
    • 328 层谓词生效 / 不生效 / 恰好一次 + 未知键 + --strict
    • 329 退出码契约,含「退 1 且 stdout 带信封」。
  • 本机全量:96 个单测二进制全过;e2e 276 passed,2 个失败(168、208)在已发布的
    2026.8.30.2 上同样失败
    —— 本机 musl 载荷的环境缺口,非回归,对照已跑。

规范/文档

  • 新增 docs/spec/exit-codes.md(SPEC-003)。2026-08-08 的
    协议设计文档 §R4 把这份契约指派给了 docs/spec/,一直没有写。
  • docs/05、11、13、14、17 + 中文五份;docs/spec/README.md 索引。

七条里六条成立,一条判据打偏。核验过程本身挖出四条没有人报过的,其中一条比原报告
的全部七条都严重。它们几乎全是同一族:**mcpp 关于自己说了一句话,而 mcpp 不遵守它。**

完整核验、量化与设计见
`.agents/docs/2026-08-31-issue540-seven-audit-findings.md`。

── 1. 供给从不检查自己是否成功(未报告,最严重)────────────────

`xlings::call` 返回 `expected<CallResult, string>`,只要子进程跑起来就处于**值**态
—— 能力自身的状态在 `CallResult` 里面,因为 xlings 讲完 NDJSON 协议后按设计退 0。
#531 的调用点只测了 `if (!r)`,于是 xlings 能报出的每一种失败都被读成成功。实测:

    $ mcpp build                    # deps = ["definitely-not-a-real-package"]
    Provisioning [xlings] deps (definitely-not-a-real-package)
        Finished dev [unoptimized + debuginfo] in 0.12s
    $ cat .mcpp/.xlings-deps.stamp
    definitely-not-a-real-package                   ← 记为已完成
    $ mcpp build
        Finished dev in 0.00s                       ← 连 Provisioning 都不再打印

xlings 报得完全正确(`E_NOT_FOUND` + `{"exitCode":1,"kind":"result"}`),`call()` 也
解析对了。⚠️ 正确写法就在同一个文件里:依赖安装路径写的是
`if (r && r->exitCode != 0 && …)`。#531 的注释说它修的缺陷是「声明看起来被接受了却
什么都没做,这是一个配置键能有的最坏形态」—— 没人读结果,它的修法重现了那个形态,
而记号把它变成永久的。

── 2. 该路径不认两个自动安装开关(未报告)────────────────────

它自称与 `[toolchain]` 平权,而那条先例在 `MCPP_OFFLINE` 或 `MCPP_NO_AUTO_INSTALL`
下硬错并报出触发的是哪一个。⚠️ 一个专门导出 `MCPP_NO_AUTO_INSTALL` 来阻止意外下载
的 CI,会从一条从没听说过这个变量的路径上拿到下载。

拦的是安装**动作**而不是整块:已供给好的工程仍然离线构建得出来。

── 3. 记号记录全局效果却存在项目里(未报告)────────────────

安装落在 registry(刻意如此),而 `<project>/.mcpp/.xlings-deps.stamp` 记着它。清掉
或换掉 `MCPP_HOME`,项目仍然声称已装;`mcpp clean` 只删 `target/`,也清不掉。改按
依赖列表哈希存进 registry,并且只在成功时写。

⚠️ 搬迁不得让昨天能跑的构建今天被拒。自审时发现:升级后每个已供给的工程读起来都是
「未供给」,配上第 2 条的闸,离线首次构建会被拒。旧记号因此在**唯一一处**被采信 ——
就是那道闸 —— 因为在那里网络关着,没有别的办法查证。它绝不被提升进 registry:写它
的那个版本不读结果,所以它的含义是「尝试过」而不是「成功了」,别处采信等于把缺陷
带过修它的这次升级。

── 4. 三份手抄的词汇表,三份都漂移了 ───────────────────────

`kKnownBuildKeys`、`kKnownConditionalBuildKeys` 与 xpkg 的 `target_cfg` 列表,都是
别处已有机器可读形式(紧挨其上的读取点、`BuildInputs` 的成员表)的转录。代价不是
少一条警告,而是**一条假的警告**。

* `[build] std-module` / `std-compat-module` / `std-module-flags` 被读取却报
  unsupported —— `kKnownBuildKeys` 的**第二次**漂移,而第一次的详细叙述就在它上方
  八行。
* 条件轴拒绝 `BuildInputs` 的两个成员:`std-module-flags`(#494 就是为这条轴才把它
  挪上来的,成员注释写着「membership here is what makes the cfg axis carry it」)
  与 `private_include_dirs` —— 后者更严重,xpkg 描述符的 `target_cfg` 块,也就是
  **同一条轴的另一套语法**,是接受它的。
* `[features]` 是唯一一个完全没有 schema 检查的结构化段落。

两条列表的消息现在都由列表本身生成。新增 6 个单测,每个都带否定对照 —— 「没有警告」
这类断言会被一个把检查整个删掉的解析器满足。

── 5. cfg(<层> = "…"):文档记载而从未接线的特性 ────────────

docs/14 用一整节记载它,连「为什么不能用 feature 选择代替」和作用域约束都论证过;
而 `cfgpred::Ctx` 只由三元组构造,`match_kv` 只认 os/arch/family/env。于是每一个这样
的段落被**静默**丢弃,包成功构建在错误的 C 库配置上。实测:`cfg(env="gnu")` 生效、
`cfg(c-abi="glibc")` 不生效、零诊断。8 处文档如此(中英各 4)。

实现:目标侧解析(`tsd::resolve`)与 P1689 扫描之间有一段空窗,而 build.mcpp 已经在
用它 —— 它按同样的形状把 directive tail 镜像进 `packages[0]`。第二趟合并用同样的
`directives::mark` + `fold_private_tail`,不另造机制。

⚠️ 两趟必须不相交,而只靠 `matches()` 做不到:`cfg(any(linux, c-abi="musl"))` 的
三元组腿在第一趟就为真,第二趟会再匹配一次,`append()` 是追加式的于是贡献两遍。
按**是否命名了层**归属,而不是按答案。e2e 328 数 `-D` 出现次数来守这条。

⚠️ 层谓词不能选择依赖(层是从依赖图解析出来的),这种段落被报出并忽略。

── 6. 未知的 cfg 键现在会说话 ──────────────────────────────

求值器过去对未知键返回假,而那与「这一段本就不该匹配」读数完全相同。⚠️ 词汇表从
求值器**导出**而不是被转录 —— 否则这条诊断自己就会成为第 4 条里的第四份手抄件。
求值器同时就是校验器:一次遍历回答三个问题,因为另写一个校验器就是同一份文法的
第二个解析器,而本仓库已经为其中一个付过账。

`ident()` 现在接受 `-` 与 `+`,否则 `c-abi` 会被扫成裸词 `c` 加一堆垃圾,诊断能报的
就只有字母 `c`。

── 7. c-abi 层报的是库名,不再是三元组的 env 段 ──────────────

这条是实现第 5 条时才暴露的:谓词是一次比较,而比较有两侧,而此前的设计工作从没问过
右侧的取值是什么。它在普通 Linux 宿主上是 `gnu`(`payload_libc_name` 原样返回 env
段),而 docs/14 的表一直写着 `glibc`/`musl`/`picolibc`,e2e 296 的文件头也把它期望的
报告写作 `c-abi glibc (payload)`。⚠️ **一个只被打印的值没有拼写纪律,把它提升为用户
比较的对象会追溯地强加一条。**

请求侧保留三元组的拼写(规范 §3.4:env 段是对 c-abi 的请求而非答案),两者经
`c_abi_request_satisfied` 比较而非按相等 —— 否则每一次普通 `-gnu` 构建都会被报成请求
不匹配。Windows 上 `-gnu` 命名的是工具链的 MinGW 形态,其 C 运行时是 UCRT,因此映射
按 OS 分叉。

── 8. 退出码:补上 runtime 的一半,并写下被指派的契约 ─────────

原报告说 docs/11 的表漏了 `4`。判据打偏了:那张表按信封命令划定,而**没有一个信封
命令给得出 4** —— `self env --format json` 恰恰是被特意做成绕开产生 4 的
`load_or_init` 的。表真正漏的是 `1`(`xpkg parse` 五处返回)。

2026-08-08 的协议设计文档 §R4 把完整契约指派给了 `docs/spec/`,一直没有写。现在写了:
`docs/spec/exit-codes.md`(SPEC-003),0/1/2/4/70/127 全表 + 稳定性承诺。

── 9. 其余文本 ─────────────────────────────────────────────

* `mcpp build --help` / `mcpp test --help` 说默认档位是 release,而它是 dev。六处说得
  对(含一条 e2e 与 mcpp 自己的 mcpp.toml),两处说错;`prepare.cppm` 那条字段注释是
  没被报告的第三处。
* `mcpp index update <name>` 承诺按索引筛选而只筛项目级。限制此前只写在一条注释里 ——
  一个只有实现者看得到的地方,从外面看与「这功能坏了」无从区分。
* docs/13 与 docs/17 仍在说 `[xlings] deps` 不是安装触发器(#531 之后为假)。
* `mcpp::target_libc()` 的文档改为它实际回答的问题:供给 sysroot 的那个**载荷**包,
  而这个值是目标侧解析的一项**输入**。

── 测试 ────────────────────────────────────────────────────

单元:test_manifest 新增 6 个(三份词汇表各一正一负),test_targetside 新增 2 个;
96 个测试二进制全过。

e2e:新增 327(供给失败会报出来 + 两个开关 + 搬迁连续性,五条断言,前两条不需要网络)、
328(层谓词生效/不生效/恰好一次 + 未知键 + --strict)、329(退出码契约,含「退 1 且
stdout 带信封」)。

⚠️ 判据的分母:327 的核心断言跨**两次**调用 —— 为失败而写的记号在写它的那一次里
不可见,只有第二次构建才分得开「失败了」与「失败了并被记成完成」。328 数 `-D` 的
出现次数而不是用预处理器判断,因为预处理器分不开一个 `-D` 和两个。
一个 32 位宿主会把 64 位的 FNV offset basis 截断,于是它拥有一份与别人不同的键空间
而没有任何东西说明为什么。碰撞本身两侧都不是正确性问题 —— 记号文件存的是**列表**,
比较也是针对内容的,所以两个共键的列表会重新供给而不是悄悄采用对方的记录。
把 `have != want` 换成一个 `needProvision` 布尔,于是自动安装闸可以在供给块**之前**
求值并在采信旧记号时把它关掉 —— 供给块本身保持原来的嵌套层级。行为不变;改的是让
一个千行的 PR 里这一段仍然读得下去。
自审读出:`deps` 的那条消息提供了 `optional = true`,而 mcpp 从来没有这个键。
一条把读者送去一个解析器不认识的键的诊断,与本次发布正在移除的那些警告是同一个
缺陷,只是外了一层。文档化的机制是 `[feature-deps.<name>]`(docs/05 §2.8.2)。

同步中英两份 docs/05。
自审读出:328 此前每一条断言都能被一个只给 packages[0] 打补丁的实现满足。而
docs/14 是为「供给某一层、并支持其下方多个实现的包」写的这个特性 —— 那是一个**库**,
以别人的依赖身份被走到。与本 pass 共用同一段窗口的 build.mcpp tail 只打补丁给根包
(对它自己的用途是对的),照抄那个形状会让这个特性唯一存在的对象没被服务,而所有
只看根包的断言照样全绿。

新增的这条带否定对照:依赖里不匹配的那一段必须不生效。
它是机器接口上的一个字段,而取值从 `gnu` 变成了 `glibc`/`ucrt`。§6 承诺的是
字段的**含义**不变 —— 含义确实没变 —— 但按字面量取值的客户端会受影响,而契约页
不说这件事,就只能靠对方撞上。
实现了这条诊断,却从没跑过它 —— 而「判据写了、绿了、却从没跑到」正是本次发布在修的
那一族。带一条同样重要的对照:同一个谓词下的 build 输入必须**仍然生效**,否则一条
警告就是把一次静默丢弃换成了另一次。
@Sunrisepeak

Copy link
Copy Markdown
Member Author

自审记录

第一版实现全绿之后又读了一遍,读出四条。都不是编译错误,四条里三条测试全绿

1. 挪记录 + 加闸 = 升级悬崖

两处修改各自都对:记号记录全局效果却存在项目里(搬进 registry),以及该路径不认两个
自动安装开关(补闸)。合在一起:升级后每个已经供给好的工程都只有旧位置的记号,
读起来是「未供给」,闸随即拒掉一个昨天还能跑的离线构建 —— 一次纯粹由挪文件造成的回归。

单独 review 每一处都发现不了,因为交互只在升级后的第一次构建上出现,而本地工作树
从来没有「旧记号 + 新引擎」这个状态。

旧记号现在只在唯一一处被采信 —— 就是那道闸,因为在那里网络关着,没有别的办法
查证 —— 并且绝不被提升进新位置:写它的那个版本不读结果,所以它的含义是「尝试过」
而不是「成功了」,别处采信等于把缺陷带过修它的这次升级。判据在 e2e 327 第 4、5 条。

2. 一条诊断指向一个不存在的键

[features].deps 的消息提供了 optional = true,而 mcpp 从来没有这个键。这与本次
发布正在移除的那些假警告是同一个缺陷,只是外了一层。
文档化的机制是
[feature-deps.<name>]

3. e2e 328 此前每一条断言都能被一个只补 packages[0] 的实现满足

而 docs/14 是为「供给某一层、并支持其下方多个实现的包」写的这个特性 —— 那是一个,
以别人的依赖身份被走到。与本 pass 共用同一段窗口的 build.mcpp tail 只补根包(对它
自己的用途是对的),照抄那个形状会让这个特性唯一存在的对象没被服务,而所有只看根包的
断言照样全绿。

4. 「层谓词不能选依赖」的诊断实现了,却从没跑过

补上判据,并带一条同样重要的对照:同一个谓词下的 build 输入必须仍然生效,否则一条
警告就是把一次静默丢弃换成了另一次。

另:一条只有实现出来才会暴露的

谓词是一次比较,而比较有两侧;三稿设计文档争论的都是「该不该有这个特性」「窗口在哪」
「怎么不双算」,没有一稿问过右侧的取值是什么。它在普通 Linux 宿主上是 gnu,而
docs/14 的表一直写着 glibc

⚠️ musl 把它掩盖了 —— env 段与库名在 musl 上恰好同名,所以文档里那个例子是能工作的,
只有 glibc 这一侧断。用「两侧同名」的取值去测,会得到全绿而什么都没验证。

本机读数

  • 96 个单测二进制全过。
  • e2e:2 个失败(168208),两个都在已发布的 2026.8.30.2 上以相同消息失败 ——
    本机 musl 载荷的环境缺口,对照已跑,非回归。

`make@4.4` 与 `cmake@3.28` —— 索引里是 make 4.3 与 cmake 4.4.2/4.0.2,两个都从没
解析过。这一点此前不可见,因为 #531 的供给不读自己的结果:xlings 报出的每一种失败都
被当成成功。于是**这条最直接覆盖 `[xlings] deps` 的测试,是建立在一次从未成功的安装
之上做断言的** —— 正是 #531 想要终结的那个状态。

结果被读之后,fixture 的错误第一次可见:构建停下来了。这不是回归,是这条修复第一个
抓到的真实例子,而它抓到的是仓库自己的测试。

改用 `ninja@1.12.1`:mcpp 能构建的地方它一定已装,所以供给短路,这条测试仍然不花
任何下载。
e2e 88 的 fixture 声明 `make@4.4` 与 `cmake@3.28`,而索引里是 make 4.3、
cmake 4.4.2/4.0.2 —— 两个版本从来不存在。这条测试在 #531 的整个生命周期里都是绿的,
因为供给不读自己的结果。

⭐ 一般形状:**fixture 里的取值在有东西开始检查它们的那一刻,就不再是随意的了。**
在「这段文字有没有到达那个文件」是唯一断言的时候,它们只是自由字符串。
§4 原本给了一个退出码清单并声称穷举,而那个清单少了三处 `return 4`(pack/pipeline、
cmd_toolchain、pm/commands —— 都是同一个 `load_or_init` 失败,所以 `4` 的含义没变,
共 11 处而非 8 处),也没提 `20` 与 `1024`。

改成:贴出产生读数的命令,然后**按出处**逐个归类。⚠️ `4` 同时落在两栏 —— 它在
`index_management.cppm` 里是退出码,在 `runtime/elf.cppm` 里是 `R_RISCV_COPY`。
一份声称穷举的规范,自己就得能被复核。
⚠️ **`cfg(all(unix, …))` 在 Windows 上正确地为假**,于是 fixture 的 #error 触发,
Windows e2e 1/2 变红。这条腿要证明的是「三元组键与层键**组合**」,那么它的三元组
那一半就必须在测试会跑的每个平台上为真。改成 `any(unix, windows)`。
「恰好一次」那条腿同理:三元组腿为假的地方,只有一条路能匹配,这个守卫就不再守任何
东西 —— 它存在的理由正是第一趟会经三元组腿匹配而第二趟经层腿匹配。

⚠️ **注释里的反引号落在**未加引号**的 heredoc 里,变成了命令替换。** fixture 要
插值 $CABI 所以 heredoc 不能加引号;套件因此打印
`syntax error: unexpected end of file` 而**测试照样通过**。注释移到 heredoc 之外。

顺带:`-DPROBE_ONCE=1` 的计数改为同时接受 `/D`(Windows 自举可能驱动 MSVC)。

本机全量:355 passed / 3 failed —— 三条(62、168、208)在已发布的 2026.8.30.2 上以
相同消息失败,是本机 musl/glibc 载荷的缺口,对照已跑。
@Sunrisepeak
Sunrisepeak merged commit 3da6b0c into main Aug 31, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Audit of 2026.8.30.2: seven places where help text, warnings, or docs disagree with the code

2 participants