diff --git a/.agents/docs/2026-08-16-windows-toolchain-three-axes-design.md b/.agents/docs/2026-08-16-windows-toolchain-three-axes-design.md index bbccc394..09fcf704 100644 --- a/.agents/docs/2026-08-16-windows-toolchain-three-axes-design.md +++ b/.agents/docs/2026-08-16-windows-toolchain-three-axes-design.md @@ -300,6 +300,10 @@ LD_TRACE_LOADED_OBJECTS=1 '' ## 6.5 落地状态(2026-08-17,mcpp 2026.8.17.1) +> 落地过程中撞到的、不在计划里的三件事(clang 在两个目标上同时出问题、一处文档 +> 自相矛盾、`--mode static` 覆盖用户 target),连同验证结果与遗留账,记在 +> `2026-08-17-windows-three-axes-final-report.md`。 + **§1 / §2 / §3 / §4 全部实现,§2.4 明确不做。** 逐条对应: | 条目 | 状态 | 落点 | diff --git a/.agents/docs/2026-08-17-windows-three-axes-final-report.md b/.agents/docs/2026-08-17-windows-three-axes-final-report.md new file mode 100644 index 00000000..acac27f6 --- /dev/null +++ b/.agents/docs/2026-08-17-windows-three-axes-final-report.md @@ -0,0 +1,288 @@ +# Windows 三条轴:落地报告(mcpp 2026.8.17.1) + +> 设计:`2026-08-16-windows-toolchain-three-axes-design.md`(§6.5 记录逐条状态) +> 实现:mcpp #448(单 PR,含设计文档本身) +> 前一轮:`2026-08-16-msvc-ecosystem-final-report.md` + +--- + +## 0. 一句话 + +上一轮给**编译器**装上了版本轴;这一轮把它编译时用的**头文件**和产物加载的 +**运行时**也各自变成了**声明出来、解析一次**的值 —— 并且顺手让 `mcpp pack` +不再需要**运行产物**才能知道产物依赖什么。 + +--- + +## 1. 三条轴,分别落在哪 + +| 轴 | 之前 | 现在 | +|---|---|---| +| **来源** 编译器哪来的 | ✅ 已建模,代价摊在 ~30 处分支 | 解析一次(`Origin`),三处重复各消掉一份 | +| **SDK** 用哪套头/导入库 | ❌ **靠搜**,两种来源同一条链 | **按来源绑定**;受管 toolset 忽略环境并说出来 | +| **运行时分发** 产物带不带 vcruntime | ❌ 对 MSVC **一律拒绝** | PE 上 `toolchain-coupled` 真正成立 | +| **打包**(轴三的下游) | ❌ Windows 硬拒绝;跨不了 OS/架构 | 静态读导入表,任何宿主都能给 PE 打 zip | + +### §2 SDK:绑定而不是搜索 + +`find_windows_sdk()` 过去按 `WindowsSdkDir` → 兄弟 store → 常规路径扫,**两种来源 +走同一条链**。于是被 pin 住的 `msvc@` 是一个**环境可以悄悄改写**的 pin, +两台机器可以用两套 SDK 编同一份 manifest,而日志里没有一行提到 SDK。这不是假设: +这一轮之前追的那个 LNK1104,就是一个只解包了一半的 payload 因为版本号更高赢了这场 +扫描。 + +``` +msvc@ → 随该 toolset 装进 store 的 windows-sdk payload。 + WindowsSdkDir / WindowsSdkVersion 被忽略 —— 并且打印 note。 +msvc@system → 维持今天的搜索链。机器上的东西只能靠找,而在那里 + "明确声明"应当压过"扫描"。 +``` + +旁边没有 SDK payload 的受管 toolset **仍然**退回机器的 SDK —— 能用 > 失败 —— +并且**说出来**,因为那次构建已经不可复现,而除此之外没有东西会记录它。 + +### §2.3 `ucrt@`:填一个从字段诞生起就留着的槽 + +`RuntimeBinding::runtimeId` 的注释从一开始就写着 +*"(`glibc@…`, `macos_sdk@…`, `ucrt@…`)"*,而仓库里从来没有一处写过 `ucrt@`。 +于是 SDK 版本进不了 `runtimeContractHash`,**两套 SDK 共用一个构建缓存键**。 + +**它和 `glibc@` 不同构,而且这一点写在会被读到的地方**: + +| | `glibc@2.39` | `ucrt@10.0.26100.0` | +|---|---|---| +| 绑的是 | 一个 **payload**,头和 `.so` 都在里面 | — | +| 能真绑上去吗 | ✅ patchelf 让产物真跑在那份上 | ❌ `ucrtbase.dll` 是 OS 组件,换不掉也不该发 | +| 于是标识的含义 | **运行时绑定** | **兼容性下限声明** | + +所以它**不**投影进 `libc` —— 否则私有 libc 那套机制会去找一个本就不该存在的 +payload。各处 `starts_with("glibc@")` 改成 `runtime_provider()` 分派:另一个 +provider 读起来是"这里没有规则",而不是"没有身份"。 + +**§2.4(manifest 的 SDK 版本键)明确不做** —— 它会和 `_WIN32_WINNT` 并排,看起来 +可以互相替代,实际管的是不同的事。 + +### §3.3 PE 上的 `toolchain-coupled` 现在有意义 + +原来的拒绝语是 MSVC 运行时"随 OS/redistributable 分发,而不是随工具链"。 +对 `ucrtbase.dll` 是对的;对 `vcruntime140.dll` / `msvcp140.dll` 是**错的** —— +它们躺在每个 toolset 的 `VC\Redist\MSVC\…` 里,正是 gcc 和 `libstdc++.so` 的关系。 + +于是它拿同一个契约。PE 没有 rpath,所以**机制**是拷到产物旁而不是加一条搜索路径 —— +同一个契约,不同的机制,这正是三层模型存在的理由。 + +`/MT` 仍然是降级,而且是**真正的矛盾**:静态 CRT 根本没有 DLL 可以耦合,消息会说清 +是哪一边赢了。DLL 集合来自 `vc_redist_dir()` —— 排除 `debug_nonredist\`(不可再分发) +的**唯一**判据。在这里再写一条按名字的规则,可能和它不一致,而在**这件事**上不一致 +是许可问题,不是 bug。 + +### §4 打包:读导入表,别运行产物 + +`mcpp pack` 用 `#if defined(_WIN32)` 拒绝 Windows,理由写的是工具是 POSIX-only。 +那是症状。闭包来自 + +``` +LD_TRACE_LOADED_OBJECTS=1 '' +``` + +—— 它**要把产物跑起来**,所以既跨不了 OS,也跨不了**架构**。移植 `tar` 没有用,而 +2026-05 那份设计提的每一个工具(dumpbin、`ImageNtHeader`、`Compress-Archive`)都会 +把障碍**下移一层**,因为它们都只存在于问题已经消失的那个平台上。 + +- `mcpp.pack.binfmt` —— ELF `DT_NEEDED`(经段表翻译);PE 导入表**和延迟导入表** + (少一个延迟导入不会在启动时失败,而是在第一次调用它时失败,更难查) +- `mcpp.pack.zip` —— mcpp 自己写压缩包,理由相同:没有哪个 zip 工具在每个宿主上都 + 存在。条目 **stored** 不压缩,这是真实代价和诚实的取舍:DEFLATE 编码器是这里唯一 + 可能产出**解出来是错的**而不是响亮失败的部分,而 mcpp 没有 zlib 可借。**确定性**: + 不读任何时间戳,所以公布校验和才有意义 +- **§4.3 契约终于到达打包这一步**:以前它止步于编译/链接旗标,决定"哪些文件真的跟着 + 走"的那一步看不见承诺过什么 —— ELF 上 `ldd` 闭包**碰巧**一致,PE 上没有任何东西一致 + +**明确没做的一半,连同代价**:ELF 闭包**仍然**运行产物。`ldd` 交回的是**已解析的 +路径**,`DT_NEEDED` 只有名字,把名字变成路径要重新实现 loader 的搜索顺序 +(`$ORIGIN`、`DT_RPATH` → `LD_LIBRARY_PATH` → `DT_RUNPATH` → `ld.so.cache`、hwcaps)。 +在一条**已经正确、有 e2e 覆盖**的路径上重写它,风险大于收益 —— 于是**跨架构的 ELF +打包仍然不支持**,这正是 §4.1 指出的第二个限制。 + +### §1 收拢来源轴,而不是推广它 + +- **`gcc@system` / `llvm@system` 在被读到的地方就拒绝**,并同时给出两种可能的本意。 + 它们过去能解析,然后在别处以 `xim:gcc@system` → "no such package" 失败,把读者 + 引向一个根本不会存在的版本。`msvc@system` 是对**一个平台**的让步,不是别的族缺失 + 的能力;不带族的 `system` 逃生口原样保留 +- **spec 过去在相隔十几行的地方被解析了两遍**,各自下结论。现在一次,`origin_of()` 分派 +- `resolve_managed_msvc()` 取代两份手写的"受管 toolset 在哪、为什么不能用 fetcher 的 + `root`" —— 而理由只写在其中一份里 +- `needs_linux_sysroot_payloads()` 取代同一条规则的两种拼写,其中一份的注释声称它们 + 互为镜像。**它们不是** —— 少了 PE 那一项。今天不可达,这正是它活下来的原因 +- 工具链解析顺序**被写了两遍**,一处说 3 步一处说 4 步,合起来点到 9 个真实输入里的 + 5 个,还互相矛盾。现在一张表,按 `TcOrigin` 的枚举名写,不会悄悄失配 + +--- + +## 2. 验证 + +### 2.1 单元测试:31 个新增,两个**故意不能被"CI 绿了"满足** + +- `WindowsSdkDirCannotOverrideAPinnedToolsetsSdk` —— 设计 §6 的验收判据写成单元测试。 + 把 `WindowsSdkDir` 指到别处,payload 的 SDK 必须仍然赢,**并且** note 必须说出变量 + 被忽略了。一个被**静默**忽略的覆盖,和一个从未设置过的覆盖无法区分 +- `NothingElseAsksForStagedRuntimeFiles` —— 那个 deploy 标志会到达一个拷贝步骤,所以 + 一个多余的 `true` 会在一个根本没有这种东西的平台上往产物目录里放 DLL。扫过 + (format × stdlib × contract × /MT × explicit) 的每一格 + +### 2.2 e2e 240:**在 Linux 上**给 PE 打包 + +放在 mingw-cross job 里,因为**在 Windows 上跑它什么也证明不了**。它构建一个真正的 +交叉 PE,放一个该 EXE 会导入的 DLL 的替身,打包,然后由 **Python** 独立验证压缩包: + +- `msvcrt.dll` 在 —— **正面**那一半。一个什么都没读到的解析器**产不出**它 +- `kernel32.dll` 不在 —— **反面**那一半。单独看,一个什么都没读到的解析器也能通过; + 两条合起来才是决定性的 +- `--mode system` 不放任何 DLL;`toolchain-coupled` + `--mode system` 被拒绝,消息里 + 同时有契约名和出路 + +### 2.2b e2e 241:`ucrt@` 身份**真的到达了一次真实构建** + +单元测试钉住的是哈希函数(两个 SDK 版本 → 两个哈希)。它看不见的是:这个值在 +**真实的 Windows 构建**上到底有没有被填进去 —— 而 Windows CI 绿了并不能区分 +"身份填好了"和"代码路径跑了但什么都没产出":一个空字符串会毫无怨言地流过其中 +每一个 job。 + +所以 241 读 `resolution.json`,断言**值**本身:`runtime_id` 以 `ucrt@` 开头、 +`contract_hash` 非空(一个不参与任何事的身份就是装饰)、并且它**没有**被投影进 +`libc`(那个字段指的是私有 libc **payload**,ucrt 没有对应物)。 + +它显式 pin 了 `msvc@system`:Windows 的默认工具链是 clang targeting MSVC ABI, +那条路径上 SDK 是 clang 自己找的、mcpp 确实无可声明 —— 用默认工具链写这条测试, +会断言一个空身份然后**因为错误的理由通过**。 + +### 2.3 本机真实验证 + +在这台 Linux 上,`mcpp pack --target x86_64-windows-gnu` 产出的 zip 被 +`python3 -m zipfile` 和 `unzip -t` 双双接受,内含 exe 和解析出来的 DLL,没有别的。 +`--format dir`、`--mode self-contained`、`--mode static --target …` 逐一验过。 + +### 2.4 CI + +mcpp #448:**19 项全绿**(Linux / macOS ARM64 / Windows × build+unit+e2e+toolchains、 +hermetic 容器、四条 cross-build、xlings 集成)。 + +### 2.5 生态真实验证:**发布出去的那个二进制**,在沙盒里 + +不是本地构建产物,而是 `xlings install mcpp@2026.8.17.1` 从索引装下来的那一个; +不是这台开发机,而是 `xlings subos new` 出来的**全新 SubOS**,并且每一条都跑在 +`xlings subos use --sandbox --cmd "…"` 里面。 + +| 验证 | 结果 | +|---|---| +| 从索引安装 | ✓ `xim:mcpp@2026.8.17.1` | +| `new` → `build` → `run` | ✓ `Hello from ecoproj!`(import std + C++23) | +| `mcpp test` | ✓ 1 passed | +| `mcpp pack`(ELF,未改动的路径) | ✓ `ecoproj-0.1.0-x86_64-linux-gnu.tar.gz` | +| **`mcpp pack --target x86_64-windows-gnu`** | ✓ 在这个 **Linux 沙盒**里产出 `ecowin-0.1.0-x86_64-w64-mingw32.zip`,`zipfile.testzip()` 通过 | +| `mcpp self doctor` | ✓ **all checks passed** | +| `gcc@system` | ✓ 被拒绝,消息里同时给出 pin 与 PATH 逃生口两种写法 | +| 裸 `system` 逃生口 | ✓ 仍解析到宿主 gcc 13.3.0,然后因为**不相干且正确**的理由失败(那个 gcc 没有 `import std`)—— 这正是"没有被破坏"的样子 | + +**发布链路**同样逐段核过:四平台产物齐全 → 镜像 `all assets mirrored + verified on +2 host(s) in 488s` → 用**真实 GET**(不是 `curl -I`,它在 gitcode 上会骗人)复核四个 +归档与上游**逐字节同尺寸** → 索引 PR openxlings/xim-pkgindex#643 合入 → +`xlings update` 后可安装。 + +> 上一轮预留的「本地 `gtc` 补 gitcode 资源」这次**没有用到**:镜像那一条腿自己过了。 +> 授权仍在,只是没有需要修的东西。 + +--- + +## 3. 路上撞到的四件事(都不在计划里) + +### 3.1 clang 在**两个目标上同时**出问题 + +同一份新代码,gcc 到处都绿,而 clang: + +| | 现象 | +|---|---| +| Windows | clang 20.1.7(MSVC ABI)**编译** mcpp.pack 时段错误,0xC0000005,无诊断,五个 job 同时红 | +| macOS ARM64 | `test_pack_binfmt` 在**运行**时 SIGSEGV | + +**解析器不是问题,而且这是量出来的**:同一份代码在 clang 22.1.8 + libc++ 下 +ASan+UBSan 干净,在 x86_64 Linux 上**作为 clang 模块**编译并运行正确(-O0/-O2)。 + +处理方式沿用本仓库已有的判例(`hostflags.cppm` 的开头注释:往一个模块的匿名命名空间里 +加一个**没被使用**的函数,会让**相邻**函数被误编译;结论是"机制未知,复现稳定,便宜的 +反应是别往那个命名空间里加东西")。于是移除形状、保留行为:跨模块的作用域枚举做导出 +结构体的默认成员、ranges 投影取导入类型的成员、跨模块边界的 `std::span`、模块 purview +里的函数模板 —— 全部换成更简单的等价写法。**哪一个是原因并未确定,注释里就是这么写的**, +而不是编造一个结论。 + +macOS 那一半后来被**拆分测试**定位到了:崩在 `ThePeFixtureItselfIsWellFormed` —— +**完全不碰任何模块**的测试夹具代码。把夹具改写成最朴素的形式(不用 span 参数、不用捕获 +可变字符串的 lambda)之后消失。夹具现在带一个 `MCPP_TEST_TRACE=1` 才开的分步 trace: +如果它再来,一轮 CI 就能定位,而不是这次的四轮。 + +### 3.2 自审出来的一个崩溃(在已合入、已全绿之后) + +合入之后重读 `binfmt.cppm`,发现 `identify()` —— 注释写着"never throws" —— +在一种输入上会**终止进程**: + +```cpp +if (b.substr(*lfanew, 4) == "PE\0\0") // *lfanew 直接读自文件 +``` + +`std::string_view::substr` 在 `pos > size()` 时抛 `std::out_of_range`。 + +**触发面要说准,不能夸大**:`identify()` 拿到的不是任意文件,而是**链接产物**, +所以现实中的触发路径是一次**被截断/写坏的链接输出**(磁盘满、链接被 kill), +而不是"用户随便丢了个文件进来"。这一条的分量不在于它多常见,而在于:这个模块 +写明了自己"对垃圾输入是全函数",而它不是 —— 一个自称从不抛异常的函数,在一种 +输入上终止了进程。 + +不是推理出来的,是量出来的:`std::string_view{256 字节}.substr(0xFFFFFFFF, 4)` +在 libc++ 下抛 `string_view::substr`。 + +模块里其它每一处读取都走了带边界检查的访问器,只有这两处比较是例外 —— 一个自称 +"对垃圾输入是全函数"的模块,就是这样不再是的。已改为 `has_at()`,并补了回归测试。 +原来的截断测试为什么没抓到:它构造的 `e_lfanew` 只落在**正好等于**文件末尾的位置, +那里 `substr` 是良定义的、返回空。 + +### 3.3 一处文档自相矛盾 + +`docs/03-toolchains.md` 的 MinGW 段说 `[build] linkage` 这个键不存在、会被静默忽略; +两百行之后的 MSVC 段**恰好**把它当成选 `/MT` 的写法展示。是照着文档写了一遍、看着 mcpp +打印 `unsupported key 'linkage' (ignored)` 才发现的 —— 而跟着这一页做的用户,得到的也是 +这个,只是没人告诉他为什么什么都没变。两个语种都已修正。 + +### 3.4 `--mode static` 会覆盖用户点名的 target + +强制 musl 的那次重新 prepare 忽略了 `opts.targetTriple`,于是 +`--mode static --target x86_64-windows-gnu` 悄悄变成一次 **Linux** 构建。在 PE 打包 +还不存在时这是看不见的 —— 根本没有 Windows 包可以让人发现它缺了 —— 而现在它是个错误答案。 + +--- + +## 4. 留下的账(都写清楚了,没有藏) + +| # | 事项 | 为什么这次不做 | +|---|---|---| +| 1 | **macOS 上的 `mcpp pack` 会执行用户的产物** | `ldd_parse` 在 macOS 上等于直接跑二进制(`LD_TRACE_LOADED_OBJECTS` 在那里不是环境变量)。两个 pack e2e 都 `requires: pack patchelf elf`,即 **macOS 上完全没有覆盖**,改一条没有测试的路径无法验证。`binfmt` 已经识别 Mach-O,补 `LC_LOAD_DYLIB` 读取是自然的下一步 | +| 2 | **跨架构 ELF 打包**仍不支持 | 见 §4「明确没做的一半」 | +| 3 | `[pack] include` / `exclude` 被解析、存进 Plan,**从未被消费** | 早于本轮;文档把它们当作已有功能。属于 pack 的另一条轴,不在三条轴的范围内 | +| 4 | Windows CI 仍用 `7z a -tzip` 手工打包 release | 那是一个自带 `registry/` 的定制布局,不是 pack 的输出。现在 `mcpp pack` 能产 zip 了,迁移是可能的,但那是发布流程的改动 | +| 5 | **`mcpp new` + `mcpp add compat.gtest@1.15.2` 开箱即坏** —— 脚手架的 `test_smoke.cpp` 自带 `main()`,而 gtest 依赖会把 `gtest_main.o` 链进每个测试目标,`multiple definition of 'main'`。生态验证时撞到的 | **先于本轮**,已用 2026.8.16.3 在沙盒里复现过同样的失败,所以不是本次回归。它属于依赖/测试目标的链接策略,和这三条轴无关;放在这里是因为它是一条**开箱即坏**的路径,值得单独一轮 | +| 6 | **release 的 `SHA256SUMS` 只覆盖 4 个平台里的 1 个** —— 里面只有 `linux-x86_64` 的两行(带版本名 + 无版本别名),aarch64 / macOS / Windows 都不在。核对发布产物时撞到的 | **先于本轮**:v2026.8.16.3 与 v2026.8.15.1 同样如此。机制很清楚 —— `build-release`(linux x86_64)最先跑并写下这个文件,另外三个平台各自上传自己的 `.sha256` 边车,但没有人往 `SHA256SUMS` 里追加。**能校验**(边车在),只是那个名字承诺了它没做到的事。属于发布流水线,不属于这三条轴 | +| 7 | Windows 宿主 → Linux target 时 `dist::Format` 仍解析为 PE | 早于本轮。本轮的 triple 判据**只新增答案**(说不出 OS 的 triple 走原推导),刻意没有动这一条:它会改变一个正在通过的 CI job 的旗标,而对这三条轴没有好处 | + +--- + +## 5. 迁移(用户视角) + +| 改动 | 用户可见格式 | 迁移 | +|---|---|---| +| `ucrt@` 进 `runtimeContractHash` | ⚠️ 缓存键变 | **Windows 构建缓存重建一次**,和任何 contract 变更同类 | +| 受管 toolset 绑定 SDK | 行为,非格式 | pinned toolset 上 `WindowsSdkDir` 从"生效"变成"忽略 + 报告" | +| 拒绝 `gcc@system` | ⚠️ 之前"未实现",现在显式错误 | 消息给出两种替代写法 | +| PE `toolchain-coupled` / PE pack | 新增能力 | 之前是 degraded / 硬错误 | +| `--mode static --target ` | ⚠️ 之前静默变成 Linux 构建 | 现在按 target 走 | + +其余全部是内部改动。 diff --git a/.xlings.json b/.xlings.json index 8e9bb512..c72e01d3 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": "2026.8.15.1" + "mcpp": "2026.8.17.1" } } diff --git a/src/cli.cppm b/src/cli.cppm index 4441327b..14e4397a 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -63,7 +63,7 @@ void print_usage() { std::println(" mcpp update [pkg] Re-resolve deps and rewrite mcpp.lock"); std::println(" mcpp search Search packages in registries"); std::println(" mcpp publish [--dry-run] Publish package to default registry"); - std::println(" mcpp pack [--mode ] Build + bundle a tarball (m: system|vendored|self-contained|static)"); + std::println(" mcpp pack [--mode ] Build + bundle an archive (m: system|vendored|self-contained|static)"); std::println(" mcpp emit xpkg [-V VER] [-o FILE] Generate xpkg Lua entry"); std::println(" mcpp xpkg parse [--json] Validate an xpkg descriptor (resolver grammar)"); std::println(""); @@ -399,13 +399,19 @@ int run(int argc, char** argv) { .option(cl::Option("allow-dirty").help("Allow uncommitted changes")) .action(wrap_rc(cmd_publish))) .subcommand(cl::App("pack") - .description("Build + bundle into a self-contained tarball") + // "archive", not "tarball": a Windows target produces a .zip, and + // the help said tarball while the code had already stopped + // agreeing. `--format tar` likewise selects "an archive rather + // than a plain directory" — WHICH archive follows the artifact, + // because a .tar.gz full of DLLs is a package most Windows users + // cannot open without installing something first. + .description("Build + bundle into a self-contained archive") .option(cl::Option("mode").takes_value() .help("system | vendored (default) | self-contained | static")) .option(cl::Option("target").takes_value() .help("Triple, e.g. x86_64-linux-musl")) .option(cl::Option("format").takes_value() - .help("tar (default) | dir")) + .help("tar (default; .zip for a Windows target) | dir")) .option(cl::Option("output").short_name('o').takes_value() .help("Override output path")) .action(wrap_rc(cmd_pack))) diff --git a/src/pack/binfmt.cppm b/src/pack/binfmt.cppm index 3bd03e3c..945961f4 100644 --- a/src/pack/binfmt.cppm +++ b/src/pack/binfmt.cppm @@ -160,6 +160,19 @@ std::optional le64(std::string_view b, std::size_t off) { return v; } +// Do the bytes at `off` equal `lit`? +// +// NOT `b.substr(off, n) == lit`, and the difference is a crash. +// `std::string_view::substr` THROWS `std::out_of_range` when `pos > size()`, +// and `off` here comes from a field READ OUT OF THE FILE — a file starting +// with "MZ" whose `e_lfanew` is garbage is ordinary malformed input, not a +// reason to terminate. This module's contract is that it is total over +// nonsense; one unchecked `substr` was enough to break that promise. +bool has_at(std::string_view b, std::size_t off, std::string_view lit) { + if (off > b.size() || b.size() - off < lit.size()) return false; + return b.compare(off, lit.size(), lit) == 0; +} + // NUL-terminated string at `off`, bounded by the file end. std::optional cstr(std::string_view b, std::size_t off) { if (off >= b.size()) return std::nullopt; @@ -309,7 +322,7 @@ pe_needed(std::string_view b) { auto lfanew = le32(b, 0x3C); if (!lfanew) return std::unexpected("PE: no e_lfanew"); const std::size_t nt = *lfanew; - if (b.substr(nt, 4) != std::string_view("PE\0\0", 4)) + if (!has_at(b, nt, std::string_view("PE\0\0", 4))) return std::unexpected("PE: no PE\\0\\0 signature at e_lfanew"); auto numSections = le16(b, nt + 6); @@ -445,7 +458,7 @@ Ident identify(const std::filesystem::path& binary) { // Saying "PE" for a file that has none would send the caller into a // parser that cannot succeed. if (auto lfanew = detail::le32(b, 0x3C)) { - if (b.substr(*lfanew, 4) == std::string_view("PE\0\0", 4)) { + if (detail::has_at(b, *lfanew, std::string_view("PE\0\0", 4))) { id.format = Format::Pe; if (auto m = detail::le16(b, *lfanew + 4)) id.arch = detail::pe_arch(*m); diff --git a/tests/e2e/241_windows_ucrt_runtime_identity.sh b/tests/e2e/241_windows_ucrt_runtime_identity.sh new file mode 100755 index 00000000..835261ea --- /dev/null +++ b/tests/e2e/241_windows_ucrt_runtime_identity.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# requires: msvc python3 +# 241_windows_ucrt_runtime_identity.sh — the Windows SDK has an identity, and +# it reaches the build's runtime contract. +# +# `RuntimeBinding::runtimeId`'s own comment has documented `ucrt@…` since the +# field existed, and nothing ever wrote one. The cost was not cosmetic: the +# SDK version never reached `runtimeContractHash`, which keys the build cache, +# so TWO SDKs shared ONE cache key — the version axis simply stopped existing +# one layer below the compiler. +# +# The unit tests pin the hash function (two versions → two hashes). What they +# cannot see is whether the value ever gets there on a real Windows build, and +# a green Windows CI does not distinguish "the identity is filled in" from +# "the code path ran and produced nothing" — an empty string flows through +# every one of those jobs without a complaint. So this asserts the VALUE. +# +# WHY IT PINS msvc@system EXPLICITLY. The identity is produced where mcpp +# RESOLVES the SDK itself, which is the native cl.exe path. Windows' default +# toolchain is clang targeting the MSVC ABI, and there clang finds its own SDK +# — mcpp does not know which one, so there is honestly nothing to declare. A +# test that took the default would therefore assert an empty identity and pass +# for the wrong reason. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +"$MCPP" new ucrtid > /dev/null +cd ucrtid +cat >> mcpp.toml <<'EOF' + +[toolchain] +windows = "msvc@system" +EOF + +"$MCPP" build > build.log 2>&1 || { cat build.log; exit 1; } + +RES="$(find target -name resolution.json | head -1)" +[[ -n "$RES" ]] || { echo "FAIL: no resolution.json"; exit 1; } + +python3 - "$RES" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +b = d.get("runtime", {}).get("binding", {}) +rid = b.get("runtime_id", "") + +assert rid.startswith("ucrt@"), ( + "the Windows runtime identity is not filled in: runtime_id=" + f"{rid!r}. The SDK version never reaches runtimeContractHash, so two " + "SDKs share one build-cache key.") + +version = rid[len("ucrt@"):] +assert version and version[0].isdigit(), f"implausible SDK version in {rid!r}" + +# The identity is only worth anything if it PARTICIPATES. An empty contract +# hash would mean the value was recorded and then not used for anything. +assert b.get("contract_hash"), "runtime binding has no contract hash" + +# ...and it must NOT have been projected into the private-libc field. That +# field is read by the loader/patchelf machinery, and ucrt has no payload for +# it to name — `ucrtbase.dll` is a Windows component. See +# mcpp.platform.runtime_binding on why the two providers are not isomorphic. +assert not b.get("libc"), ( + f"ucrt was projected into `libc` ({b.get('libc')!r}); that field names a " + "private libc PAYLOAD, and there is no such thing for ucrt") + +print(f"OK: runtime identity {rid}, contract {b['contract_hash']}") +PY + +echo "OK" diff --git a/tests/unit/test_pack_binfmt.cpp b/tests/unit/test_pack_binfmt.cpp index da365372..6f84f3bb 100644 --- a/tests/unit/test_pack_binfmt.cpp +++ b/tests/unit/test_pack_binfmt.cpp @@ -324,6 +324,27 @@ TEST(PackBinfmt, ADosStubWithoutAPeSignatureIsNotAPe) { EXPECT_FALSE(bf::needed_names(f.path).has_value()); } +TEST(PackBinfmt, AGarbageELfanewIsRejectedAndDoesNotThrow) { + // An "MZ" file whose `e_lfanew` points past the end is ordinary malformed + // input: a truncated download, a DOS stub, a text file named `.exe`. + // + // This crashed. `std::string_view::substr` THROWS `std::out_of_range` when + // `pos > size()`, and the offset comes straight out of the file — so + // `identify()`, which is documented as never throwing, terminated the + // process instead of answering Unknown. Bounds-checked comparison now. + for (std::uint32_t lfanew : {0xFFFFFFFFu, 0x7FFFFFFFu, 0x10000u, 0x101u}) { + std::string b(0x100, '\0'); + b[0] = 'M'; b[1] = 'Z'; + put(b, 0x3C, lfanew, 4); + TempFile f{"mzjunk", b}; + EXPECT_NO_THROW({ + EXPECT_EQ(bf::identify(f.path).format, bf::Format::Unknown) + << "e_lfanew=" << lfanew; + EXPECT_FALSE(bf::needed_names(f.path).has_value()); + }) << "e_lfanew=" << lfanew; + } +} + TEST(PackBinfmt, TruncatedInputIsRejectedRatherThanRead) { // Malformed input is ordinary: a half-downloaded file, a text file named // `.exe`. Every read is bounds-checked, so the parser is total over it.