From 0e989527b0ab2d9c61439dc68532e4bd425e2083 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:17:55 +0800 Subject: [PATCH] docs: the MSVC ecosystem round, and the tool that made it decidable Keeps two things that are worth more than the diff they came from. The report records eleven defect layers, each reachable only once the one before it was fixed, and every one of them green in CI at the time. They share a single shape -- an acceptance criterion weaker than "usable" -- and the endpoint is the one criterion that could not be satisfied by adjusting a test: PASS: msvc@14.44.35207 installs, builds, stays distinct from msvc@system, and removes It also records what I got wrong: a premise about renaming directories that CI disproved, a diagnostic that probed by deleting, an invented `gcc@system` proposal that ran against the design it claimed to improve, and three separate occasions where the honest conclusion was "this test cannot fail on the platform that would run it, so do not write it". `tools-msimap.py` resolves an MSI's File/Component/Directory tables to real install paths, offline. It is here because it was the only thing that could answer "what is actually inside this payload" -- the package names, Microsoft's own docs, and my own comments in this repo were each wrong about the Windows SDK, and only the tables were right. Anyone auditing a payload set again will need it. --- .../2026-08-16-msvc-ecosystem-final-report.md | 741 ++++++++++++++++++ .agents/docs/tools-msimap.py | 140 ++++ 2 files changed, 881 insertions(+) create mode 100644 .agents/docs/2026-08-16-msvc-ecosystem-final-report.md create mode 100644 .agents/docs/tools-msimap.py diff --git a/.agents/docs/2026-08-16-msvc-ecosystem-final-report.md b/.agents/docs/2026-08-16-msvc-ecosystem-final-report.md new file mode 100644 index 00000000..eeaba00e --- /dev/null +++ b/.agents/docs/2026-08-16-msvc-ecosystem-final-report.md @@ -0,0 +1,741 @@ +# MSVC × xlings 生态打通 —— 综合报告(2026-08-16) + +## 0. 一句话 + +MSVC 从「mcpp 工具链体系里唯一无法声明版本的家族」变成了与 gcc 同构的一等公民。 +**而这次真正的收获在验证环节**:在已经发布、CI 全绿的东西里查出了一连串缺陷 —— 其中「解包」这一处就有六层, +其中一条让包对默认构建完全不可用,一条是我自己写的测试**不可能失败**, +还有一条是我明知道该怎么避免却还是犯了(零先例的 sandbox API)。 + +它们的共同形状只有一个:**"没发生"和"成功了"输出一样。** + +**终点判据**(真机、非 skip): + +``` +PASS: msvc@14.44.35207 installs, builds, stays distinct from msvc@system, and removes + ── e2e 239,1 passed / 0 skipped + +xrgui: Resolved msvc@system → msvc 19.52.36629 (C:\VS2026Insider\...\14.52.36629\cl.exe) + [ PASSED ] 54 tests. ── 两条腿全绿,且 vswhere workaround 仍然是删掉的 +``` + +而每修好一个,下一个才够得着 —— 这条链一共走了十一层: + +``` +installed() 只查 cl.exe → 只查目录(缺 kernel32.lib 照样说装好了) + → 缺 kernel32.lib → 缺 um 头 / shared 头 / rc.exe / mt.exe + → mcpp 认 SDK 只看头文件 → 构建终于通了,remove 在 Windows 上失败 + → 报错不说是哪个文件 → vctip.exe 占着 payload,谁都卸不掉 + → 不再安装它 ≠ 修好已经装了的 → installed() 得能认出「旧 recipe 的产物」 + → 改名目录(前提就错了) → 空目录骨架也删不掉,判据得是「没有文件残留」 +``` + +**每一层都被上一层挡着看不见,而每一层的"绿"都是真的绿。** +最后一层尤其说明问题:它是在「构建成功」之后才够得着的 —— +在此之前没有任何一次运行走到过 remove 这一步。 + +--- + +## 1. 交付物 + +| 仓库 | PR | 状态 | +|---|---|---| +| mcpp | #434 MSVC 版本轴(主体) | ✅ 合入,**18/18 绿** | +| mcpp | — | ✅ **发布 `2026.8.16.1`**,四平台产物 + 索引 bump 完成 | +| mcpp | #436 装好的 toolset 不出现在 list 里 | ✅ 合入,18/18 绿 | +| mcpp | #435 记录被查出的缺陷(文档) | ✅ 合入,18/18 绿 | +| mcpp | #438 e2e 239 只能 pass/skip,不可能 fail | 已并入 #440(单 PR) | +| mcpp | #440 payload 根目录 + SDK 两半判据 + Windows remove 三层 + review §3b/§3c/§3 | ✅ **合入,19/19 绿,e2e 239 真机 PASS** | +| mcpp | #441 拒绝 `gcc@system` | ❌ **已关闭 —— 我自己造出来的问题** | +| mcpp | #442 host helper 链接策略对齐(**closes #437**)+ review §1 更正 | ✅ 合入,17/17 绿 | +| mcpp | #443 版本号 → `2026.8.16.3` | ✅ 合入 | +| mcpp | #444 自我 review 抓到的两条(清扫波及面 / yes-no 起编译器) | ✅ 合入,19/19 绿 | +| mcpp | — | ✅ **发布 `2026.8.16.3`**,gitcode 镜像 **21/21 字节一致** | +| xim-pkgindex | #641 `xim:mcpp` → 2026.8.16.3 | ✅ 合入,索引已发布 | +| xrgui | #6 `MCPP_VER` → 2026.8.16.3 | ✅ **合入,两条腿全绿,54/54** | +| mcpp | — | ✅ **发布 `2026.8.16.2`**(带 #436),索引已 bump | +| xim-pkgindex | #629 payload 多地址 + 27 个镜像 | ✅ 合入,15/15 绿 | +| xim-pkgindex | #630 补动态 CRT 导入库 | ✅ 合入,15/15 绿 | +| xim-pkgindex | #631 `xim:mcpp` → 2026.8.16.1 | ✅ 合入,15/15 绿 | +| xim-pkgindex | #632 GNU tar 盘符 / `os.curdir` / skill 规则 | ✅ 合入,15/15 绿 | +| xim-pkgindex | #633 zip 要 bsdtar / 引号 / 去掉 cd | ✅ 合入,15/15 绿,**真机装通** | +| xim-pkgindex | #634 `xim:mcpp` → 2026.8.16.2 | ✅ 合入,15/15 绿 | +| xim-pkgindex | #635 cl.exe 没有 clui.dll 跑不起来 | ✅ 合入 | +| xim-pkgindex | #636 SDK 缺核心导入库 + um/shared 头 + rc/mt(17 payload,+120 MB) | ✅ 合入,windows-test 真机装通 | +| xim-pkgindex | #637 msvc 不再安装 vctip.exe(否则卸不掉) | ✅ 合入,15/15 绿,索引已发布 | +| xim-pkgindex | #638 复用 `toolsdir()`,不再手写第二份布局 | ✅ 合入,15/15 绿 | +| xim-pkgindex | #639 已经装了旧布局的机器要被拉回来 | ✅ 合入,15/15 绿 | +| xim-pkgindex | #640 `installed()` 规则进 skill + lint + windows-test **真的运行**程序 | ✅ 合入,15/15 绿 | +| xlings | #549 D4 管道侧实测 | ✅ 合入 | +| xlings | #550 `info` 平台误报 + e2e flake | ✅ 合入,全绿 | +| xlings | #551 卸载扛得住被占用的文件(park-and-sweep 下沉) | ✅ 合入 | +| xrgui | #5 删 workaround | ✅ **合入,两条腿全绿,54/54** | +| xrgui | #3(原 Windows 支持 PR) | 已关闭 —— #5 是它的超集,并且**不带那个 workaround** | + +--- + +## 2. 架构:版本轴决定来源 + +改前:`is_system_toolchain()` 对**所有** msvc spec 为真 —— manifest 写了版本也被丢掉。 +后果不是不优雅,是**同一份源码在两台机器上被不同编译器编译且不报错** +(xrgui#3 实测:同一轮 CI 里 mcpp 用 14.51、xmake 用 14.52,直到 ICE 才暴露)。 + +| spec | 来源 | 用哪个编译器 | +|---|---|---| +| `msvc@system`(或裸 `msvc`) | 机器自己的 VS | 这台机器装的那个(**语义一字未改**) | +| `msvc@` | mcpp 安装的 xlings payload | 声明的那个,每台机器都是 | + +关键在于**两条正交的轴**:「获取」与 gcc 共用(xim 安装),「解析」与 +`msvc@system` 共用(`installation_from_tools_dir`)。受管 toolset 因此不是 +第二条代码路径,长不出自己的 bug。 + +已在**发布产物**上验证:Linux 上 `mcpp toolchain install msvc 14.44.35207` +走的是 xim 路径而不是 `msvc_wrong_host()` —— 分流生效。 + +### ⭐ 最强的一条证据:xrgui 删掉 workaround 之后仍然绿 + +这是整套里**唯一不能靠调测试自我满足**的判据。workaround 还在的时候, +「`VSINSTALLDIR` 被采纳」与「vswhere 找不到东西」现象完全一样。 + +xrgui#5 把「把 `vswhere.exe` 挪开」那一步**删掉了**,而 vswhere **还在**、 +它排第一的 Enterprise **14.51 也还装着** —— 什么都没被拿走,错误答案只是必须输: + +``` +Resolved msvc@system → msvc 19.52.36629 + (C:\VS2026Insider\VC\Tools\MSVC\14.52.36629\bin\Hostx64\x64\cl.exe) +``` + +`xrgui_tests` **54/54**,mcpp 与 xmake 两条腿全绿。原来的 PR #3 已关闭 —— +#5 是它的超集,而且**不再需要那个 workaround**。 + +合入 master 之后 `mcpp (Windows / MSVC)` 在 master 上**又跑了一遍,仍然绿** —— +不是同一次运行的复用,是合并后的树重新构建的结果。 + +### 顺带修掉的三条 + +- **`VSINSTALLDIR` 现在优先于 vswhere**。vswhere 几乎总能返回点什么,所以 + `VSINSTALLDIR` 事实上不可达 —— **猜测不该压过答案**。vswhere 加 `-prerelease`。 + `VS*COMNTOOLS` 仍排 vswhere **之后**(机器全局残留 ≠ 有人为这个 shell 设的)。 +- **SDK 搜索**:`WindowsSdkDir` → store 里的 `xim:windows-sdk` payload → 写死路径(降为回退)。 + 第二条零配置、零版本硬编码:**编译器自己的路径就说明了它来自哪个 store**。 +- **`cxx_runtime` 与 `linkage`**:在 MSVC ABI 上它们是同一个物理开关,现在都走 + `msvc_wants_static_crt()`。默认仍是 `/MD`(判据取 manifest 字面值,不是解析后的 + contract —— 否则每个 Windows 构建都会翻成 `/MT`)。 + +--- + +## 3. 验证:哪些能自证,哪些不能 + +**能被"调一下测试"满足的判据不算证据。** + +| 判据 | 自证? | 结果 | +|---|---|---| +| 声明的 toolset 优先于"最新" | ❌ | 单测:两个都在,要**老的**那个。「取最新」会失败,真机上可能碰巧对 | +| 两条来源互不污染 | ❌ | e2e 239:换回 `msvc@system` 必须解析到系统 cl | +| 受管 toolset 真的被用了 | ❌ | e2e 239 每条断言都写成「系统编译器应答就失败」 | +| 27 个 payload 镜像正确 | ❌ | **下载回来** sha256 与微软一致 —— 而这抓到了真问题(下面 §4) | +| SDK 里到底有什么 | ❌ | **解 MSI 的 File/Component/Directory 表**算出每个文件的安装路径 —— 名字和文档都在骗人(§4.8) | +| 删掉 vctip.exe 是否安全 | ❌ | 依据是 **cl.exe 自己的字符串** `Not launching VCTIP: Binary not found` —— 它本来就有这条分支 | +| 单测 83/83、静态 1798 项 | ✅ | 证明没回归,不证明做成了 | + +**msvc 发现逻辑第一次能在 Windows 之外测试**:`installation_at()` 收目录而不是 +探测机器,`find_windows_sdk()` 收 root 列表,两者都不再被平台宏包住 —— +6 个新单测在 Linux CI 上跑真 fixture。改动前这里一行都测不了。 + +--- + +## 4. 验证过程抓到的缺陷 —— 这一节是重点 + +### 4.1 上传工具说失败、列表说在、实际 404 + +镜像 27 个 payload 时,`gtc` 对 16 个文件报了失败,而 release 列表显示它们**在**。 +下载回来一验:**404,根本不在**。 + +原因是 gitcode 拒绝 `.cab` 扩展名、也拒绝名字里的空格,而失败发生在 `obs_callback` +之后 —— 资产**注册了**、对象**没落盘**。改用 `<下划线名>` / `.cab.bin` +重传,再逐个下载校验:**27/27 与微软一致**。 + +> 判据不是「上传成功」,是「字节回来了且摘要一致」。这两者差了 16 个文件。 + +### 4.2 已发布、CI 全绿的 toolset 链接不了默认构建 ⚠️ + +查「打包的 14.52 能不能构建 xrgui」时发现:**不能,而且对任何项目都不能。** + +payload 集里只有**静态** CRT。判据是头文件自己写的(`use_ansi.h`): + +```c +#if defined(_DLL) && !defined(_STATIC_CPPLIB) +#define _LIB_STEM "msvcprt" // /MD ← payload 里没有 +#else +#define _LIB_STEM "libcpmt" // /MT ← 有 +``` + +`msvcprt.lib` 在 `Microsoft.VC..CRT.x64.Store.base` 里 —— **名字是它被跳过的原因**, +听起来像 UWP,实际把桌面用的动态导入库放在 `lib/x64` 顶层。 + +**真正的缺陷不是少一个 payload,是没有任何东西会发现它。** `installed()` 只查 +`cl.exe` 和 `std.ixx`,两个都在 → 安装报成功 → 真 Windows runner 上 +`windows-test` 全绿 → 工具链在默认配置下不可用,失败会出现在用户的链接步骤里。 + +现在 `installed()` 对每种 CRT 模型各查一个导入库,并且失败时**说出缺哪个文件**。 +补丁后的 `windows-test` 在真机上仍然绿 —— 这次那个绿是有意义的。 + +> **一个比"能用"弱的"装好了"判据,报的不是安装成功,是解压成功。** + +### 4.3 装好的 toolset 在 `toolchain list` 里根本不出现 + +拿**已发布的** 2026.8.16.1 二进制对着一个 payload 形状的 fixture 跑,发现 +装好的 msvc toolset 一行都不显示。 + +原因:枚举问的是 `toolchain_frontend(root / "bin", pkg)` —— 而 cl.exe 在 +`VC/Tools/MSVC//bin/Host//`,深四层。拿不到 → `continue` → 消失。 + +**这个布局有三个地方需要知道:安装知道、构建知道、列表不知道。** +第三份内联副本就是这么来的。现在三者共用 `payload_frontend()`。 + +值得记的是它**怎么被发现的**:不是测试。我为此写的单测钉的是 +`identify_xim_payload("msvc")` —— 那一条本来就是对的。 +**「身份映射」和「枚举」是两个问题,而我只问了其中一个。** +(e2e 239 的 1b 步会抓到它,但那要等包发布后 Windows e2e 再跑一轮。) + +--- + +### 4.4 我自己写的 e2e 只能 pass 或 skip,不可能 fail ⚠️ + +包发布之后,Windows e2e 第一次真的跑到了 `239_msvc_managed_toolset.sh`。 +它打印了 `PASS`。**而它其实失败了。** + +skip 的判据是**事后**匹配失败文本,而其中一个模式是 `*"index"*` —— +几乎每条 mcpp 命令都会打印 "package index"。于是**任何**真实的安装失败 +都会走进 skip 分支。那次安装跑了 **135 秒**、失败了、脚本报 PASS。 + +> **用失败的样子来决定 skip,不是 skip,是一种不去看。** + +现在 skip 在动手**之前**由一条正向检查决定(`toolchain list` 里有没有 msvc 行), +之后的任何失败都是失败,并且**把输出打出来**而不是折进一行消息里。 + +### 4.5 它藏住的那条:GNU tar 把 `C:` 当成主机名 + +``` +tar -xf "C:\Users\...\.payloads\Microsoft.VC...vsix" +tar: Cannot connect to C: resolve failed +``` + +GNU tar 先按 `host:path` 解析,再看盘符;Windows 自带的 bsdtar 不会。 +**同一个 runner 镜像、同一份 recipe、不同的 tar** —— 这就是它在 index 的 +`windows-test`(System32 的 bsdtar)通过、在 mcpp 的 e2e(Git for Windows +把 GNU tar 排在前面)失败的全部原因。 + +`--force-local` 只有 GNU tar 认、bsdtar 直接拒绝,等于换一个坏掉的环境。 +**当时**改成 cd 进 payload 目录用相对文件名 —— 而那一版后来自己变成了 +第 4 层的故障,见 §4.5b。 + +**同一份日志还确认了一件好事**:五个 payload(含 #630 新加的 Store) +**全部从 gitcode.com 取到** —— 镜像在真实 Windows runner 上验证通过了, +坏的只是解包。 + +### 4.5b 解包这一个代码块,拆出五层原因 + +修好 skip 判据之后,同一个 `tar` 调用连着炸了五次,**每一次都是真原因, +而且每一层只有在上一层被拿掉之后才看得见**: + +| # | 报错 | 真正的原因 | +|---|---|---| +| 0 | *(无 —— 报 PASS)* | e2e 的 skip 匹配到了 `*"index"*` | +| 1 | `Cannot connect to C:` | GNU tar 把 `C:` 当主机名 | +| 2 | `does not look like a tar archive` | **GNU tar 根本不读 zip** | +| 3 | `"C:\Windows/System32/tar.exe"` | `path.join` 混分隔符,exe 路径也要 `winpath()` | +| 4 | 命令完全正确却找不到文件 | `system.exec` **不继承** `os.cd` | +| 5 | `The filename... syntax is incorrect` | `cmd /c` 遇到「带引号的 exe + 带引号的参数」会弄坏整行 | + +最后的形态**比我动手之前更简单**:不改 cwd、不用相对路径、不依赖 PATH。 +中间几版是在加 workaround,最后一版是在**删** —— 第 1 层的相对路径本来 +就只是为了绕开第 2 层里那个错误的 tar,钉死 bsdtar 之后它没了理由, +却留下来变成了第 4 层的故障。 + +### 4.5c 第六层在 mcpp 自己这边:payload 根目录被猜了 + +装成功之后暴露的: + +``` +error: msvc payload installed at '...\xim-x-msvc\14.44.35207\VC' +``` + +注意结尾的 `\VC`。`XpkgPayload::root` 只在版本目录**直接**含 +`bin/` `include/` `lib/` 时才认它是根,否则**钻进唯一的子目录** —— +而装好的 msvc payload 只有一个条目 `VC/`。 + +修法不是去迁就那个启发式,是**不再问它**:位置是 +(store, name, version),三者在两个调用点都是已知的。 +`resolve_xpkg_path` 继续负责安装,只是不再被问「在哪」。 + +### 4.6 还有一条,而且我明知道该怎么避免:`os.curdir` 不在 sandbox 里 + +修 tar 时我写了 `local prevdir = os.curdir()`。Windows CI: + +``` +msvc.lua:360: attempt to call a nil value (field 'curdir') +``` + +**这条 recipe 已经在同一堵墙上撞过三次**(`os.execv`、`path.absolute`、 +`os.files`),每一次的信号都一样:**整个 index 里零处使用**。 + +```bash +grep -rn "os\.curdir" pkgs/ | wc -l # 0 → 别用 +``` + +零处使用的 sandbox API 基本可以认定不在 runtime 里 —— 不是概率判断, +是「能用的东西早就有人用了」。而代价不对称:猜对省几秒,猜错要等一轮 +Windows CI,失败信息还出现在离你写的那行几层远的地方。 + +已经把这条规则和四个条目一起写进了 `xpkg-creater` 技能的 sandbox API 表。 +当时的改法是 `os.cd(pkginfo.install_dir())` —— 而最终版**把 `os.cd` 整个删了**, +因为 `system.exec` 根本不继承它(§4.5b 第 4 层)。也就是说:这条 API 缺失 +是在一段**本来就不该存在**的代码里踩到的。 + +--- + +### 4.8 Windows SDK:名字写着 "Desktop" 的,一样都不给 ⚠️⚠️ + +§4.2 之后 `installed()` 改成查文件名而不是查目录。它立刻报了两个不在: +`Include//um/winnt.h` 和 `bin//x64/rc.exe`。 + +它们**从来就没在过**。recipe 里那份注释写着 +「`Windows SDK Desktop Headers x64` → um / shared 头文件」—— 那是我写的,而且是错的。 + +把每个 MSI 的 `File` / `Component` / `Directory` 三张表解出来 +(7z 取 OLE 流,`!_Columns` 取 schema —— SDK 的 MSI 列宽**并不统一**, +按固定宽度猜会让后面每一列静默错位),算出每个文件的真实安装路径: + +| 以为在 | 实际在 | 实际内容 | +|---|---|---| +| Desktop Headers x64 | — | **只有 4 个文件**:三个 Hyper-V 头 + 一个 catalog | +| Desktop Tools x64 | — | 99 个 bin 工具,**没有 rc,也没有 mt** | +| — | **Store Apps Headers** | 528 个 um 头:`windows.h` `winnt.h` | +| — | **Store Apps Headers OnecoreUap** | `shared/`:`windef.h` `sal.h` `winerror.h` | +| — | **Store Apps Tools** | `rc.exe` `rcdll.dll` `mt.exe` | + +**一个 Win32 程序需要的每一样东西,都在名字写着 "Store Apps" 的 MSI 里; +四个写着 "Desktop" 的,给的是长尾、几个 bin 工具、三个 Hyper-V 头。** +和 §4.2 的 `CRT.x64.Store.base` 是同一个陷阱,这是第三层和第四层。 + +补齐 3 个 MSI + 14 个 cab(8 MSI + 35 cab,170 MB → 291 MB)。 +其中 Tools 一个就 99 MB,换一个 200 KB 的资源编译器 —— 付了, +因为 `programs = {"rc", "mt"}` 写在包里,而那两个文件当时**不在磁盘上**。 + +`required_files()` 现在是**覆盖**而不是抽样:每条对应一个「只有它才提供」的 +payload(`gdi32.lib` 只在 Desktop Libs —— 它的 365 个库和 Store Apps Libs 的 +116 个**完全不相交**),所以哪个 payload 没下来/没解开,报错就点名哪一个。 + + +补完之后**离线核过一遍全集**(把 8 个 MSI 的表并起来,算每个文件的安装路径), +一个 x64 桌面程序链接需要的东西全部到位,包括下一层本来会炸的 ucrt 库: + +``` +Lib//ucrt/x64/{libucrt.lib, ucrt.lib} Include//ucrt/corecrt.h +Lib//um/x64/{kernel32,user32,gdi32,uuid} Include//um/{windows.h,winnt.h} +bin//x64/{rc.exe, mt.exe} Include//shared/{windef.h,sal.h,winerror.h} +``` + +—— 这一步是**在 CI 发现之前**做的:这一轮已经反复出现「修好一层才够得着下一层」, +所以这次没有等它自己撞出来。 + +**无感升级这一条是顺带成立的,而且成立的理由值得记一笔。** `windows-sdk` 的版本号 +没变(还是 `10.0.26100`),按「目录在就算装了」的判据,已经装了旧的那一份的人 +**永远等不到这个修复**。 + +但 xim 决定「是否已安装」时会调 recipe 自己的 `installed()` 钩子 +(`installer.cpp:2451` `executor.check_installed(ctx)`),而 `installed()` 现在 +是**覆盖式**的七条文件断言 —— 旧的那棵树少 `winnt.h`、少 `windef.h`、少 `rc.exe`, +判据直接为假,于是重装。 + +也就是说:**把"装好了"的判据从"目录在"改成"能用",顺手把升级路径也修好了。** +这不是额外做的设计,是同一个决定的另一面。 + +### 4.9 mcpp 这边同一个病:SDK 只查了头文件 ⚠️ + +补完 SDK 之后,e2e 239 仍然挂在同一句: + +``` +LINK : fatal error LNK1104: cannot open file 'kernel32.lib' +``` + +`find_windows_sdk()` 认一个根的条件是 `Include//ucrt/corecrt.h` 存在 —— +**只看头文件**。而 SDK 是头文件**和**导入库两半。 + +于是半装的 payload「找到了」,而且因为版本号更高,**排在机器自己那套完整 +SDK 前面**;每个 TU 都编过,一直到最后一步才炸,**日志里没有一行提到 SDK**。 + +这条和 §4.2、§4.8 是同一句话的第三种说法,只是这次在 mcpp 自己的代码里: +`has_usable_msvc()` 的注释早就写了「用比构建真正需要的更弱的信号去选工具链, +正是这个判据要防的 bug」—— 它对编译器坚持了两半,对 SDK 没有。 + +现在两半都要。半装的根被跳过,搜索落到下一个,**本来就能用的构建就能用了**。 +三个测试各自对着旧判据跑过:headers-only 被拒、完整的仍然收 +(否则「全拒」也能让测试变绿)、高版本的半装根输给低版本的完整根。 +第一和第三条在没有修复时失败。 + +### 4.10 修好之后才够得着的下一个:remove 在 Windows 上失败 + +SDK 修好之后 e2e 239 的前三步全过了 —— 装上、用 payload 里的 cl 构建、 +跑出 Hello、切回 `msvc@system` 仍然解析到系统 cl。第四步 remove 挂了: + +``` +error: remove failed: Access is denied. +``` + +`remove_all` 直接上,不清只读位、不重试、报错不说是哪个文件。payload 从 +.vsix/.msi 解出来,归档条目带只读属性;而 POSIX 只要**目录**可写就能 unlink +子项 —— 所以这个问题在 Linux/macOS 上不可能出现,**而单元测试都跑在那边**。 +另外 `/Zi` 构建之后 mspdbsrv.exe 还在 payload 里活几秒。 + +两个原因表现一模一样,所以两个都处理:清可写位重试 + 给活着的进程一个 +**有上限**的窗口。并且报错点名卡在哪个文件。 + +> 没有配单元测试:唯一会跑它的平台上,删只读文件本来就成功 —— +> **那个测试不可能失败**。这一轮已经修过一个这样的测试(§4.5),不再造第二个。 +> 判据是 Windows runner 上的 e2e 239。 + +--- + +#### 而它点名的那个文件,是第八层 + +加上「说出卡在哪个文件」之后,CI 立刻给出了答案: + +``` +error: remove failed: Access is denied. + stuck at: ...\bin\Hostx64\x64\Microsoft.VisualStudio.Telemetry.dll +``` + +**两个猜测都不对** —— 不是只读位,也不是 mspdbsrv。占着它的是 `vctip.exe`: +cl.exe 拉起来的后台**遥测上传进程**,它比编译器活得久,而且就住在 +payload 目录里面。于是这个 toolset **装得上、编得过、跑得起来,就是卸不掉**。 + +关键在于:**占用者不是 mcpp 能删掉的东西**。任何一边的 `remove` 都删不掉 +另一个进程正打开的文件 —— `xlings remove msvc` 一样卸不掉。所以修复必须在 +payload 那一层:recipe 解包后直接不装 `vctip.exe`。 + +删它安全,依据是 cl.exe **自己的字符串**,不是我猜的: + +``` +Not launching VCTIP: Binary not found @ '%S' +Not launching VCTIP: CLR not present +``` + +—— 二进制不在,是它本来就处理的一条分支。而且没有环境变量可以关掉它: +cl.exe 里没有任何遥测开关(vctip 自己的 `-upload:optout` 写的是**机器级**状态, +装个包不该动那个)。 + +顺带一提,一个构建工具链本来也不该因为你编译了一下就联网上报。 + +**另外修掉了我自己在这一层引入的一处:** 那个「找出卡住的文件」的探测, +第一版是**靠试删**来找的 —— 诊断变成第二次破坏。`remove_all` 已经把能删的 +都删了,所以**活下来的第一个文件就是卡住的那个**,根本不需要再删。 +同时报错现在明说:**失败的 remove 不是空操作**,剩下的是一个有洞的工具链。 + +#### 第九层:修好「不再安装」,不等于修好「已经装了的」 + +#637 合入并发布之后,mcpp #440 的 e2e 239 **还是挂在同一个文件上**。 + +原因很干脆:#637 让 recipe 不再**安装** vctip.exe,对**已经装了它的机器** +一点作用都没有 —— 而那正是卸不掉的那批机器。包的版本号不会因为 recipe 改了 +就变,于是 xim 问 `installed()`,旧 payload 回答「装好了」,安装钩子直接跳过, +vctip.exe 原地不动。**修复被自己的判据挡在门外。** + +所以 `installed()` 现在也检查 vctip.exe **不在**: + +> `installed()` 的含义是「payload 处于**这份 recipe 产出的状态**」, +> 不是「这儿有个 payload」。两者只在 recipe 变更时不一样,而那正是它要紧的时候。 + +这和 §4.8 那条「无感升级顺带成立」是同一个机制,只是这次针对的是新 recipe +**删掉**的东西,而不是新增的东西 —— 加文件靠断言文件在就能发现, +删文件必须显式说「它不该在」。 + +**顺带解释了一件事:为什么索引侧的 CI 从来没发现它。** index 的 windows-test +做的是「装 → 检查 → 卸」,全程**不编译任何东西**。而 vctip.exe 是 cl.exe +**运行时**才被拉起来的 —— 不跑编译器,就没有进程,卸载自然成功。 +所以 msvc.lua 前后几个 PR 的 windows-test 一路全绿,包括「post-uninstall checks」。 + +只有真正构建过东西的 e2e 才够得着这一条。**一个不运行被测物的验收步骤, +验的是解压。** + +### 4.11 一条不是缺陷、但今天撞了五次:「合入索引」不等于「装得到」 + +| # | 任务 | 起跑 | 索引就绪 | 差 | +|---|---|---|---|---| +| 1 | xrgui CI | 07:16:55 | 07:17:35(产物发布) | 抢跑 40s | +| 2 | xrgui CI 重跑 | 07:20:28 | 07:17:29(指针已更新) | 指针 CDN 缓存 | +| 3 | mcpp #438 e2e | 07:36:38 | 07:41:20(tar 修复入索引) | 抢跑 4.5 min | +| 4 | mcpp #440 e2e | 10:31 起跑,239 跑在 10:52 | 10:26:49(SDK 补全发布) | **赶上了** —— 构建第一次链通 | +| 5 | mcpp #440 e2e 重跑 | 11:31 起跑,239 跑在 11:39 | ~11:40(vctip 修复发布) | 抢跑 ~1 min | + +**这不是这次改动引入的**,但它意味着「合入 → 索引 → 装得到」之间有一个 +肉眼不可见的窗口,而**窗口期内的失败信息是 `not found`** —— 与「这个包根本 +不存在」一模一样。第 3 次尤其值得看:它让一个**已经修好**的缺陷看起来像 +没修好,只有对着时间戳才分得清。 + +`xlings` 已经会说 `run \`xlings update\` if the package was just published` —— +那句提示正是为这个窗口写的,它是对的,只是在 CI 里没有人能照做。 + +第 5 次是我自己踩的:合入 #637 之后**没等发布完就重跑**了 mcpp 的 CI。 +之后改成「先等 raw 索引里真的能 grep 到那行代码,再 push」—— +也就是把判据从"合入了"换成"取得到",和这一整轮的主题是同一句话。 + +小插曲,同一个毛病的第三种形态:第一版等待脚本 grep 的模式**少写了一个右括号** +(`vctip.exe") then` vs 实际的 `vctip.exe")) then`),于是它永远等不到 —— +而"等不到"和"还没发布"输出一模一样。**连我用来检查判据的东西,判据本身也得能失败。** + +**而且第 5 次差点把结论带偏**:它失败在同一个文件上,看起来像"修复没用", +实际有两个原因叠着(抢跑 + 老 payload 不会自动重装)。分清它们靠的是时间戳 +和"runner 上那份 payload 是 #637 之前装的"这个事实,不是重跑一次看看。 + +--- + +## 4b. 沙箱真实验证(`xlings subos ... --sandbox --cmd`) + +在**干净 subos** 里,对**已发布的 2026.8.16.2 二进制**做的验证: + +| 验的是什么 | 命令 | 结果 | +|---|---|---| +| 索引装得到 mcpp | `xlings update && xlings install xim:mcpp` | ✅ `xim:mcpp@2026.8.16.2` 装上 | +| xlings 生态本身可用 | `xlings install gcc` → 编译 → 运行 | ✅ `eco ok` | +| **两条来源在发布产物上真的分开了** | `mcpp toolchain install msvc system` | ✅ `the msvc toolchain is only available on Windows hosts`(探测机器那条路) | +| 同上,受管那条 | `mcpp toolchain install msvc 14.44.35207` | ✅ `Installing msvc@14.44.35207 via mcpp's xlings`(走 xim,**不是**宿主守卫) | + +最后两行是这轮架构改动**在发出去的二进制上**的判据:同一个命令、只差一个版本号, +走的是两条完全不同的路径。 + +### 两处已知、且与本轮无关的现象(如实记) + +1. **`xlings info msvc` 仍然说 `not found`。** 平台感知的新消息在 xlings#550 + 里(2026-08-16 15:16 合入),而最新 release tag 是 `v2026.8.14.1`, + 本机 0.4.51 **早于**那个修复。CI 上 E2E-76 带新断言通过,证明消息确实产生了。 +2. **干净沙箱里 mcpp 自举 gcc 失败(exit 127)。** 装出来的 gcc@16.1.0 其 ELF + 解释器指向一个本机早已不存在的目录;xlings 自己 store 里的同一个 payload + 解释器是对的 —— 说明发布产物没问题,是 mcpp 自举那步没打上 patchelf。 + mcpp 自己的日志点名了原因:SubOS 缺 `subos_info`(**openxlings/xlings#547**)。 + **已知在跟踪,与 MSVC 这轮无关,没有动它。** + +--- + +## 4c. 第十、十一层:改名改错了东西,以及"卸载"的判据 + +vctip 修好之后,占用者换成 `mspdbcore.dll` —— `/Zi` 构建拉起的 **mspdbsrv.exe**, +它比 cl.exe 多活几十秒,而且就住在正要被删的 payload 里。等它不现实(等多久都是猜)。 + +### 我先前的前提是错的,CI 当场否掉 + +我写的是:「Windows 拒绝删除含打开文件的目录,但允许**重命名**」,于是去改名 +payload 目录。**错在哪:** Windows 允许重命名一个**打开着的文件**(更新器就是这么 +替换运行中的 .exe),但**不允许**重命名一个**含有**打开文件的目录。改名失败, +remove 照样报错。 + +正确的形状是反过来的:**把文件逐个挪出去**,再删掉此时只剩目录的那棵树。 + +### 然后是第十一层:空目录也删不掉 + +文件都挪走了,`remove_all` 仍然失败 —— 这次是**目录**上的 sharing violation +(某个进程把它当工作目录;mspdbsrv 正是在 payload 里被拉起来的)。 + +判据因此定为 **「没有文件残留 = 已卸载」**: +**一个没有文件的工具链就是没装**,这正是 `remove` 承诺的事; +报失败等于报了相反的事实。骨架在下一条生命周期命令里清扫。 + +而这立刻照出对称的另半句谎:`toolchain default` 原来只查目录**存在**, +会把骨架当成装好的工具链交给构建。现在它问 `payload_frontend` 要一个 +**能解析出来的编译器**。 + +> 又是同一句话:**装好了 = 能用,不是"在那儿"。** + +### 测试:第三次得出"这个测试不可能失败" + +POSIX 上唯一能让文件删不掉的办法是去掉父目录的写权限 —— 而这个函数的**第二遍** +正是要把写权限加回来(那是它要修的只读 payload 那条)。**fixture 在被测代码碰到 +它的那一刻就变成可删的了。** 那是函数在正常工作,不是漏洞。 + +所以两个依赖该 fixture 的测试删掉了,留下的是在任何平台都成立的那两条 +(普通 payload 走普通路径、sweep 只吃 `.trash-*` 不吃装好的 toolset)。 +判据是 Windows runner 上的 e2e 239。 + +--- + +## 4d. review 落地了什么 + +| 项 | 落在哪 | +|---|---| +| §3b `doctor` 还留着 #436 修掉的第四份布局拷贝 | mcpp#440 | +| §3c `has_usable_msvc` 把"机器有 VS"当成"这里能用 MSVC" | mcpp#440 | +| §3 `/MD` 产物在干净 Windows 上跑不起来(`vc_redist_dir` → PATH) | mcpp#440 | +| §3e 主构建与 host helper 链接策略不一致(**#437**) | mcpp#442 | +| §4 `installed()` 语义写进 skill §2.2.1 + lint | index#640 | +| §5 windows-test **真的运行**声明的程序 | index#640 | +| §6 park-and-sweep 下沉到 xlings 卸载路径 | xlings#551 | + +**没做的四项**(§1 收拢 Origin、§2 SDK 绑定 + 版本轴、§3d PE 版 pack、§7 索引修订号) +都要动 spec/schema 层或跨仓库,不适合塞进发版窗口 —— 在 review 里写到了可以直接开工的程度。 + +### 一处我自己造出来的问题,已作废 + +review 初稿把 `is_system_toolchain()` 里的 `Family::Msvc` 读成"不对称,应该推广", +提议支持 `gcc@system`。**方向反了**:xlings 是用户态 OS,host 依赖是要**降到最低**的。 +被纠正后我又去给自己发明的拼写写了个"显式拒绝"(#441),那是从自己的错误里长出来的 +新范围,已关闭。而且那条消息还说错了 —— `prepare.cppm:1376` 有一条**故意保留**的 +裸 `system` 逃生口,真正提供 host 依赖的那一处**不带族名**。 + +--- + +## 5. 发布与 gitcode 资源 + +- **mcpp 2026.8.16.1**:四平台产物 + 别名 + `SHA256SUMS` 共 20 个资产, + release CI 全绿。**发布产物本身验过**:下载 linux-x86_64 跑 + `mcpp toolchain install msvc 14.44.35207`,走的是 xim 路径而不是 + `msvc_wrong_host()` —— 版本轴分流在**发出去的二进制**上生效。 + + ⚠️ 这个 tag 打在 #436 / #438 **之前**,所以它带着 §4.3 那条 + 「装好的 toolset 不出现在 `toolchain list` 里」。两个修复合入后应当补一个 + `2026.8.16.2`,让发布版本与 main 一致 —— 已列入待办,`.1` 的其余部分 + (安装、构建、default、remove)不受影响。 +- **gitcode 镜像**:四个平台产物都在 + `gitcode.com/xlings-res/mcpp/releases/download/2026.8.16.1/`(206 实测)。 + 注意 tag **不带 `v` 前缀** —— 第一次我按 `v2026.8.16.1` 探测得到 404, + 差点误报成「镜像没传上去」。 +- **MSVC 生态资源**:29 个 payload(27 + #630 新增的 2 个 Store)手动 + `gtc release upload` 到 `xlings-res/{msvc,windows-sdk}`,逐个下载校验 + sha256 与微软一致。 + +--- + +## 5b. 发布 2026.8.16.3:`publish-ecosystem` 连续两版卡在同一步 + +| | 结果 | +|---|---| +| 四平台产物 + manifest 封存 | ✅ 全部成功 | +| GitHub release 21 个资产 | ✅ 齐 | +| `publish-ecosystem` 的 gitcode 镜像步 | ❌ **10 分钟超时,重跑仍然超时** | + +`.2` 那次也是同一步超时(当时重跑成功了)。**连续两个版本卡在同一位置, +已经不是 flake。** 值得当成一条待办给 pipeline。 + +按授权用本地 `gtc` 补齐 14 个缺失资产,再**下载回来逐个哈希**:**21/21 与本地一致**。 + +### 又一次:HEAD 不能当判据 + +第一次扫描镜像用的是 HTTP HEAD,结论是"21 个全缺"。改用真实 GET 才发现 +**实际是 19 缺 2 在** —— gitcode 对确实存在的文件也会给 HEAD 返回非 200。 + +要是照着 HEAD 的结论去传,就会对着已经在的两个重复上传; +要是反过来(HEAD 说在、实际不在),就会漏。**判据必须是"字节回来了且摘要一致"** +—— 和 §4.1 那 16 个文件是同一课,只是这次我差点自己又栽一遍。 + +索引 bump 用的是 release pipeline 自己的 `version-check.py --apply --only mcpp`, +所以 sha256 是**从已发布产物算出来的**,不是手抄。 + +--- + +## 6. 镜像:地址会失效,字节不会 + +27 个 payload(325 MB)镜像到 `gitcode.com/xlings-res/{msvc,windows-sdk}`, +官方 CDN 作为**最后**一个回退。**sha256 规则一行没动** —— 从哪个地址取到都按同一个 +摘要校验,所以镜像不是第二个可信来源,是同一份字节的第二个地址。 + +回退的两个前提都单独验过:`curl -f` 遇 404 退 22 且不留文件(所以 `pcall` 接得住)、 +官方地址仍服务同样的字节。走到第一个之后的地址会 `log.warn` —— +**看不见的回退等于没有回退**。 + +--- + +## 7. 明确没有覆盖的部分 + +1. **「用受管 toolset 真的链接一次」这条,在写这份报告时正在 CI 上跑第一次**。 + mcpp#438 修好 e2e 239 的 skip 判据之后,加上索引里的 tar 修复(`8b291db`), + 239 会第一次真的装 `xim:msvc@14.44.35207` 并用它构建一个 hello。 + §4.2 那条缺陷能走到发布,正是因为在此之前**没有任何 CI 用这条 toolset + 链接过东西**;补强 `installed()` 只是让「文件在不在」变严, + **不等于「链接得通」**。 + + xrgui 目前用的仍是 `msvc@system`(见 §2 —— 那一条腿存在的意义是 + 「只有构建系统不同」,pin toolset 会让两条腿同时差两件事)。 + 用受管 toolset 构建 xrgui 是下一步,不是这一轮。 +2. **`msvc@14.52.36629` 的安装路径没在 CI 上跑过**(windows-test 装的是 14.44)。 + 本机按 recipe 的方式解包了 14.52 的五个 payload,`installed()` 要的四个文件 + 加上 `/MD` 的其余部分(`msvcrt.lib` / `vcruntime.lib` / `oldnames.lib`) + **全部就位** —— 但那验的是解包,不是 Windows 上的安装。 +3. **镜像回退没被真正触发过** —— 两个前提验过了,完整路径没执行过。 +4. **D4 的控制台那一半仍然开着**。管道那条路量到了(零撕裂,且样本是**修复前**的 + 2026.8.10.1,所以结论是「管道从来没坏过」,这收窄了 D4 而不是回答它); + CI 结不了控制台的案 —— runner 上 job 没有附着的控制台。 +5. **gitcode 的 probe 资产删不掉**(API 两个路径都 404),已在镜像 README 里点名。 +6. **index CI 用 4 个版本以前的 xlings 验证 recipe**(`ci-test.yml` 钉 + `v2026.8.10.1`)。没在这里改 —— 换版本可能影响别的 recipe,应当是它自己的 PR。 +7. **Linux 侧沙箱里 `mcpp` 自举 gcc 失败,不是这轮引入的。** + 在干净 subos + 干净 `MCPP_HOME` 里,mcpp 装的 gcc@16.1.0 其 ELF 解释器指向 + 一个**本机早已不存在的目录**,`g++ --version` 直接 127。 + xlings 自己 store 里的同一个 payload 解释器是对的 —— 也就是说发布的产物没问题, + 是 mcpp 自举那一步没打上 patchelf。mcpp 自己的日志点了名: + SubOS 缺 `subos_info`(**openxlings/xlings#547**),所以它「拒绝猜一个版本」。 + 属于已知在跟踪的问题,与 MSVC 这轮无关,**没有在这轮动它**。 + xlings 生态本身在同一个沙箱里验过是通的:`xlings install gcc` → 编译 → 运行 → `eco ok`。 +8. **`info` 的修复已合入但尚未发布**。本机 `xlings` 是 0.4.51,还是旧的, + 所以在这台机器上跑 `xlings info msvc` 依然是 `not found`。 + 证据来自 CI:E2E-76 带着新断言(`no build for` + 必须点名平台)通过, + 97/97 全绿 —— 也就是说新消息**确实产生了**,只是还没到本机。 + +--- + +## 7b. 自我 review:在自己刚打完 tag 的代码里又找到两条 + +`2026.8.16.3` tag 打完之后,我对 `v2026.8.16.2..v2026.8.16.3` 的 diff 做了一次 +独立 review,抓到两条真的: + +**1. 清扫会误删另一个正在解压的版本(🔴 数据损失)** + +`sweep_parked_payloads` 扫整个 family 目录,删掉**任何**没有文件的版本目录。 +但安装是**逐步**往目录里写文件的 —— 这正是 `package_fetcher` 用标记文件而不是 +"目录在"判断装完没有的原因。于是另一个进程半解压的目录和"残骨架"短暂无法区分, +而共用一个 `MCPP_HOME` 的机器(自托管 runner、共享开发机)上两个 mcpp 同时跑 +是常态,**恰恰就是 park/sweep 这套东西存在的理由**。 + +修法:`.trash-*` 照旧全扫(那个名字只有这段代码会写); +"没有文件的骨架"收窄成**只扫这条命令点名的那一个版本**。 + +**2. 一个 yes/no 问题在起编译器(延迟回归)** + +`msvc_available_here()` 只想知道"这儿有没有能用的 toolset",却走完整 +`installation_at()` —— 里面跑 cl.exe 拿 banner 定**版本号**,然后把它丢掉。 +而这个判据在**每次构建**的 MSVC ABI 门上都被问到,装了多个 toolset 的机器 +每次构建付好几次子进程 —— 正好是这个判据当初为之添加的那类机器。 + +修法:`installation_at(..., identifyVersion=false)`。 +**布局仍然只有一份实现** —— 是加参数,不是再抄一遍路径拼接 +(那正是 review §1 骂的事)。 + +新测试对着旧的全目录扫法跑过,**会失败**。 + +> 这一节存在本身就是判据:**"CI 全绿"和"这段代码是对的"是两件事。** +> 两条都是 19/19 绿之后才被读出来的。 + +--- + +## 8. 按你给的八个角度逐条对照 + +| 角度 | 这轮做了什么 | 依据 | +|---|---|---| +| **架构** | MSVC 拆成**两条正交的轴**:「获取」与 gcc 共用(xim),「解析」与 `msvc@system` 共用(`installation_from_tools_dir`)。受管 toolset 因此不是第二条代码路径,长不出自己的 bug | §2 | +| **稳定性** | 九层缺陷全部收口,每一层都补了**对着缺陷跑过**的测试;三处「不可能失败的测试」被识别出来,其中一处直接不写(平台上删只读文件本来就成功) | §4 | +| **优雅简洁** | 判据统一成一句话:**「装好了」= 能用,不是「解压完了」**。`installed()`、`find_windows_sdk()`、`has_usable_msvc()` 现在是同一条规则的三处应用;重复的布局拼写合并回 `toolsdir()`/`payload_frontend()` | §4.8–4.10 | +| **用户体验** | 报错说出**具体是哪个文件**(缺哪个 payload / 卡在哪个 DLL),并说清「失败的 remove 不是空操作」;99 MB 换 rc+mt 是明账,写在 recipe 头部 | §4.8、§4.10 | +| **兼容性** | `msvc@system` 语义**一字未改** —— e2e 239 专门有一条反向断言:切回 `msvc@system` 必须解析到系统 cl,否则失败 | §2、§3 | +| **跨平台** | msvc 发现逻辑第一次能在 **Windows 之外**测试(收目录/收 root 列表,不再被平台宏包住),新单测跑在 Linux CI 上;SDK 库路径按**宿主架构**拼,不写死 x64 | §3 | +| **一致性** | msvc 与 gcc 现在同构:同样的 `xim:` 获取、同样的 `payload_frontend`、同样的多地址 + sha256 镜像规则 | §2、§6 | +| **无感升级** | 版本号没变也能把老机器拉回来 —— 靠的是 `installed()` 忠实描述「**这份 recipe 产出的状态**」:新增的文件靠断言它在,**删掉**的文件靠断言它不在 | §4.8、§4.9(第九层) | + +**如果只留一句**:这九层缺陷没有一层是"写错了",全都是**验收判据比"能用"弱**。 +把判据改成"能用"之后,它们一层层自己冒出来了。 + +--- + +## 9. 复现用的工具 + +`msimap.py`(离线解 MSI 的 File/Component/Directory 表,算每个文件的安装路径) +留在 scratchpad 里。它是这轮唯一能回答"这个 MSI 里到底有什么"的东西 —— +**payload 的名字、微软的文档、我自己写的注释,三个都错了,只有表是对的。** diff --git a/.agents/docs/tools-msimap.py b/.agents/docs/tools-msimap.py new file mode 100644 index 00000000..e5ec0d8f --- /dev/null +++ b/.agents/docs/tools-msimap.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Resolve an MSI's File table to full install paths, offline. + +Why: the VS channel manifest says nothing about what is *inside* a payload, +and cabinet entries are keyed names (filXXXX), so "does this MSI ship rc.exe, +and where does it land?" is only answerable from the MSI's own tables. + +The schema is read from !_Columns rather than assumed -- the SDK's MSIs do +not all use the same column widths, and a guessed width silently misaligns +every column after it instead of failing. +""" +import struct, subprocess, sys, tempfile, pathlib, shutil + +STRING = 0x0800 # MSITYPE_STRING + + +def streams(msi): + d = pathlib.Path(tempfile.mkdtemp()) + subprocess.run(["7z", "x", f"-o{d}", str(msi), "-y"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + return d + + +def string_pool(d): + pool = (d / "!_StringPool").read_bytes() + data = (d / "!_StringData").read_bytes() + n = len(pool) // 4 + codepage = struct.unpack_from("64K strings -> 4-byte refs + out, off, i = [""], 0, 1 + while i < n: + size, refs = struct.unpack_from("= 4 else 2 + + +def _decode(raw, coltypes, strings, long_refs): + """MSI tables are column-major; every column is fixed width.""" + widths = [_width(t, long_refs) for t in coltypes] + rows = len(raw) // sum(widths) + cols, off = [], 0 + for t, w in zip(coltypes, widths): + vals = [] + for r in range(rows): + v = int.from_bytes(raw[off + r * w: off + (r + 1) * w], "little") + if t & STRING: + vals.append(strings[v] if v < len(strings) else "") + else: # integers are stored sign-biased + vals.append(v ^ (0x80000000 if w == 4 else 0x8000)) + cols.append(vals) + off += rows * w + return list(zip(*cols)) + + +def schema(d, strings, long_refs): + """_Columns has a fixed shape, and describes every other table.""" + raw = (d / "!_Columns").read_bytes() + sw = 4 if long_refs else 2 + rows = len(raw) // (sw + 2 + sw + 2) + out, off = {}, 0 + def take(w, isstr): + nonlocal off + vals = [] + for r in range(rows): + v = int.from_bytes(raw[off + r * w: off + (r + 1) * w], "little") + vals.append(strings[v] if isstr and v < len(strings) else (v ^ 0x8000)) + off += rows * w + return vals + tabs, nums, names, types = take(sw, True), take(2, False), take(sw, True), take(2, False) + for t, n, nm, ty in zip(tabs, nums, names, types): + out.setdefault(t, []).append((n, nm, ty)) + return {t: [c for _, _, c in sorted(v)] for t, v in out.items()}, \ + {t: [nm for _, nm, _ in sorted(v)] for t, v in out.items()} + + +def table(d, name, sch, strings, long_refs): + f = d / f"!{name}" + if not f.exists() or name not in sch: + return [] + return _decode(f.read_bytes(), sch[name], strings, long_refs) + + +def longname(n): + n = n if isinstance(n, str) else "" + return n.split("|")[-1] if "|" in n else n + + +def resolve(msi): + """-> list of (full_install_path, filename)""" + d = streams(msi) + try: + strings, lr = string_pool(d) + sch, _ = schema(d, strings, lr) + files = table(d, "File", sch, strings, lr) + comps = table(d, "Component", sch, strings, lr) + dirs = table(d, "Directory", sch, strings, lr) + + parent = {r[0]: r[1] for r in dirs} + dname = {r[0]: longname(r[2]) for r in dirs} + compdir = {r[0]: r[2] for r in comps} + + def full(key, depth=0): + if not key or depth > 32 or key not in dname: + return "" + here = "" if dname[key] in (".", "") else dname[key] + up = full(parent.get(key), depth + 1) + return f"{up}/{here}".strip("/") if up else here + + return [(full(compdir.get(r[1], "")), longname(r[2])) for r in files] + finally: + shutil.rmtree(d, ignore_errors=True) + + +def main(msi, *needles): + rows = resolve(msi) + hits = 0 + for path, name in rows: + if not needles or any(n.lower() == name.lower() for n in needles): + print(f" {path or '?'}/{name}") + hits += 1 + if needles and not hits: + print(" (none of " + ", ".join(needles) + ")") + if not needles: + print(f" total {len(rows)} files") + + +if __name__ == "__main__": + main(sys.argv[1], *sys.argv[2:])