From 24b7e06a2860bc3f1f131e031688f10e4578139b Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 28 Aug 2026 08:49:59 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E4=BE=9D=E8=B5=96=E7=9A=84?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=BD=A2=E6=80=81=E6=98=AF=E4=B8=80=E6=A0=B9?= =?UTF-8?q?=E5=8F=AF=E9=80=89=E6=8B=A9=E7=9A=84=E8=BD=B4,=E8=80=8C?= =?UTF-8?q?=E6=B7=B7=E5=BD=A2=E6=80=81=E4=BC=9A=E8=A2=AB=E6=8A=93=E4=BD=8F?= =?UTF-8?q?=20(#519)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 一个库,一个提供者,一种形态。 这条不变量在两个高度上执行:plan 期对 mcpp **决定**的东西,链接后对链接器 **产出**的东西 —— 后者是唯一能看见 mcpp 从不知道其存在的那个库的高度 (vendor 包里随附的、`[system_deps]` 引入的宿主的)。 设计与实测:`.agents/docs/2026-08-28-issue519-dependency-linkage-form.md` ## 新增 - `[build] dependency_linkage`(可按 `[profile.*]` 覆盖)与依赖边上的 `linkage`。`static` 是默认值,与既有行为逐字节相同。形态**每个链接映像 解析一次**,不是每条边一次;边上的键只在**根工程**生效。 - 符号提供者检查。两段式,而**第二段不可省**:mcpp 自己的 `kind = "shared"` 机制会结构性地产出「exe 导出、`.so` 绑过来」这个形状,而那是单份定义、 完全良性的。真正的判据是「导出的东西还有第二个提供者」。默认警告, `--strict` 升级为错误,判定记进 `resolution.json`(带分母)。 ## 修复 - ⚠️ 依赖包的 `[targets.*] required_features` 从来没有生效过 —— 门控只读根的 活跃集。一个「可选」的 shared 目标因此悄悄改变了整个包对所有消费者的链接方式。 - ⚠️ `-fPIC` 不在缓存键里。形态由作者定死时可以幸存;一旦消费者能请求 shared, 同一条目就会把非 PIC 对象喂给共享链接。现由 `make_plan` 决定一次, 编译标志与缓存键读同一位;`dependency_linkage` 同时进工程指纹。 - ⚠️ 在非 shared 目标上写 `soname` 会让整份 manifest 加载失败,收窄为 「非 **library** 目标才拒绝」。⚠️ 因此写进索引描述符要等 `latest` 下限跨过本版本。 ## 文档 `docs/05` §2.2「共享库只支持 Linux/ELF」已过时(e2e 257/259),中英双份更正。 --- ...-08-28-issue519-dependency-linkage-form.md | 808 ++++++++++++++++++ CHANGELOG.md | 87 ++ docs/05-mcpp-toml.md | 102 ++- docs/zh/05-mcpp-toml.md | 84 +- mcpp.toml | 2 +- src/build/cache_key.cppm | 23 +- src/build/flags.cppm | 13 +- src/build/linkage_form.cppm | 293 +++++++ src/build/ninja_backend.cppm | 23 + src/build/plan.cppm | 20 + src/build/prepare.cppm | 147 +++- src/build/prepare_inputs.cppm | 15 + src/build/runtime_validation.cppm | 199 +++++ src/build/symbol_provision.cppm | 243 ++++++ src/manifest/toml.cppm | 34 +- src/manifest/types.cppm | 42 +- src/platform/elf_runtime.cppm | 294 +++++++ src/pm/dep_spec.cppm | 12 + src/version.cppm | 2 +- tests/e2e/306_dependency_linkage_axis.sh | 125 +++ tests/e2e/307_symbol_provision.sh | 201 +++++ tests/e2e/308_dependency_required_features.sh | 94 ++ tests/unit/test_cache_key.cpp | 30 + tests/unit/test_elf_runtime.cpp | 65 ++ tests/unit/test_linkage_form.cpp | 227 +++++ tests/unit/test_manifest.cpp | 138 ++- tests/unit/test_symbol_provision.cpp | 193 +++++ 27 files changed, 3488 insertions(+), 28 deletions(-) create mode 100644 .agents/docs/2026-08-28-issue519-dependency-linkage-form.md create mode 100644 src/build/linkage_form.cppm create mode 100644 src/build/symbol_provision.cppm create mode 100755 tests/e2e/306_dependency_linkage_axis.sh create mode 100755 tests/e2e/307_symbol_provision.sh create mode 100755 tests/e2e/308_dependency_required_features.sh create mode 100644 tests/unit/test_linkage_form.cpp create mode 100644 tests/unit/test_symbol_provision.cpp diff --git a/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md b/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md new file mode 100644 index 00000000..2e48039f --- /dev/null +++ b/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md @@ -0,0 +1,808 @@ +# issue #519:依赖的链接形态 —— 一条不变量,两个高度 + +2026-08-28 · 架构分析 + 设计方案(**待 review,尚未实施**) + +核实基线:mcpp `f2dbeec`,mcpp-index `8f5ad30`(2026-08-27)。 + +> ⚠️⚠️ **范围约束(实施前定下,不可让步)**:mcpp 侧只做**通用底座**。 +> `mcpplibs/mcpp-index#245`(eui-neo 的 Linux SNI 托盘)是这条 issue 在真实世界里的 +> 实例,用来**验证**这套机制,**绝不用来驱动设计** —— 引擎里不得出现任何 +> glib / gio / zlib / 托盘 的知识,也不得为了让那一个包通过而加特例。 +> 判据很简单:把 §4、§2 的规则里所有专有名词划掉,规则本身必须仍然完整。 +> 同样地,通用实现只依赖 mcpp + xlings 体系,不依赖宿主。 +下文标「实测」的每一条都附了命令与读数;标「读码」的附了文件行号。 + +--- + +## 0. 一句话 + +> **一个链接映像里,一个库只允许有一个提供者、一种形态。** + +issue 提的两件事,是这条不变量的两半: + +- issue §4.3 的诊断 = **执行**这条不变量; +- issue §4.1/§4.2 的形态轴 = 当映像里确实出现两个提供者时,**唯一能让用户满足它的杠杆**。 + +所以它们不是「先做哪个」的取舍,而是**一个没有杠杆的不变量只是一堵墙**。 +但顺序仍然是诊断在前,因为诊断能独立正确,而形态轴单独落地会把一个 +**可检测的缺陷换成一个不可检测的缺陷**(§5.3,这是本文最重要的一条修正)。 + +而不变量的执行有**两个高度**,因为 mcpp 有两种知识: + +| | 知道什么 | 何时 | 覆盖 | 看得见 §2 的复现吗 | +|---|---|---|---|---| +| **D1 声明层** | mcpp 自己决定的东西(包、目标、形态) | plan 期 | 全平台、精确 | **看不见**(vendor 包里的 `libz.so.1` 不是 mcpp 的概念) | +| **D2 测量层** | 链接器实际产出的东西 | 链接后 | 仅 ELF | **看得见**,而且是唯一看得见的 | + +「断言 + 核验」是这个仓库的既有形状(`scan_overrides` 断言 + P1689 核验; +pack 的 interface digest)。这里是同一个形状的第三次应用。 + +--- + +## 1. issue 的判据核实 + +### 1.1 成立的 + +| issue 陈述 | 核实 | +|---|---| +| `kind="lib"` 的对象全量入消费者链接行,无归档懒选 | 读码成立。`plan.cppm:1591` `append_package_objects` / `1782` 循环 | +| `kind="shared"` 在消费者 `bin/` 真建 `.so` | 读码成立。`plan.cppm:1607-1621` | +| 消费者对此没有发言权 | 成立。形态只由被依赖包的 `Target::kind` 决定 | +| `-fPIC` 已经是全局的 | 读码成立。`flags.cppm:444-451`,任一 shared link unit 存在即全局 PIC | +| compile-once ⇒ 同一组对象能同时喂两条路 | 成立,`docs/05` §2.2 明文 | +| `[target.*].linkage` 是另一根轴(libc/CRT) | 成立,且**只在精确 triple 表下解析**(`prepare.cppm:2026` 注释:"There is no `[build].linkage`") | +| ELF 全局扁平命名空间导致劫持,不是 bug | 成立 | +| 没有任何机制阻止或诊断混形态 | 成立 —— 这是真正的缺口 | + +### 1.2 ⚠️ 三条需要修正 + +**(a) `required_features` 门控在依赖包上从来不生效。** + +issue §3.3 说「开了 `shared` 之后两个 target 都会出 ⇒ 重复符号」。读码后是另一回事, +而且更糟: + +- 目标门控只有一处:`prepare.cppm:5402` 的 `std::erase_if(m->targets, …)`, + `m` 是**根 manifest**,判据是 `activeRootFeatures`。**依赖包的 `targets` 一个都不过滤。** +- 所以 `zlib-shared = { kind="shared", required_features={"shared"} }` 会**无条件**建出来, + 对每一个消费者都建,不管有没有开那个 feature。 +- 而重复符号**不会**发生:`plan.cppm:1475` 一旦把该包记入 `sharedDepPackages`, + `1663` 与 `1783` 就把该包的**全部**编译单元排除出消费者的链接行。 + +结论方向一致(这条路不通),但理由完全不同。**这是本次核实新挖到的独立缺陷**: +一个描述符可以用 `required_features` 写出一个自以为可选、实际强制的目标。 + +**(b) `kind = "lib"` 不是断言,是解析器的默认值。** + +issue §4.1 提议「未声明 = 两种形态都支持,声明 = 有约束」。对着装机量核一遍: + +| | 数量 | 说明 | +|---|---|---| +| mcpp-index 包总数 | 130 | `pkgs/*/*.lua` | +| 显式 `kind = "shared"` | **12** | 全是 `compat.x11` 家族 + `xcb` + `vulkan` | +| 显式 `kind = "lib"` | **84** | | +| 完全没有 `targets` 块 | 33 | | + +而 `xpkg.cppm:1435` 是 `t.kind = Target::Library; // default`。 +也就是说 **84 个包写下 `kind = "lib"` 是在复述默认值,不是在主张约束**; +按 issue 的字面读法,这 84 个包会被永久冻结成 static-only,轴对它们完全失效 —— +而它们正是这根轴要服务的对象。 + +⚠️ 索引描述符**按版本冻结**,不能要求改 84 个包(记忆:「索引是数据 mcpp 是程序」)。 + +**幸运的是不对称读法既安全又正确**: + +| 描述符说 | 读作 | 为什么安全 | +|---|---|---| +| `kind = "shared"` | **约束:必须 shared** | 12 次全部出于同一个真实理由(会被第三方 `dlopen`,进程里必须唯一) | +| `kind = "lib"` / 缺席 | **无约束** | 它是默认值,117 个包没有选择过它 | + +**零描述符改动**。这是整个设计里最省的一步。 + +**(c) 「改成 shared」不会自动解决 §2 的复现。** 见 §5.3,单独列。 + +### 1.3 ⭐ 代码里写下了这条不变量,但没有任何东西在执行它 + +`src/build/distribution.cppm:306-310`: + +> An executable's static libstdc++ is already local (ld exports only what a +> loaded object references, and mcpp passes no `-rdynamic`) + +括号里的**机制描述是对的**,括号外的**结论是错的**:当确实有一个已加载对象引用了它时, +它就不是 local 的 —— 而这正是 §2 的场景。这条注释同时给出了 §2 缺陷的成因 +和一个从未被执行的不变量。(同型记忆:「上层假设了下层从未承诺的性质」、 +「写在注释里的约束没有任何东西在执行它」。) + +⭐ 好消息是:这句话反过来读,就是 D2 的判据。 + +--- + +## 2. D2:判据全部来自测量,不需要任何声明 + +### 2.1 谓词(⚠️ 已被第二轮 review 修正两次,见 §12.1) + +适用条件:产物有 `PT_INTERP`(即它是要被加载器启动的可执行文件), +且链接行里没有 `-rdynamic` / `--export-dynamic` / `--dynamic-list` +(mcpp 从不发这些 —— 全仓 grep 只在注释里出现)。 + +**第一段(便宜,总是跑)—— 这个映像导出了什么静态定义:** + +``` +EXPORTED(exe) = { s ∈ .dynsym(exe) : DEFINED + ∧ ¬(s 是 copy relocation) } + +s 是 copy relocation ⇔ ∃ R__COPY 重定位,其 r_offset == s.st_value +``` + +`STT_FUNC` 的已定义条目**永远**属于 `EXPORTED` —— copy relocation 不会是函数。 +只有 `STT_OBJECT` 需要走上面那条重定位判定。 + +**第二段(仅当 `EXPORTED` 非空)—— 有没有第二个提供者:** + +``` +CONFLICT(exe) = { s ∈ EXPORTED(exe) : ∃ o ∈ closure(exe), o 也 DEFINE 了 s } +``` + +`CONFLICT` 非空 ⇔ **同一个符号在这个进程里有两份定义,而静态那份赢**。 +这正是 §0 的不变量,只是求值在产物上而不是在 plan 上。 + +⚠️⚠️ **第二段不能省。** 见 §12.2:mcpp 自己的 `kind = "shared"` 机制 +**结构性地**产出「exe 导出、`.so` 绑过来」这个形状,而那是单份定义、良性的。 +只看第一段会在合法安排上误报。 + +### 2.2 实测基线 + +```console +$ readelf --dyn-syms -W | awk 'NR>3 && $7!="UND" && $8!=""' +$ readelf -r -W | grep COPY +``` + +| 产物 | ELF 类型 | 已定义 / 总 | 符号类型 | COPY 重定位 | `EXPORTED` | +|---|---|---|---|---|---| +| `target/x86_64-linux-gnu/*/bin/mcpp`(四个指纹目录读数一致) | **EXEC** | 5 / 217 | 5×`OBJECT` | **4 条**,覆盖 5 个符号(`environ` 与 `__environ` 同址) | **0** | +| `/usr/bin/git` | **DYN**(PIE) | 1 / 257 | `FUNC error` | 0 | **1** —— 遮蔽 glibc 的 `error(3)` | +| `/usr/bin/ls` | **DYN**(PIE) | 7 / 127 | 6×`FUNC` + 1×`OBJECT` | 0 | **7** —— gnulib obstack vs glibc | +| `/usr/bin/bash` | DYN(PIE) | 2339 / 2578 | —— | —— | 前置条件不成立(`-rdynamic`),**正确排除** | + +**mcpp 自己产出的映像 `|EXPORTED| = 0`(分母 217)**,诊断在正常项目上静默。 +上表同时是这条规则的四点回归集:两个真阳性、一个真阴性、一个正确排除。 + +⚠️ 表里两处**推翻了第一轮的写法**: + +1. **ELF 类型不能当判据**。mcpp 的产物是 `EXEC`,而 `git`/`ls` 是 `DYN`(PIE)。 + 全仓 grep 没有任何 `-pie` / `-no-pie` / `-fPIE` ⇒ 这完全取决于载荷工具链的默认值, + 会在我们脚下变。判据是 **`PT_INTERP` 存在**,而 + `ElfRuntimeFacts::interp` 这个字段**已经在记它了**。 +2. **copy relocation 的判定必须按地址,不能按名字**。`environ` 是 `__environ` 的 + WEAK 别名,同址但没有以自己的名字出现在重定位表里 —— + 按名字判会在**每一个** mcpp 产物上误报 `environ`。 + +### 2.3 归因只在失败时付费 + +`CONFLICT` 的计算本身已经找出了**共享一侧**(closure 里那个也定义了该符号的对象, +经 `elf_runtime::resolve_runtime_closure` 的 `resolvedObjects`)。 +**静态一侧**再补一步:mcpp 知道自己把哪些 `.o` 喂进了这条链接边、 +每个 `.o` 属于哪个包(`CompileUnit::packageName`),只对冲突的那几个符号名读 `.symtab`。 + +⭐ 代价的形状:第一段是一次 `.dynsym` 遍历;第二段的输入集**实测是 0–7 个符号**, +乘以一个典型闭包(个位数对象)。清洁构建根本走不到第二段。 + +### 2.4 落点:`mcpp.build.runtime_validation` + +这个模块的开篇就是「validate only freshly linked Linux ELFs」—— +按 stat 快照决定是否重跑、判据持久化在 `build.ninja` 旁、doctor 能复读。 +D2 的作用域和它**逐字重合**,复用它的快照/缓存/持久化机制,不是新模块。 +⚠️ 但**不复用 `RuntimeVerdict::Status`** —— 那个四值枚举的两个 blocking 态回答的是 +「这个产物能不能启动」,而符号劫持的产物跑得好好的。理由与严重度见 §11.4。 + +`elf_runtime` 已经解析动态段、`DT_VERNEED`/`DT_VERDEF`、`PT_INTERP`,并解析出闭包 +(`resolvedObjects`)。新增的是 `.dynsym` 遍历(`DT_SYMTAB` + `DT_STRTAB` + +`DT_HASH`/`DT_GNU_HASH` 取表长)与 `.rela.dyn` 的 `R__COPY` 扫描。 + +### 2.5 D2 不覆盖什么 + +- **PE / Mach-O 不需要它**:PE 按 `(DLL 名, 符号)` 解析,Mach-O 默认 two-level + namespace。issue §5 的表已经说对了。 +- **`.so` 之间的重复**不在 D2 的谓词里(共享库本来就导出一切)。那是闭包里 + **SONAME 撞车**的问题,`resolve_runtime_closure` 已经看得见,属于后续。 + +--- + +## 3. D1:声明层,一个映像一种形态 + +D2 是核验,D1 是断言 —— 它在 plan 期、零 ELF 解析、全平台成立: + +> 一个链接映像里,同一个包解析出的形态必须唯一。 + +⭐ **D1 不是一个独立的检查器,它就是 §4 那个解析器的错误路径。** +`resolve_form()` 是总函数:对一个(包, 映像)只返回一个 `Form`; +两条边请求不同形态时它没有答案可返回,于是报错并指名两条边。 +把 D1 写成第二个遍历会立刻变成「同一个决定两处推导」—— +这个仓库的债本里已经有 #233/#240/#242/#344 四条同型。 + +⭐ **形态的解析单位是「映像」而不是「依赖边」。** issue §4.2 按边写 override, +但在 ELF 单一全局命名空间下,同一个库在同一个映像里出现两种形态**就是**这个诊断 +要抓的缺陷。所以:边上的 `linkage` 是一个**请求**,映像级必须收敛成一个答案。 + +--- + +## 4. 形态轴的设计 + +### 4.1 三层,照 `mcpp.build.distribution` 的既有骨架 + +`distribution.cppm` 已经把「Role × Format → Contract → Mechanism」这套分层做对过一次 +(总函数、表驱动、每个 effective ≠ requested 的格子都必须写诊断)。 +形态轴是同一个骨架的第二次实例,新模块 `mcpp.build.linkage_form`: + +| 层 | 名字 | 谁回答 | 来源 | +|---|---|---|---| +| 1 | **Admissible** 允许是什么(集合) | 引擎 | `admissible(pkg, format, libcLinkage)` —— **推导,从不询问**(4.2) | +| 2 | **Request** 想要什么 | 消费者 | `[build]` / `[profile.*]` / 依赖边(**两张表,不是三张** —— §12.4) | +| 3 | **`DepLinkage`** 最终是什么 | 引擎 | `resolve(admissible, request)`,**总函数**,**每映像一次** | + +⚠️ 枚举**不能**叫 `Form`:`mcpp.build.loader_contract` 已经导出 +`enum class Form { Executable, SharedLibrary, NotElf }`,而 `runtime_validation` +同时要读这两个(D2 在那里落地)。两个 `Form` 在同一处相遇,而它们**语义相近但不同** —— +一个是「这个产物是什么」,一个是「依赖以什么形态进来」。叫 `DepLinkage`, +与用户写的键 `dependency_linkage` 同名:一个概念,在 manifest、代码、诊断里是同一个词。 +(仓库里已经有 `Form` / `Format` / `Shape` / `Role` / `Contract` / `Binding` 六个近义名, +随手加第七个正是「口袋名」债的起点。) + +Mechanism(对象内联 / shared LinkUnit)**不是一层**,它是 emitter 的事,而且两条都已存在。 + +⚠️ **能力与约束合并成一个集合,是这轮 review 的第一处简化。** +初稿把它们分成两层,照抄了 `distribution.cppm` 的 Contract/Mechanism 分离 —— +但那里分开是因为诊断要区分「你要 X 我给了 Y」和「X 在这个平台没有机制」。 +这里两种否定的诊断文本是同一句(「这个包在这个平台只能是 static」), +分成两层只是把一次交集拆成两次判断。 + +### 4.2 Admissible 的推导,零新 key + +⚠️ **初稿的推导规则是错的**,而且索引里有现成的反例。 + +初稿写「mcpp 编译它的源码 ⇒ static + shared」。但 `compat.openssl` 同时有 +`sources = { "mcpp_openssl_anchor.c" }` **和** `ldflags = { "-Llib", "-l:libssl.a", … }` +—— 它是**混合形态**:一个 anchor TU 由 mcpp 编译,真正的实现是包内随附的 +`.a` 归档。把它做成 shared,等于把非 PIC 的 openssl 归档塞进 `.so`。 + +修正后的规则,判据是 `-L` 一个符号: + +> **一个包可以是 shared,当且仅当它对链接的全部贡献都是 mcpp 编译出来的对象。** +> `-L` 恰好是「本包携带了 mcpp 没有编译的链接输入」的标记。 +> 没有 `-L` 的 `-lm` / `-lpthread` / `-lws2_32` 是**宿主**系统库,`.so` 依赖它们是正当的。 + +实测(mcpp-index `8f5ad30`,130 个包): + +| | 数量 | 是谁 | +|---|---|---| +| 有 `ldflags` | 31 | | +| 其中含 `-L` | **4** | `compat.openssl`、`compat.mysql-connector-cpp`、`compat.openblas`、`compat.vulkan` | +| 其余 27 个的 `-l` | —— | 全部是系统库:`-lm -lpthread -ldl -lrt -latomic -lws2_32 -lbcrypt -ladvapi32 -lgdi32 -liphlpapi -lresolv -lodbc` … | + +四个里 `compat.vulkan` 本就是 `kind = "shared"`(约束),不走这条路。 +所以这条规则在 130 个包上**恰好挡住三个真正携带预构建归档的包,零误伤**。 + +⚠️ 判据要落在**解析后**的 ldflags(cfg 合并之后),且要认三种拼写: +`-Llib`、`-L lib` 两 token、`-Wl,-L…`。 + +完整的推导表: + +| 包形态 | Admissible | 判据来源 | +|---|---|---| +| mcpp 编译全部实现,ldflags 无 `-L` | `{static, shared}` | 有 `sources` ∧ 无 `-L` | +| ldflags 含 `-L` | `{static}` | 携带了 mcpp 没编译的链接输入 | +| 显式 `kind = "shared"` | `{shared}` | 约束(§1.2(b),12 个包) | +| **分发包**(`mcpp pack` 产出) | 随包的那些 role | `runtime.artifacts[].role ∈ {static-library, shared-library}` —— **已经在 manifest 里** | +| 目标格式无加载器(freestanding) | `{static}` | `ninja_backend.cppm:2383`:无 loader 就没有 shared 规则 | +| **整链 `-static`(libc 轴)** | `{static}` | ⚠️⚠️ **两根 `linkage` 轴不是独立的** —— 见下 | + +⚠️⚠️ **全静态 libc 让 shared 形态在物理上不可能**,这是第二轮才发现的跨轴耦合。 +`flags.cppm:815` 是 `full_static = (full_static_ok && linkage == "static") ? " -static" : ""`, +而 `prepare.cppm:2027` 让 **musl 工具链默认就是 `linkage = "static"`**。 +一个 `-static` 的可执行文件没有加载器,装不下任何 `.so`。 +⇒ 对 musl 用户,`dependency_linkage = "shared"` 的**默认路径**必须被拒绝并说明原因, +而不是产出一个链接不起来的图。 + +⭐ 这条耦合同时**消掉了一张表**:第一轮为「musl 要 static、gnu 要 shared」 +加了 `[target.].dependency_linkage`,而真正的答案是引擎自己知道 +musl 那条腿只能 static ——用户不需要写。见 §12.4。 + +⚠️ **Format 必须是 `capability()` 的入参**,不只是包 —— 这是 review 的第二处修正。 +`distribution.cppm` 的 `default_contract(Role, Format)` 早就是这个签名,初稿漏了。 +裸机三元组下 `dependency_linkage = "shared"` 必须被明确拒绝,而不是产出一个没有规则的边。 + +`pack/prebuilt.cppm:62` 的 `is_library_role` 已经在读这两个 role。 +**打包侧的能力集不需要新增任何 key。** + +### 4.3 「必须是 shared」/「必须是 static」的读法 + +按 §1.2(b) 的不对称读法。而「必须 static」**不需要新键**:实测索引里那三个包 +(`compat.openssl` / `mysql-connector-cpp` / `openblas`)全都因为携带 `-L` 而由 +§4.2 的规则自动落进 `{static}`。**约束是被测出来的,不是被声明的。** + +### 4.4 键的完整清单 —— 一个新键名,三张已有的表 + +`linkage` 已被 libc 轴占用(`[target.].linkage`,`docs/05` §2.7.1 已发布)。 +不动它。 + +```toml +[build] +dependency_linkage = "static" # 全图默认(缺省即 static);或 "shared" + +[profile.dev] +dependency_linkage = "shared" # 按 profile 覆盖 + +[target.x86_64-linux-musl] +dependency_linkage = "static" # 精确三元组覆盖,与 linkage/cxx_runtime/sysroot 并列 + +[dependencies] +"compat.zlib" = { version = "1.3.2", linkage = "shared" } # 单包请求(仅根 manifest) +``` + +- 全局键叫 `dependency_linkage`:说清了**谁的** linkage,不会被误读成 + 「我这个库怎么构建」(那是 `[targets.*].kind`)。长名与 + `macos_deployment_target` / `build_program_timeout` / `module_extensions` 同风格。 +- 边上仍叫 `linkage`:在 `[dependencies]` 表里没有歧义,且是 + Zig / Conan / vcpkg 的共同词汇。 + +⭐ **两个入口存在的理由不同,这是它们都该在的原因**: +`[build]` / `[profile.*]` 服务**开发回路**(shared 依赖让重链接变快), +边上的 `linkage` 服务**冲突消解**(D2 报了,我要把这一个包换个形态)。 +一个不是另一个的语法糖。 + +⭐ **两张表都是这类标量的既有归属,不是新机制:** + +| 表 | 为什么是它 | +|---|---| +| `[build]` | 加进 `toml.cppm:1190` 的 `kKnownBuildKeys`。⚠️ 该处注释记着:消息里的列表**就是**同一个列表(曾经手抄第三份并漂移过),所以只需改一处 | +| `[profile.*]` | ⭐ profile 的四个旋钮已经在 cache key 轴 C 里(`cache_key.cppm` `BuildAxes`),形态跟着它走,**缓存键问题自动关闭** | +| ~~`[target.]`~~ | ⚠️ **第二轮撤回**。第一轮按「与 `linkage`/`cxxRuntime`/`sysroot` 对称」加的,但它服务的唯一场景(musl 静态 / gnu 动态)已被 §4.2 的跨轴耦合自动答掉。我一边用「未出现的需求不入 schema」否掉白名单键,一边为对称性加了它 —— 见 §12.4 | + +⚠️ 无论哪张表,这个键都**不能**走 `cfg(...)` 通道:`types.cppm:159-165` +明文说标量需要 last-wins,而条件通道只做 append, +「conditioning a scalar is a different operation」。 + +#### 除此之外不需要任何键 —— 逐条对照 `docs/05` 附录 A + +附录 A 的准则:**「A key that duplicates an answer another section already gives is not admitted.」** + +| 候选键 | 判决 | 理由 | +|---|---|---| +| 包侧「我能是 shared」 | **不加** | §4.2 从 `sources` + `-L` 测得,零误伤(130 个包实测) | +| 包侧「我必须 static」 | **不加** | 同上,三个包自动落进 `{static}` | +| 包侧「我必须 shared」 | **不加** | `kind = "shared"` 已经说了(12 个包) | +| 分发包的能力集 | **不加** | `runtime.artifacts[].role` 已经说了。附录 A 点名库分发「added **zero** manifest keys」是范例 | +| D2 的严重度开关 | **不加** | 复用已有的 `--strict`(schema 警告已是这个约定) | +| 有意为之的符号插桩白名单(jemalloc 替换 `malloc`) | **暂不加** | 附录 A:未出现的需求不入 schema。默认是警告不是错误,用户可忽略;真出现了再按「①领域中立 ②1:1 desugar」审 | +| `pack --linkage` | **不是 manifest 键** | CLI 选项,批 F | +| `soname` | **不是新键** | 已存在,批 B1 只是放开它的校验(⚠️ 但有发布顺序约束,见 §11.3) | + +### 4.5 默认值 = `static` ⇒ 迁移判据是「零 diff」 + +今天的行为等价于「约束即形态」,而 117/130 个包的约束是 static。 +默认 `static` ⇒ **每一个既有工程的 `build.ninja` 逐字节不变**。 +这是可机器核验的迁移判据(归一化 diff build.ninja,`.agents/docs` 里 +`issue311` 那份用过同一方法)。 + +--- + +## 5. 构建期视角:必须一起改的三处 + +### 5.1 ⚠️ 这根轴把一个潜在缺陷变成可达的 + +`-fPIC` 今天由 `flags.cppm:444` **扫描 `plan.linkUnits`** 得出, +而 `cache_key.cppm` 的 A/B/C/D/E/F 六轴里**没有它**(E 取的是 manifest 的 cflags, +不是计算出来的 flags)。 + +今天这是良性的:一个包的形态固定,PIC 的差异只造成「多编了 PIC」或 +「静态对象进了 exe」,都不出错。 + +**加上这根轴之后就出错**:工程 A(无 shared)缓存了非 PIC 的 `compat.zlib`, +工程 B 请求 `linkage = "shared"` 命中同一条目 ⇒ 非 PIC 对象进 `.so` ⇒ +`relocation R_X86_64_32S … can not be used when making a shared object`。 + +**修法同时消掉一处重复推导**:PIC 由 `resolve_form()` 的结果一次决定, +落进 `BuildAxes`,`flags.cppm` 读它而不是再扫一遍 plan。 +(记忆:「同一个决定两处推导」是隐性架构债。) + +### 5.2 工程指纹 + +`dependency_linkage` 改变每一个对象与产物 ⇒ 必须进 +`FingerprintInputs::compileFlags`(决定 `target///` 的那个), +否则切换开关会复用上一次的构建目录。与 5.1 是两个不同的哈希,都要覆盖。 + +### 5.3 ⚠️⚠️ 合成的 shared 形态没有 ABI 名 —— 轴单独落地会让 §2 变得不可检测 + +这是本文最重要的一条,它决定了交付顺序。 + +`compat.zlib` 没有 `soname`(它是 `kind="lib"`)。请求 shared 之后,mcpp 会按 +**target 名**产出 `libzlib.so`。而 `libgio-2.0.so.0` 的 `DT_NEEDED` 写的是 +`libz.so.1`。于是: + +| 状态 | 进程里的 zlib | 有诊断吗 | +|---|---|---| +| 今天(混形态) | 一份(静态那份劫持了 gio) | **D2 能报** | +| 只加轴,请求 shared | **两份**(`libzlib.so` + vendor 的 `libz.so.1`) | **没有任何诊断**(两边都是 .so,exe 的 dynsym 干净) | +| 轴 + 正确 soname | 一份 | 干净 | + +⭐ 所以顺序是被推导出来的,不是偏好: +**D2 → `soname` 对任意 library target 生效 → 形态轴。** + +`soname` 今天不只是「没被读」——`types.cppm:1033` 的 +`validate_target_soname` **主动拒绝**它出现在非 shared 目标上 +(`soname is only valid for shared targets`)。批 B1 因此是三小处: +放开这个校验、`plan.cppm:427` 的别名生成、`ninja_backend.cppm:266` +的 `shared_soname_flag`(它已经在 `lu.soname` 为空时正确地不发标志)。 + +而 `soname` 是索引描述符里**唯一**必须新增的字段,且只有真正要参与 +第三方 `DT_NEEDED` 的包才需要写。 + +⚠️ 「正确的 soname 就一定赢」这一步**不能推理,要测**:vendor 包的 +`runtime_search_dirs` 经 `plan.cppm:669-676` 也变成 rpath 条目, +`$ORIGIN` 与它谁在前由 `link_line::UnitTail` 的槽位顺序决定。 +这正是 **#304** 的形状(路径遮蔽),两个 issue 在这里合流。 + +### 5.4 静态形态仍然是「对象内联」,不是归档 + +`plan.cppm:1491-1498` 记录了一次已被回退的尝试:把 `kind="lib"` 依赖做成 `.a`, +在 Windows/MSVC lld-link 上不可行(不会为入口点拉归档成员 → LNK1561; +归档改变了传递符号解析顺序 → libarchive→lzma LNK2019)。 + +⚠️ 于是 issue §2 的「放大器」一节里那条建议(改成归档懒选可缩小可劫持面) +是**被历史否决过的**。可劫持面确实会变小,但那不是这根轴该付的代价。 +D2 的谓词与内联/归档无关,不受影响。 + +--- + +## 6. 打包分发视角 + +### 6.1 ⚠️ `-L … -l` 是形态盲的 ⇒ 双形态包必须分目录 + +`pack/manifest_emit.cppm:241` 发的是: + +```toml +ldflags = ["-Llib/", "-l"] +``` + +同一个目录里同时存在 `lib.a` 与 `lib.so` 时,**ld 优先取 `.so`** —— +形态就变成了 ld 搜索规则的副产物,而不是一个决定。所以双形态包必须: + +- 分目录(`lib//static/` 与 `lib//shared/`),或 +- 用 `-l:lib.a` 的显式拼写。 + +前者更干净,且与既有的 per-triple 目录结构同型。 + +### 6.2 老客户端只读 ldflags ⇒ 双形态包的「默认形态」是一个发布决定 + +`manifest_emit.cppm:252-255` 的注释(作者实测): +「An older mcpp reads only the ldflags above and silently ignores this block」。 + +⇒ 一个双形态包,`ldflags` 那一行指向哪个形态,就是**所有老客户端拿到的形态**。 +建议固定为 static(与今天的默认一致),新客户端通过中立通道 +(`[target.'…'.runtime] link_library_dirs/libraries`)选另一个。 + +### 6.3 `mcpp pack` 今天是整包单形态 + +`LibraryPackPlan::targetShared` 是**整个包一个 bool**,legs 是按 **triple** 分的 +(胖包),不是按形态分的。要支持双形态,leg 的维度要从 `triple` 变成 +`(triple, form)`。这是打包侧唯一的结构性改动。 + +`LibraryLeg::shared` 已经是 per-leg 的,所以改动集中在 +「怎么产生 legs」而不是「怎么描述 legs」。 + +### 6.4 请求一个没随包的形态 ⇒ 复用 `check_prebuilt` 的拒绝路径 + +`pack/prebuilt.cppm` 的 ABI-tag 拒绝已经建立了「说清楚包里有什么、你要的是什么」 +的诊断形状。形态不匹配是同一类,复用它,不要新写一条。 + +### 6.5 索引描述符:零必需新键 + +- Admissible 从 `sources` + `-L` + `runtime.artifacts[].role` 推(全部已有); +- 「必须 shared」从 `kind = "shared"` 推(已有,12 个包); +- 「必须 static」从 `-L` 推(已有,实测三个包,零误伤); +- 唯一会写进描述符的 `soname` 是**可选**的,只有要参与第三方 `DT_NEEDED` 的包才写。 + +⚠️⚠️ 但 `soname` 不是「老客户端忽略它」——它会让老 mcpp **加载 manifest 直接失败**。 +见 §11.3,那是本设计里唯一一条有发布顺序约束的改动。 + +--- + +## 7. 分批 + +| 批 | 内容 | 独立价值 | 依赖 | +|---|---|---|---| +| **A** | **D2**:两段式谓词(§2.1,⚠️ 已被 §12.1/§12.2 修正两次)+ 归因 + 落进 `runtime_validation`(⚠️ 独立报告项,**不复用** `RuntimeVerdict::Status`,见 §11.4) | 把一个 latent ODR 问题变成可见的构建诊断。**issue 自己也建议先做这条** | 无 | +| **B1** | 引擎放开 `soname` 的非 shared 校验(`types.cppm:1033`) | 让「一个 SONAME 一个提供者」可表达 | 无 | +| **B2** | 索引描述符**写入** `soname` | 让 §5.3 的第三行成立 | ⚠️⚠️ B1 **已发布**且索引 `latest` 的 mcpp 下限跨过它(§11.3) | +| **C** | **D1 = `resolve_form()` 的错误路径** + `mcpp.build.linkage_form` 三层模型(Admissible 推导 + 默认 static) | 零行为改变(默认 static ⇒ build.ninja 零 diff),但把决定收敛到一处 | 无 | +| **D** | PIC 进 `BuildAxes` + 工程指纹;`flags.cppm` 改为读它 | 消掉一处重复推导 | C | +| **E** | `dependency_linkage`(`[build]` + `[profile.*]`)+ 边上 `linkage`(**仅根 manifest**,§11.3) | 轴可用 | C、D | +| **F** | `mcpp pack` 的 `(triple, form)` legs + 分目录 | 分发侧可用 | E | +| **G** | 依赖包的 `required_features` 门控(§1.2(a) 的独立缺陷) | 独立 | 无 | +| **H** | 文档:`docs/05` §2.2 「shared 仅 Linux/ELF」已过时(§11.5);`linkage` 两根轴的辨析框 | 独立 | 无 | + +A、B1、G、H 四批互不依赖,可并行开 PR。B2 是**发布顺序**问题而不是实现问题。 + +--- + +## 8. 判据(测试) + +⚠️ 按记忆里那两条:判据要带分母;判据的「否」不能与「没测成」同读数。 + +| # | 判据 | 形式 | +|---|---|---| +| 1 | 干净项目 `\|HIJACK\| = 0`,**并打印分母**(`0 of 217 dynamic symbols`) | e2e,`# requires: elf` | +| 2 | 复现 §2 的场景 ⇒ D2 **命中,且诊断里同时出现两个包名和两种形态** | e2e。⚠️ 判据是整行输出,不是子串 | +| 3 | 用户自己写了 `-rdynamic` ⇒ D2 **静默**(前置条件不成立) | 单测足够 | +| 4 | 默认 `static` 下,一组既有工程的归一化 `build.ninja` **逐字节不变** | 单测 + e2e | +| 5 | 同一映像两条边请求同一个包的不同形态 ⇒ **报错并指名两条边** | 单测(纯 `resolve_form` 表) | +| 6 | 请求 shared 的包,其对象的 `-fPIC` 必须来自 `BuildAxes` ⇒ **改 `dependency_linkage` 必须改缓存键** | 单测比对两个 key_hex | +| 7 | 分发包缺少被请求的形态 ⇒ 拒绝,**并列出包里实际有的 role** | e2e | +| 8 | 依赖包的 `required_features` 未满足 ⇒ 该 target **不出现在 plan 里** | 单测 | +| 9 | 一个 `ldflags` 含 `-L` 的包被请求 shared ⇒ **拒绝,并复述那条 `-L`** | 单测(`compat.openssl` 形状的 fixture) | +| 10 | 裸机三元组下请求 shared ⇒ **拒绝**,不是产出一条没有规则的边 | 单测(`resolve_form` 的 Format 入参) | +| 11 | 非根 manifest 的依赖边写了 `linkage` ⇒ **忽略并警告**,不改变形态 | 单测 | +| 12 | `dependency_linkage = "shared"` 的工程 `mcpp pack` ⇒ 产出的 bundle 里**有那些 `.so`,且 exe 能在解包后启动** | e2e | + +⚠️ 判据 1、2、7、12 要落在 e2e,而 shard 上的 `# requires:` 会静默跳过 +(记忆:`e2e-requires-llvm-never-runs-on-shards`)。守卫要写在 job 里。 + +--- + +## 9. 明确不做 + +- **不改 `[target.].linkage`**(libc 轴)。名字撞车用前缀解决,不动已发布语义。 +- **不做全闭包符号求交**。§2.1 的谓词只需要 exe 一侧,代价是一次 `.dynsym` 遍历。 +- **不把静态形态改成归档**。历史已否决(§5.4)。 +- **不做同一映像内同一个库的两种形态并存**。那是被诊断的缺陷本身,不是特性。 +- **PE / Mach-O 不实现 D2**。它们结构上没有这个问题。D1 仍然全平台生效。 +- **不动索引的 84 个 `kind = "lib"`**。 +- **不加符号插桩白名单键**。默认警告即可忽略,按附录 A 等真实需求出现(§4.4)。 +- **不认非根 manifest 的边上 `linkage`**(§11.3)。 +- **批 F(一个包同时发两种形态)暂缓**,直到有实证需求(§11.2)。 + +--- + +## 10. 与既有 issue 的关系 + +- **#304**(`runtime.library_dirs` 落在链接行造成路径遮蔽):§5.3 的最后一步与它合流 —— + 「正确的 soname 是否真的赢」取决于 rpath 槽位顺序。两个 issue 应该一起测。 +- **#493**(`[system_deps]` 经 pkg-config 引入宿主库):宿主库的形态完全不由 mcpp 决定, + D1 对它一无所知。**D2 是它唯一的判据**,而且不需要为它写任何新代码 —— + 这是「测量而非声明」这个选择最大的一笔回报。 + +--- + +## 11. 自我 review:五个视角 + +⚠️ 本节记录的是**这轮 review 推翻或补上的东西**,不是复述前文。 +已经写回正文的四条(§4.1 三层、§4.2 的 `-L` 规则、§4.2 的 Format 入参、§3 的 D1 归位) +在这里只列结论。 + +### 11.1 架构 + +| # | 发现 | 处置 | +|---|---|---| +| 1 | ⭐ **D1 不该是第二个遍历**,它是 `resolve_form()` 没有答案时的错误路径 | 已写回 §3 | +| 2 | ⭐ **五层过度分解**:Capability 与 Constraint 的两种否定诊断文本相同,合成一个 `Admissible` 集合 | 已写回 §4.1 | +| 3 | ⚠️ **Format 必须是 `capability()` 的入参**,`distribution.cppm` 的 `default_contract(Role, Format)` 早就是这个签名 | 已写回 §4.2 | +| 4 | ⚠️ **Form 必须挂在 `PackageRoot` 上,不能让 `make_plan` 再推一次**。今天 `plan.cppm:1474` 直接读 `t.kind` 来决定要不要建 shared 边;如果解析在 `prepare_build`、消费在 `make_plan` 而中间不传值,就又是一次「同一决定两处推导」 | 批 C 的实现约束 | +| 5 | ⚠️ **shared 依赖会改变 `mcpp pack` 的闭包**。`pipeline.cppm:171` 已经把第三方 `.so` 的搜索目录喂给 pack,但那两个通道(`plan.runtimeLibraryDirs` / `linkIntent.runtimeSearchDirs`)装的是**依赖包声明的目录**,而合成的 `bin/libzlib.so` 是**本次构建的 LinkUnit 输出**,不在其中。⚠️ 它大概率经 `$ORIGIN` 的 DT_NEEDED 闭包被捞到 —— 但这是推理不是测量,判据 12 就是为它写的 | **批 E** 前必须实测(shared 依赖在 E 落地时就存在,不等 F) | + +### 11.2 简洁 + +- **净新增概念:两个**(`Admissible` / `Form`)。D1、D2、PIC 轴、soname 全都挂在已有结构上: + `runtime_validation` 的快照机制、`BuildAxes` 的轴 C、`TargetEntry` 的标量位、 + `check_prebuilt` 的拒绝路径、`kKnownBuildKeys` 的一处列表。 +- **净新增 manifest 键:一个名字**(`dependency_linkage`),外加依赖边上的 `linkage`。 + 索引描述符侧**零必需新键**。 +- ⭐ 最省的一步仍然是 §1.2(b) 的不对称读法:它把「要改 84 个已发布描述符」 + 变成「一行代码都不用改」。 +- 反向自查:有没有哪一块是可以删掉而设计仍然成立的?**有一块** —— + §6.3 的 `(triple, form)` legs。它只服务「一个包同时发两种形态」, + 而这个需求今天没有实证。⚠️ 批 F 应该等到有人真的要它, + 按附录 A 的「未出现的需求不入 schema」同理处置。 + +### 11.3 兼容 —— 这轮最大的一处发现 + +⚠️⚠️ **`soname` 写进索引描述符会让所有老 mcpp 硬失败,不是被忽略。** + +`types.cppm:1030` 的 `validate_target_soname` 在 `kind != SharedLibrary` 时返回错误, +而两个解析器都把它变成**加载失败**: + +``` +src/manifest/toml.cppm:580 return std::unexpected(error(origin, *msg)); +src/manifest/xpkg.cppm:1483 return std::unexpected(ManifestError{*msg, …}); +``` + +这与记忆里那条实测同型(`provides` 硬失败 / `requires` 静默忽略)。 +⇒ **批 B 必须拆成 B1(引擎)/ B2(描述符),B2 的前置判据是索引 `latest` 的 +mcpp 下限跨过 B1 的发布**,而不是「B1 合入了」。这是一条发布顺序,不是实现顺序。 + +其余兼容项: + +| 项 | 结论 | 判据 | +|---|---|---| +| `[build] dependency_linkage` 出现在依赖的 mcpp.toml,被老 mcpp 读到 | ✅ 安全 | `toml.cppm:1219` 走 `schemaWarnings`,不是错误 | +| 边上的 `linkage` 出现在**非根** manifest | ⚠️ **必须只认根 manifest** | 一个中层包强推全图形态是供应链属性;`reexport` 的注释已经为同一形状写下过判决(「visibility 默认 public,riding it 会让任意深度的依赖悄悄改变消费者」)。而真正需要 shared 的中层包,应该在**自己的 target 上写约束**,不是在边上写请求 —— 请求通道只需要根级 | +| 默认值 | ✅ `static` ⇒ 既有工程 `build.ninja` 零 diff(判据 4) | +| 索引 84 个 `kind = "lib"` | ✅ 不动 | + +### 11.4 易用性 + +| # | 发现 | 处置 | +|---|---|---| +| 1 | ⚠️ **D2 不能复用 `RuntimeVerdict::Status`**。`ProvenMismatch` 字面上就是「两个运行期载荷被混在一起」,但它 `blocking()`;而 `/usr/bin/git`、`/usr/bin/ls` 形状的映像在真实世界大量存在(实测),第一天就 block 会把绿的构建变红。而且那个四值枚举的注释本身就在警告「把 Unresolvable 折进 Inconclusive」这类混淆 | **独立报告项**;默认警告,`--strict` 升级为错误 | +| 2 | ⚠️ **两个 `linkage` 的辨析必须进文档**。同一份 manifest 里 `[target.].linkage`(libc)与 `[build] dependency_linkage`(依赖)并存,是可预见的踩坑点 | 批 H:`docs/05` §2.7.1 加辨析框;`mcpp doctor` 一并打印两根轴的解析值 | +| 3 | 诊断的可操作性:issue §4.3 的文本给了两条出路。**要补第三条**(声明 soname 让两者统一),否则 vendor 包场景下用户无路可走 | 批 A 的文案 | +| 4 | 有意插桩(jemalloc 替换 `malloc`)会误报 | 默认是警告 ⇒ 可忽略。不加白名单键(§4.4) | + +### 11.5 跨平台 + +| # | 发现 | 处置 | +|---|---|---| +| 1 | ⚠️ **`docs/05` §2.2(:100-104)已经过时**:它写「shared 仅支持 Linux/ELF,macOS/Windows 会在 plan 前被拒」。而 `tests/e2e/257_shared_library_pe.sh` 的头注明确写着「Until now this was Linux-only… this test is a NEW CAPABILITY」,`259_shared_library_macho.sh` 同理 | 批 H,独立的文档缺陷 | +| 2 | ⚠️ **MinGW 的 `-Wl,-Bdynamic` 位置敏感**:`manifest_emit.cppm` 记着实测 —— mcpp 给 PE 可执行文件 `-static`,ld 进入 static-only 模式会拒绝导入库,标志必须**紧挨**它启用的那个 `-l`。合成的 shared 依赖在 MinGW 上会撞上同一条 | 批 E,复用 `link_line` 的槽位而不是再拼一次 | +| 3 | **MSVC ABI**:DLL 不 `dllexport` 就什么都不导出,mcpp 已有 `coff_exports` 自动生成 `.def`(e2e 258)。把一个从没打算做 DLL 的 C 库合成成 shared,会导出全部符号 —— 能用,但体积和链接时间要在文档里说清 | 批 E 的文档 | +| 4 | **freestanding**:`ninja_backend.cppm:2383` —— 没有 loader 就没有 shared 规则。见 §4.2 的 Format 入参与判据 10 | 已写回 | +| 5 | **D2 仅 ELF**,与 `runtime_validation` 的既有作用域一致;PE/Mach-O 结构上没有这个缺陷 | 已在 §2.5 | + +--- + +## 12. 第二轮自我 review:六个视角 + +⚠️ 本轮的规则:**不复述 §11**。只记这一轮**推翻或补上**的东西。 +本轮推翻了三条我自己在第一轮写下的判断,其中一条会让整条诊断在合法安排上误报。 + +### 12.1 ⚠️⚠️ D2 的谓词错了两次(架构 / 正确性) + +**(1) 「无版本 ⇒ 劫持」是一条依赖 libc 的启发式,不是判据。** + +第一轮用「已定义 dynsym 是否带 `@GLIBC_x`」区分劫持与 copy relocation。 +但**符号版本是 glibc 的特性,musl 完全没有**。mcpp 的 musl 目标是一等公民 —— +只是它默认 `-static`(§4.2)所以恰好没有 `.dynsym`,判据才没当场露馅。 +**「恰好不出错」不是正确。** + +替换成一条不依赖 libc 也不依赖架构的: + +| 符号类型 | 判定 | +|---|---| +| `STT_FUNC` 已定义 | **一定**是导出 —— copy relocation 永远不会是函数 | +| `STT_OBJECT` 已定义 | 查 `.rela.dyn` 里有没有 `R__COPY` 的 `r_offset == st_value` | + +⚠️ **必须按地址查,不能按名字。** 实测:`environ` 是 `__environ` 的 WEAK 别名, +两者同址,但重定位表里只有 `__environ` 那一条。按名字查会在**每一个** mcpp 产物上 +误报 `environ`。 + +四个产物的回归读数在 §2.2,新规则全部判对(mcpp 0 / git 1 / ls 7 / bash 排除)。 + +**(2) ⚠️⚠️ 只看 exe 一侧会在 mcpp 自己的合法安排上误报。** 见 §12.2。 + +### 12.2 ⚠️⚠️ mcpp 的 `kind = "shared"` 机制结构性地产出同一个形状(架构) + +读码:一个 shared 依赖的链接单元只拿**它自己**的对象 +(`plan.cppm:1618` `append_package_objects(lu, dep.packageName)`), +它的 shared 依赖走 `append_direct_shared_deps` —— 而它的 **static 依赖什么都不走**。 +那些对象进了**消费者的 exe**(它们不在 `sharedDepPackages` 里)。 +`ld -shared` 默认允许未定义符号,于是 `.so` 链接通过,运行期绑到 exe。 + +**这正是 §2 的形状,而且是 mcpp 自己排的。** + +⭐ 但它是**良性的**,而且是 load-bearing:进程里只有**一份**定义。 +换成「把 static 依赖也塞进 `.so`」反而会变成两份、两套状态。 + +⇒ 真正的判据不是「exe 导出了东西」,而是「exe 导出的东西**还有第二个提供者**」。 +§2.1 因此改成两段式。⭐⭐ 而这一改让 D2 与 §0 的不变量**逐字对齐**: +一个库,一个提供者。第一轮的谓词只是它的一个必要条件。 + +⚠️ 今天的索引躲过这一劫是**人口的巧合**,不是设计的性质: +12 个 shared 包唯一的 static 依赖是 `compat.xorgproto` / `compat.xtrans`, +而这两个的 `sources` 是 `mcpp_generated/xorgproto_empty.c` —— **空 TU**。 +换任何一个真有代码的 static 依赖进来,形状立刻实体化。 + +⭐ 附带的好处:**D2 的 e2e fixture 不需要手工组装 glib 包**(issue §7 那个)。 +三个 mcpp 包就能造出来:一个 `kind="shared"` 的 A、一个真有符号的 static B、 +一个也定义了 B 那些符号的 `kind="shared"` C。 + +### 12.3 ⚠️⚠️ 两根 `linkage` 轴不是独立的(跨平台 / 架构) + +第一轮把「名字撞车」当成纯粹的命名问题。它不是。 + +`flags.cppm:815` 全静态 libc 发 `-static`,而 `prepare.cppm:2027` +**让 musl 工具链默认就是 `linkage = "static"`**。一个 `-static` 可执行文件 +没有加载器,**装不下任何 `.so`** ⇒ 在 mcpp 最常见的 musl 路径上, +`dependency_linkage = "shared"` 物理不可能。 + +⇒ `admissible()` 的入参从 `(pkg, format)` 变成 `(pkg, format, libcLinkage)`。 +已写回 §4.1 / §4.2 与判据 13。 + +### 12.4 ⚠️ 我对自己的准则不一致(简洁) + +同一节里我用「未出现的需求不入 schema」否掉了插桩白名单键, +**又为对称性加了 `[target.].dependency_linkage`** —— 它没有实证需求, +只有「和 `linkage`/`cxxRuntime`/`sysroot` 并排好看」这个理由。 +而它想服务的场景(musl 静态 / gnu 动态)被 §12.3 的耦合自动答掉了。 + +**撤回。用户可写的入口从三个降到两个 + 一个边上的键。** + +### 12.5 ⚠️ 命名:`Form` 已经被占用(优雅 / 架构) + +`mcpp.build.loader_contract` 导出 `enum class Form { Executable, SharedLibrary, NotElf }`, +而 `runtime_validation.cppm:622` 正在用它 —— 那也正是 D2 要落地的地方。 +两个 `Form` 会在同一个翻译单元里相遇,而它们**语义相近但不同**。 + +改叫 **`DepLinkage`**,与用户写的 `dependency_linkage` 同名: +**一个概念,在 manifest、代码、诊断里是同一个词。** + +⭐ 仓库里已经有 `Form` / `Format` / `Shape` / `Role` / `Contract` / `Binding` +六个近义名。随手加第七个正是「口袋名」债的起点 —— 这次不是模块名,是类型名, +但同一条规则适用:**按职责命名,不按形状命名。** + +### 12.6 ⚠️ `PT_INTERP`,不是 ELF 类型(跨平台) + +实测:mcpp 的产物是 `ET_EXEC`,而 `/usr/bin/git`、`/usr/bin/ls` 是 `ET_DYN`(PIE)。 +全仓 grep **没有任何 `-pie` / `-no-pie` / `-fPIE`** ⇒ 这个属性完全来自载荷工具链的默认值, +会在我们脚下变,而且变的方向是「判据静默失效」—— +`ET_EXEC` 的检查在 PIE 世界里读数永远是「没有可执行文件要查」。 + +判据是 **`PT_INTERP` 存在**,而 `ElfRuntimeFacts::interp` **已经在记它**。 +(记忆:「判据写了、绿了、却从没跑到」——这是它的第 n 次。) + +### 12.7 易用性:诊断必须区分两种「导出」 + +有了两段式谓词,消息也分两层,而且**第一层默认不打印**: + +| 情形 | 说什么 | +|---|---| +| `EXPORTED` 非空、`CONFLICT` 空 | **什么都不说**。那是 §12.2 的合法安排 | +| `CONFLICT` 非空 | 报告:符号(截断 + 总数)、静态提供者(包)、共享提供者(文件 + 包)、**三条出路**(设 `dependency_linkage`、让其中一方不再提供、声明 soname 让两者统一) | + +⭐ 「第一层默认不打印」是这轮易用性上最重要的一条: +第一轮的设计会在每一个用了 `compat.x11` 且有真实静态依赖的项目上刷警告, +而用户对此**无事可做** —— 那正好是 `distribution.cppm` 的 +`explicitRequest` 注释警告过的东西:诊断是为**被打破的承诺**准备的。 + +### 12.8 优雅:这个设计的「一句话」现在能自我证明 + +> 不变量:**一个库,一个提供者,一种形态。** +> +> - **D1** = 在 plan 上求值:`|DepLinkage(pkg, image)| = 1` +> - **D2** = 在产物上求值:`EXPORTED(exe) ∩ ⋃ defines(closure) = ∅` +> +> **同一句话,两个论域。** + +第一轮的 D2 不满足这个形式(它只看一侧),所以两者只是「都有用」; +修正后它们是**同一条不变量的两次求值**。这才是这套设计该有的样子。 + +另一条可检查的优雅性判据 —— **输入里有几个是新的**: + +| `resolve()` 的输入 | 来源 | +|---|---| +| `sources` | 已有 | +| `ldflags` 里的 `-L` | 已有 | +| `targets.*.kind` | 已有 | +| `runtime.artifacts[].role` | 已有 | +| 目标格式 | 已有 | +| libc `linkage` | 已有 | +| **请求** | **1 个新键** | + +**六个已有 + 一个新的。** 这根轴不是往 manifest 里加信息, +它是**问一个从来没人被问过的问题**。 + +### 12.9 本轮新增的判据 + +| # | 判据 | 形式 | +|---|---|---| +| 13 | musl / 整链 `-static` 下请求 shared ⇒ **拒绝并说明是 libc 轴导致的**,不是产出一个链不起来的图 | 单测 | +| 14 | `environ` **不得**出现在任何 `EXPORTED` 里(WEAK 别名同址) | 单测,读真实产物 | +| 15 | PIE 产物(`ET_DYN` + `PT_INTERP`)与 `ET_EXEC` 产物 **走同一条路径** | 单测两个 fixture | +| 16 | `kind="shared"` A → static B(**B 有真实符号**),`CONFLICT` 空 ⇒ **静默** | e2e,§12.2 的合法安排 | +| 17 | 同上再加一个也定义 B 符号的 C ⇒ **报告,并同时指名 B 与 C** | e2e | +| 18 | 静态链接产物(无 `.dynsym`)⇒ 诊断**跳过而不是报 0**,两者读数必须可分 | 单测(记忆:「判据的否与没测成同读数」) | + +### 12.10 仍然没有答案的两个 + +1. ⚠️ **`mcpp pack` 能不能捞到合成的 `bin/lib*.so`**(§11.1 第 5 条)—— + 仍是推理。判据 12 覆盖它,但批 E 之前无法实测。 +2. ⚠️ **「正确的 soname 就一定赢」**(§5.3 末)—— 取决于 `$ORIGIN` 与依赖包 + `runtime_search_dirs` 在 `link_line::UnitTail` 里的相对次序,与 **#304** 合流。 + 两条都**不要写进设计当结论**,它们是待测项。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5795ba6e..8cd993f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,93 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.28.2] — 2026-08-28 + +依赖是静态并进来还是作为共享库放在旁边,以前由**包作者**定死,消费者没有任何 +发言权;而两个包同时提供同一个库时,没有任何机制说这是错的。完整分析见 +[`.agents/docs/2026-08-28-issue519-dependency-linkage-form.md`](.agents/docs/2026-08-28-issue519-dependency-linkage-form.md)。 + +> **一个库,一个提供者,一种形态。** 这条不变量在两个高度上执行: +> plan 期对 mcpp **决定**的东西,链接后对链接器**产出**的东西 —— +> 后者是唯一能看见 mcpp 从不知道其存在的那个库的高度。 + +### 新增 + +- **`[build] dependency_linkage`,以及依赖边上的 `linkage`。**(#519) + + ```toml + [build] + dependency_linkage = "shared" # 全图默认;缺省即 "static" + [dependencies] + "compat.zlib" = { version = "1.3.2", linkage = "shared" } + ``` + + `static`(默认)与 mcpp 一直以来的行为**逐字节相同**,不写这个键的工程 + 构建结果不变。也可按 `[profile.*]` 覆盖。 + + 形态是**每个链接映像解析一次**,不是每条依赖边一次 —— 同一个库在一个映像里 + 出现两种形态,正是本条要抓的缺陷。边上的 `linkage` 只在**根工程**生效: + 依赖图深处的包无权决定最终程序的布局。 + + ⚠️ **这不是 `[target.].linkage`**(那根是 C 库轴),而且两者**不独立**: + 整链静态的映像没有解释器,装不下任何共享对象。C 库静态链接的目标 —— + **musl 的默认** —— 会拒绝 `shared` 并说明原因。 + +- **符号提供者检查。** 链接后核验映像里每个符号恰好有一个提供者。 + + ⚠️ ELF 上可执行文件排在最前,被静态并进程序的库会在共享副本之上获胜: + 共享的那份永远不会被调用,那个库里的代码跑在一份它并非针对其链接的构建上。 + 链接器与加载器都不报任何一句话。实测 `/usr/bin/git`(自己的 `error` 遮蔽 + glibc 的 `error(3)`)与 `/usr/bin/ls`(gnulib obstack 遮蔽 glibc 的)都是 + 这个形状的野生实例。 + + ⭐ 判据是**测量**不是声明,因此对 vendor 包里随附的库、`[system_deps]` + 引入的宿主库同样有效 —— 引擎不需要认识任何具体的库。 + + ⭐⭐ **两段式,而第二段不可省。** mcpp 自己的 `kind = "shared"` 机制会 + **结构性地**产出「exe 导出、`.so` 绑过来」这个形状,而那是单份定义、 + 完全良性的。只看第一段会在正确的构建上刷警告,而用户对此无事可做。 + 真正的判据是「导出的东西**还有第二个提供者**」。 + + 默认警告,`--strict` 下升级为错误。判定写进 + `resolution.json` 的 `runtime.symbol_provision`,带计数与分母。 + +### 修复 + +- **⚠️ 依赖包的 `[targets.*] required_features` 从来没有生效过。** + + 目标门控只有一处,判据是**根**的活跃 feature 集,依赖包的 `targets` + 一个都不过滤。一个描述符写下 `required_features`,得到的是它要求的**反面**: + 该目标对每一个消费者都构建,不管 feature 开没开。 + + ⭐ 对 `kind = "shared"` 的目标这不是外观问题:包里只要存在任何一个 shared + 目标,它的**全部**对象就会从每个消费者的链接里被拿走。一个「可选」的目标 + 因此悄悄改变了整个包对所有人的链接方式。 + +- **⚠️ `-fPIC` 不在缓存键里。** + + 它是全图的(图里任何一个 shared 链接单元存在,所有对象都带 PIC),而键取的是 + 包**声明**的 flags,不是 flag 构造器算出来的。在形态由作者定死时可以幸存; + 一旦消费者能请求 shared,同一条目就会把非 PIC 对象喂给共享链接,而报错指向 + 一个没人改过的文件。现在由 `make_plan` **决定一次**,编译标志与缓存键读同一位。 + + `dependency_linkage` 同样进了工程指纹 —— 否则切换开关会复用上一次配置的 + 构建目录(实测:两次构建落在同一个 `target/x86_64-linux-gnu//`)。 + +- **⚠️ 在非 shared 目标上写 `soname` 会让整份 manifest 加载失败。** + + `soname` 是一个库被**找到**时用的名字,也是 mcpp 构建的那份与第三方携带的 + 同一个库能解析到同一个文件的唯一途径。收窄为「非 **library** 目标才拒绝」, + 可执行文件仍然不允许声明。 + + ⚠️ 因此把 `soname` 写进索引描述符要等 `latest` 的 mcpp 下限跨过本版本 —— + 旧客户端读到的是加载失败,不是忽略。 + +### 文档 + +- `docs/05` §2.2 「共享库目标只支持 Linux/ELF」已过时:PE 与 Mach-O 早已支持 + (`tests/e2e/257`、`259`)。中英双份同步更正。 + ## [2026.8.27.2] — 2026-08-27 一个文件名把整个 Windows 构建打断了,而报错说的是别的事。完整分析见 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 7c76e886..f8ed6770 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -97,11 +97,16 @@ downstream programs can load the library via its standard ABI name through `DT_NEEDED` or `dlopen()`. This field only applies to `kind = "shared"`, and the value must be a filename basename. -Shared-library targets are currently supported only for Linux/ELF targets. A -`kind = "shared"` target for macOS or Windows (including a cross build) is -rejected before planning because mcpp does not yet model Mach-O install names -or PE import libraries. Use `kind = "lib"` for a static library on those -targets, or build the shared library for Linux. +Shared-library targets work on all three binary formats. ELF gets a `.so` with +its `soname` and a `$ORIGIN` search path; Mach-O gets a `.dylib` whose install +name is `@rpath/`, so it survives being moved; PE gets both the `.dll` +the loader opens and the import library the linker consumes, with the export +list generated from the objects on the MSVC ABI (which exports nothing without +`__declspec(dllexport)` or a `.def`). See `tests/e2e/08`, `257` and `259`. + +A `soname` is meaningful on `kind = "lib"` too — see +[`dependency_linkage`](#dependency_linkage--static-or-shared-is-the-consumers-decision) +below, where the form a library takes becomes the consumer's decision. #### Per-target keys @@ -186,11 +191,98 @@ cxx_runtime = "self-contained" # C++ runtime contract (§ below); static_stdl target = "x86_64-linux-musl" # Default build target when no --target is passed # (≙ cargo build.target; e.g. "ship fully-static") macos_deployment_target = "14.0" # Minimum supported OS version for macOS artifacts (macOS only) +dependency_linkage = "static" # How dependencies arrive: static (default) | shared (§ below) cache = "global" # Global dependency cache: global (default) | local | off (§2.10) jobs = "auto" # Concurrent compiles: a positive number, or "auto" (§ below) bmi_schedule = "auto" # Module-edge scheduling: auto (= off) | on | off (§ below) ``` +#### `dependency_linkage` — static or shared is the consumer's decision + +```toml +[build] +dependency_linkage = "shared" # whole-graph default; "static" is the default default + +[profile.dev] +dependency_linkage = "shared" # per profile + +[dependencies] +"compat.zlib" = { version = "1.3.2", linkage = "shared" } # one package +``` + +Until mcpp 2026.8.28.2 a dependency had exactly one shape and the *package +author* chose it: `kind = "lib"` merged its objects into every consumer's link, +`kind = "shared"` produced a real shared library. That is the wrong owner for +the decision. Whether a library should be a separate file at run time is a +property of the **program being built** — how it is shipped, how often it is +relinked, whether something else in the process already provides that library. + +- **`static`** (default) — the dependency's objects are merged into the images + that use it. Byte-for-byte what mcpp has always done; a project that does not + write this key builds exactly as before. +- **`shared`** — mcpp builds the dependency as a shared library beside the + artifact and links against it, with `$ORIGIN` (ELF) / `@loader_path` (Mach-O) + / the executable's own directory (PE) finding it again after the build + directory moves. + +⚠️ **This is not `[target.].linkage`** (§2.7.1). That key answers the +same-sounding question about the **C library** (a musl `-static` link, MSVC's +`/MT`). The two are not independent, and the direction matters: a fully static +image has no interpreter, so it cannot load a shared object at all. On a target +whose C library is linked statically — which is the **default for musl** — +`dependency_linkage = "shared"` is refused, and says so. + +**A package can say it must be one form**, and only for a real reason: + +| The package writes | mcpp reads it as | +|---|---| +| `[targets.] kind = "shared"` | *must* be shared — something else in the process will `dlopen` it, so there may only be one copy (X11, a Vulkan loader) | +| `ldflags` containing `-L` | *must* be static — the package ships prebuilt archives mcpp did not compile and cannot place inside a shared object it builds | +| a packaged library (`mcpp pack`) | whichever legs it actually ships, from `[[runtime.artifacts]] role` | +| anything else | either form | + +`kind = "lib"` is **not** a constraint: it is the default value, and most +packages write it without choosing anything. Absence of a statement is not a +statement. + +A per-dependency `linkage` is honoured **only in the root project's** +`[dependencies]`. A package deep in the graph does not get to decide how the +final program is laid out; one that genuinely must be a single shared copy says +so on its own target instead. + +#### `soname` on a library target + +A `soname` (§2.2) may be declared on `kind = "lib"` as well as +`kind = "shared"`. It is the name a library is *found* by, and it is the only +way mcpp's build of a package and a third party's copy of the same library can +resolve to **one file** instead of two — which a package cannot state if +declaring it forces the package to stop being consumable as a static library. + +⚠️ A descriptor that writes `soname` on a non-shared target cannot be read by +mcpp releases before 2026.8.28.2 — the whole manifest fails to load, not just +the key. Publishing one to an index therefore waits for that floor to move. + +#### The symbol-provision check + +After a link, mcpp asks whether every symbol in the image has exactly **one** +provider. On ELF an executable is searched first, so a library statically +merged into the program wins for every symbol it shares with a shared library +loaded beside it — the shared copy is never called, and code inside that +library runs against a build it was not linked against. No linker or loader +diagnostic exists for this. + +The check is a measurement, not a declaration: it reads the produced image's +dynamic symbol table, removes the entries that are copy relocations, and +reports only those a library in the artifact's own closure **also** defines. +An arrangement with one copy in the process is silent. The verdict is recorded +in `target///resolution.json` under `runtime.symbol_provision`, +with the count and its denominator, so CI can read it without `readelf`. + +It is a warning by default and an error under `--strict`. Ways out, in the +order they usually apply: give the duplicated package a single form with +`dependency_linkage`; stop one side from providing the library; or give both +the same `soname` so they resolve to one file. + `private_include_dirs` names the entries **of `include_dirs`** that stop at this package's own boundary: this package compiles with them, and a consumer never receives them. diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 44579cd0..555f6040 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -94,10 +94,14 @@ soname = "libmylib.so.1" # 可选: Linux/ELF ABI 名称,运行时会生成同 让下游程序可通过标准 ABI 名称 `DT_NEEDED` 或 `dlopen()` 加载该库。 该字段只对 `kind = "shared"` 有效,值必须是文件名 basename。 -当前共享库目标只支持 Linux/ELF。面向 macOS 或 Windows 的 -`kind = "shared"` 目标(包括交叉构建)会在规划阶段直接拒绝,因为 mcpp -尚未建模 Mach-O install name 或 PE import library。若目标是这些平台,请使用 -`kind = "lib"` 构建静态库,或将共享库目标设为 Linux。 +共享库目标在三种二进制格式上都可用。ELF 产出带 `soname` 的 `.so` 与 `$ORIGIN` +搜索路径;Mach-O 产出 install name 为 `@rpath/` 的 `.dylib`,因此移动后 +仍能被找到;PE 同时产出加载器打开的 `.dll` 和链接器消费的 import library,并在 +MSVC ABI 上从对象生成导出表(该 ABI 没有 `__declspec(dllexport)` 或 `.def` 时 +不导出任何符号)。参见 `tests/e2e/08`、`257`、`259`。 + +`soname` 对 `kind = "lib"` 同样有意义 —— 见下文的 `dependency_linkage`, +库以何种形态出现是**消费者**的决定。 #### 按目标的键(per-target keys) @@ -174,11 +178,83 @@ ldflags = ["-lfoo"] # 额外链接参数 defines = ["BIZ=1", "QUX"] # 作用于每个 TU 的预处理宏(脱糖为 -D;会进入模块扫描) cxx_runtime = "self-contained" # C++ 运行时契约(见下节);static_stdlib 是旧拼写 macos_deployment_target = "14.0" # macOS 产物的最低支持系统版本(仅 macOS 生效) +dependency_linkage = "static" # 依赖以何种形态进入:static(默认)| shared(见下文) cache = "global" # 依赖的全局构建缓存:global(默认)| local | off(见 §2.10) jobs = "auto" # 并发编译数:正整数,或 "auto"(见下节) bmi_schedule = "auto" # 模块边调度:auto(= 关)| on | off(见下节) ``` +#### `dependency_linkage` —— 静态还是动态由消费者决定 + +```toml +[build] +dependency_linkage = "shared" # 全图默认;缺省即 "static" + +[profile.dev] +dependency_linkage = "shared" # 按 profile 覆盖 + +[dependencies] +"compat.zlib" = { version = "1.3.2", linkage = "shared" } # 单个包 +``` + +在 mcpp 2026.8.28.2 之前,一个依赖只有一种形态,而且由**包作者**定死: +`kind = "lib"` 把它的对象并进每个消费者的链接,`kind = "shared"` 产出真正的 +共享库。这个决定放错了位置。一个库在运行期该不该是独立文件,是**被构建的那个 +程序**的性质 —— 它怎么分发、多久重链一次、进程里是不是已经有人提供了这个库。 + +- **`static`**(默认)—— 依赖的对象并进使用它的映像。与 mcpp 一直以来的行为 + 逐字节相同;不写这个键的工程构建结果不变。 +- **`shared`** —— mcpp 把依赖构建成产物旁边的共享库并链接它,由 `$ORIGIN` + (ELF)/ `@loader_path`(Mach-O)/ 可执行文件自身目录(PE)保证构建目录 + 移动后仍能找到它。 + +⚠️ **这不是 `[target.].linkage`**(§2.7.1)。那个键回答的是听起来相同、 +实则关于 **C 库**的问题(musl 的 `-static`、MSVC 的 `/MT`)。两者并不独立,而且 +方向很重要:整链静态的映像没有解释器,根本装不下任何共享对象。因此在 C 库静态 +链接的目标上 —— 这是 **musl 的默认** —— `dependency_linkage = "shared"` 会被 +拒绝,并说明原因。 + +**包可以声明它必须是某一种形态**,而且只在确有理由时: + +| 包写了 | mcpp 读作 | +|---|---| +| `[targets.] kind = "shared"` | *必须* shared —— 进程里会有别人 `dlopen` 它,因此只能有一份(X11、Vulkan loader) | +| `ldflags` 里含 `-L` | *必须* static —— 包携带了 mcpp 没有编译的预构建归档,放不进 mcpp 自己构建的共享对象 | +| 分发包(`mcpp pack`) | 它实际随包的那些腿,取自 `[[runtime.artifacts]] role` | +| 其他 | 两种形态都可以 | + +`kind = "lib"` **不是**约束:它是默认值,大多数包写下它并没有做任何选择。 +**没有陈述不等于一条陈述。** + +依赖边上的 `linkage` 只在**根工程**的 `[dependencies]` 里生效。依赖图深处的包 +无权决定最终程序的布局;真正必须只有一份共享副本的包,应当在自己的 target 上 +声明。 + +#### library 目标上的 `soname` + +`soname`(§2.2)在 `kind = "lib"` 上同样可以声明。它是一个库被**找到**时用的 +名字,也是 mcpp 构建的那份与第三方携带的同一个库能解析到**同一个文件**的唯一 +途径 —— 而如果声明它就意味着这个包不能再作为静态库被消费,包就无法陈述这件事。 + +⚠️ 在非 shared 目标上写 `soname` 的描述符,**无法被 2026.8.28.2 之前的 mcpp 读取** +—— 失败的是整份 manifest,不只是这个键。因此把它发布进索引要等下限抬上去。 + +#### 符号提供者检查 + +链接之后,mcpp 会问:映像里的每个符号是不是**恰好有一个**提供者。在 ELF 上 +可执行文件排在最前,因此被静态并进程序的库,会在它与旁边加载的共享库共有的 +每个符号上获胜 —— 共享的那份永远不会被调用,而那个库里的代码跑在一份它并非 +针对其链接的构建上。链接器和加载器都不会为此报任何一句话。 + +这项检查是**测量**而不是声明:读产物的动态符号表,去掉 copy relocation,只报告 +产物自身闭包里**也**有定义的那些。进程里只有一份副本的安排保持静默。判定记录在 +`target///resolution.json` 的 `runtime.symbol_provision` 下,带计数 +与分母,CI 不需要 `readelf` 就能读。 + +默认是警告,`--strict` 下升级为错误。三条出路,按通常适用的顺序:用 +`dependency_linkage` 给重复的包一个统一形态;让其中一方不再提供这个库;或者给 +两者相同的 `soname`,让它们解析到同一个文件。 + `private_include_dirs` 指出 **`include_dirs` 中**在本包边界处停住的那些条目: 本包用它们编译,消费者永远收不到。 diff --git a/mcpp.toml b/mcpp.toml index 7643c004..1f0e9d02 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.28.1" +version = "2026.8.28.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/cache_key.cppm b/src/build/cache_key.cppm index e9b0ea32..fad8e517 100644 --- a/src/build/cache_key.cppm +++ b/src/build/cache_key.cppm @@ -141,6 +141,20 @@ struct BuildAxes { bool debug = false; bool lto = false; bool strip = false; + // ⚠️ Position-independent code, and it is here because it was MISSING. + // + // `-fPIC` is whole-build: one shared link unit anywhere and every object + // in the graph gets it. That was invisible to this key, which hashes a + // package's own declared flags rather than the ones the flag builder + // computes. Harmless while a package's form was fixed by its author — + // both configurations produced objects that only ever went into an + // executable. Not harmless once `[build] dependency_linkage = "shared"` + // exists: a project that asked for it can hit an entry another project + // wrote without it and hand non-PIC objects to a shared link. + // + // Decided by `make_plan` (BuildPlan::needsPic) and passed in, so the + // compiler flag and this key read the same bit. + bool pic = false; }; // Axes D/E/F for one package. @@ -195,7 +209,8 @@ BuildAxes build_axes(const mcpp::toolchain::Toolchain& tc, std::string_view cppStandardFlag, const std::vector& dialectFlags, std::string_view macosDeploymentTarget, - const std::filesystem::path& storeRoot = {}); + const std::filesystem::path& storeRoot = {}, + bool needsPic = false); // Axes E from one PackageRoot. `storeRoot` is stripped off absolute include // dirs so the key survives a different MCPP_HOME (the payload paths are @@ -265,6 +280,7 @@ nlohmann::json to_json(const BuildAxes& b, const PackageAxes& p) { {"debug", b.debug}, {"lto", b.lto}, {"strip", b.strip}, + {"pic", b.pic}, }; j["package"] = { {"index", p.indexName}, @@ -311,6 +327,7 @@ std::string key_hex(const BuildAxes& b, const PackageAxes& p) { put(s, "debug", b.debug ? "1" : "0"); put(s, "lto", b.lto ? "1" : "0"); put(s, "strip", b.strip ? "1" : "0"); + put(s, "pic", b.pic ? "1" : "0"); // D put(s, "index", p.indexName); put(s, "pkg", p.packageName); @@ -337,9 +354,11 @@ BuildAxes build_axes(const mcpp::toolchain::Toolchain& tc, std::string_view cppStandardFlag, const std::vector& dialectFlags, std::string_view macosDeploymentTarget, - const std::filesystem::path& storeRoot) + const std::filesystem::path& storeRoot, + bool needsPic) { BuildAxes b; + b.pic = needsPic; b.compilerId = std::string(tc.compiler_name()); b.compilerVersion = tc.version; // Same rule the whole-project fingerprint uses: prefer the declared driver diff --git a/src/build/flags.cppm b/src/build/flags.cppm index f0651a98..b1dc6af5 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -443,14 +443,11 @@ CompileFlags compute_flags(const BuildPlan& plan) { return t->is_pe(); return bool(mcpp::platform::is_windows); }(); - bool need_pic = false; - for (auto& lu : plan.linkUnits) { - if (lu.kind == LinkUnit::SharedLibrary) { - need_pic = true; - break; - } - } - std::string pic_flag = (need_pic && !isMsvcDialect && !peTarget) ? " -fPIC" : ""; + // READ, not re-derived. `make_plan` decides this once and the cache key + // hashes the same bit; a second scan here is how the compiler and the + // cache came to disagree about which objects they were talking about. + std::string pic_flag = + (plan.needsPic && !isMsvcDialect && !peTarget) ? " -fPIC" : ""; // Include dirs — this is the TYPED PATH channel (bare paths from the // manifest; the dialect prefix is applied here at emission), not the diff --git a/src/build/linkage_form.cppm b/src/build/linkage_form.cppm new file mode 100644 index 00000000..c14f7536 --- /dev/null +++ b/src/build/linkage_form.cppm @@ -0,0 +1,293 @@ +// mcpp.build.linkage_form — static or shared is the CONSUMER's question. +// +// WHAT THIS DECIDES +// +// mcpp used to give a dependency exactly one shape, chosen by the package +// author: `kind = "lib"` merged its objects straight into the consumer's link, +// `kind = "shared"` built a real shared library. The consumer had no say. That +// is the wrong owner for the decision — whether a library should be a separate +// file at run time is a property of the PROGRAM being built, not of the source +// it is built from — and it is why two packages could quietly supply the same +// library in two different shapes with nothing to say so (issue #519). +// +// THE INVARIANT THIS SERVES +// +// One library, one provider, one form. +// +// This module enforces it over what mcpp DECIDES. `mcpp.build.symbol_provision` +// enforces the same sentence over what the linker PRODUCES, which is the only +// altitude that can see a library mcpp never knew about. Two domains, one +// invariant. +// +// THREE LAYERS, AND THE FIRST ONE IS NEVER ASKED +// +// Admissible the set of forms a package can take here — DERIVED +// Request what the consumer wants — one new manifest key +// DepLinkage the answer — a total function of the two +// +// Every input to `resolve` except the request already existed in the manifest: +// `sources`, the `-L` in `ldflags`, `targets.*.kind`, +// `runtime.artifacts[].role`, the target format, the libc linkage. This axis +// does not add information to a manifest; it asks a question nobody was being +// asked. +// +// WHY THERE IS NO `Mechanism` LAYER +// +// Because there is nothing to write. A resolved `Shared` is materialised by +// setting the package's target kind, and every emitter mcpp already has — +// ELF soname and `$ORIGIN`, PE import library and auto-`.def`, Mach-O install +// name — then applies unchanged. Adding a fourth layer would mean writing a +// second copy of machinery that is already correct on three formats. +// +// Design: .agents/docs/2026-08-28-issue519-dependency-linkage-form.md §4. + +export module mcpp.build.linkage_form; + +import std; + +export namespace mcpp::build::linkage_form { + +// NOT `Form`. `mcpp.build.loader_contract` already exports +// `enum class Form { Executable, SharedLibrary, NotElf }`, and +// `runtime_validation` reads both — two spellings of nearly the same word +// meeting in one translation unit is how a reader stops trusting either. This +// is named after the key the user writes, so one concept has one word in the +// manifest, in the code and in the diagnostics. +enum class DepLinkage { Static, Shared }; + +std::string_view to_string(DepLinkage linkage); +std::optional parse(std::string_view value); + +// What a package is permitted to be, HERE — the intersection of what it can +// be and what it is constrained to be. +// +// A SET rather than two layers. The first draft separated "capability" from +// "constraint" by analogy with mcpp.build.distribution's Contract/Mechanism +// split, but that split exists there because the two refusals say different +// things ("you asked X and got Y" versus "X has no mechanism on this +// platform"). Here both refusals are the same sentence, so the distinction +// bought a second traversal and nothing else. +struct Admissible { + bool staticOk = true; + bool sharedOk = false; + // Why not, when `sharedOk` is false. Always populated in that case: a + // refusal a user cannot act on is worse than no feature. + std::string sharedRefusal; + + bool allows(DepLinkage linkage) const { + return linkage == DepLinkage::Static ? staticOk : sharedOk; + } +}; + +// Everything about ONE package that bears on the answer. All of it already +// exists in that package's manifest; this struct just names the subset. +struct PackageFacts { + std::string label; // "compat.zlib@1.3.2" — diagnostics only + + // mcpp compiles this package's own sources. + bool hasSources = false; + + // The author wrote `kind = "shared"`. Read as a CONSTRAINT ("this must be + // the only copy in the process"), because that is the only reason anyone + // has ever written it — a library another library will `dlopen`. + // + // ⚠️ The mirror image is NOT true: `kind = "lib"` is the parser's DEFAULT, + // written by 84 of 130 packages in mcpp-index as boilerplate. Reading it + // as "must be static" would freeze the entire ecosystem out of this axis. + // Absence of a constraint is not a constraint. + bool declaredShared = false; + + // The package's resolved `ldflags` name link inputs mcpp did not compile + // (see `carries_foreign_link_inputs`). + bool carriesForeignLinkInputs = false; + + // A package produced by `mcpp pack`: its forms are the ones it SHIPS, and + // no source exists to build another. + bool isDistribution = false; + bool shipsStatic = false; + bool shipsShared = false; +}; + +// Everything about the TARGET that bears on the answer. +struct TargetFacts { + // The target has a dynamic loader. False for a freestanding image, where + // there is no shared-library rule at all — nothing loads anything. + bool hasLoader = true; + + // The image links its C library statically (`-static`). A fully static + // executable has no interpreter and cannot load a shared object, so the + // libc axis and this one are NOT independent — a fact that is easy to + // miss because they are separate keys, and one that reaches the most + // common musl configuration, where `linkage = "static"` is the default. + bool fullStaticLibc = false; +}; + +// Does this flag list bring link inputs that mcpp did not compile? +// +// `-L` is the marker, and it is exact rather than heuristic: a package that +// ships prebuilt archives has to point the linker at them, and a package that +// merely names a HOST library (`-lm`, `-lpthread`, `-lws2_32`) does not. Over +// mcpp-index, 31 packages carry ldflags and exactly 4 carry `-L`; those 4 are +// precisely the ones with prebuilt binaries inside them. Making such a package +// shared would wrap somebody else's non-PIC archive in a shared object. +bool carries_foreign_link_inputs(std::span ldflags); + +Admissible admissible(const PackageFacts& package, const TargetFacts& target); + +// What the consumer asked for. +struct Request { + // `[build] dependency_linkage`, overridable by `[profile.*]`. + DepLinkage whole = DepLinkage::Static; + // Did a human write the whole-graph value, or is it just the default? + // Decides whether a refusal SPEAKS: mcpp promised nothing when nobody + // asked, and warning on every build about a default is noise. + bool wholeIsExplicit = false; + // Per-package, from the dependency edge. Keyed by the same label as + // `PackageFacts::label` and by the bare package name. + std::map> perPackage; +}; + +struct Resolution { + DepLinkage linkage = DepLinkage::Static; + // Non-empty exactly when the answer differs from an EXPLICIT request. + std::string diagnostic; +}; + +Resolution resolve(const PackageFacts& package, const Admissible& admissible, + const Request& request); + +// The one derivation of "does this build need position-independent code". +// +// ⚠️ It used to be a scan of the finished plan for a shared link unit, in +// `flags.cppm`, and it was ABSENT FROM THE CACHE KEY. That was survivable +// while a package's form was fixed by its author; it stops being survivable +// the moment a consumer can ask for the shared form, because the same cache +// entry then serves non-PIC objects to a link that puts them in a shared +// object — a hard `relocation R_X86_64_32S ... can not be used when making a +// shared object` on an input nobody edited. +// +// Deciding it here, from the resolved forms, is what lets the key carry it. +bool needs_pic(std::span resolved, bool anyOwnSharedTarget); + +} // namespace mcpp::build::linkage_form + +namespace mcpp::build::linkage_form { + +std::string_view to_string(DepLinkage linkage) { + return linkage == DepLinkage::Shared ? "shared" : "static"; +} + +std::optional parse(std::string_view value) { + if (value == "static") return DepLinkage::Static; + if (value == "shared") return DepLinkage::Shared; + return std::nullopt; +} + +bool carries_foreign_link_inputs(std::span ldflags) { + // Three spellings, because all three reach the linker: `-Llib`, the + // two-token `-L lib`, and `-Wl,-Llib`. A bare `-L` as the last element is + // still an intent to add a search path even though its argument is + // missing, so it counts. + for (auto const& flag : ldflags) { + if (flag.starts_with("-L")) return true; + if (flag.starts_with("-Wl,-L")) return true; + if (flag.starts_with("-Wl,--library-path")) return true; + // MSVC-dialect spelling, for a package written against that ABI. + if (flag.starts_with("/LIBPATH:")) return true; + } + return false; +} + +Admissible admissible(const PackageFacts& package, const TargetFacts& target) { + // Order matters and is the order of the diagnostic: a reason the user + // could not have changed by editing the package comes first, because it + // is not the package they need to look at. + if (!target.hasLoader) { + return Admissible{ .staticOk = true, .sharedOk = false, + .sharedRefusal = "this target has no dynamic loader, so there is " + "nothing that could load a shared library" }; + } + if (target.fullStaticLibc) { + return Admissible{ .staticOk = true, .sharedOk = false, + .sharedRefusal = "this image links its C library statically " + "(`linkage = \"static\"`), and a static " + "executable has no interpreter to load a shared " + "library with" }; + } + + if (package.isDistribution) { + // A packaged library has no source to build the other form from. Its + // admissible set is exactly what is inside it, which its own manifest + // already records as `[[runtime.artifacts]] role`. + Admissible out; + out.staticOk = package.shipsStatic; + out.sharedOk = package.shipsShared; + if (!out.sharedOk) + out.sharedRefusal = std::format( + "{} is a packaged library and ships only a static leg", + package.label); + return out; + } + + if (package.declaredShared) + return Admissible{ .staticOk = false, .sharedOk = true }; + + if (!package.hasSources) { + return Admissible{ .staticOk = true, .sharedOk = false, + .sharedRefusal = std::format( + "{} builds none of its own sources, so mcpp has no objects to " + "make a shared library from", package.label) }; + } + if (package.carriesForeignLinkInputs) { + return Admissible{ .staticOk = true, .sharedOk = false, + .sharedRefusal = std::format( + "{} brings its own prebuilt link inputs (its `ldflags` carry a " + "`-L`), which mcpp cannot place inside a shared library it " + "builds", package.label) }; + } + return Admissible{ .staticOk = true, .sharedOk = true }; +} + +Resolution resolve(const PackageFacts& package, const Admissible& admissible, + const Request& request) { + // A per-package request is always explicit — someone wrote it on the edge. + bool explicitRequest = request.wholeIsExplicit; + DepLinkage wanted = request.whole; + if (auto it = request.perPackage.find(package.label); + it != request.perPackage.end()) { + wanted = it->second; + explicitRequest = true; + } + + if (admissible.allows(wanted)) return Resolution{ .linkage = wanted }; + + // Not allowed. There is exactly one other form, and the admissible set is + // never empty by construction — `staticOk` is false only for a package the + // author constrained to shared, and that case allows Shared. + const DepLinkage fallback = admissible.sharedOk ? DepLinkage::Shared + : DepLinkage::Static; + Resolution out{ .linkage = fallback }; + // SPEAK ONLY FOR A BROKEN PROMISE. When the whole-graph value is mcpp's + // own default, nobody asked for anything and there is nothing to report; + // saying so on every build would put a warning on correct manifests that + // their authors cannot act on. Same rule mcpp.build.distribution applies + // to its contract defaults. + if (explicitRequest && wanted != fallback) { + out.diagnostic = std::format( + "{} is linked as a {} library: {}", + package.label, to_string(fallback), + admissible.sharedRefusal.empty() + ? std::string("the requested form is not available here") + : admissible.sharedRefusal); + } + return out; +} + +bool needs_pic(std::span resolved, bool anyOwnSharedTarget) { + if (anyOwnSharedTarget) return true; + return std::ranges::any_of(resolved, [](DepLinkage linkage) { + return linkage == DepLinkage::Shared; + }); +} + +} // namespace mcpp::build::linkage_form diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 8055e0d3..3c95c10c 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -2521,6 +2521,29 @@ std::expected NinjaBackend::build(const BuildPlan& plan mcpp::ui::warning(finding.explain()); } stage("loader-tags"); + // Issue #519 — one library, one provider. Reported through `diag` + // rather than `ui::warning` so that `--strict` promotes it in the one + // place that policy lives, and so the record is deduplicated across + // the several images a workspace build produces. + // + // NOT an error by default. The arrangement it detects exists in the + // wild in artifacts nobody considers broken (/usr/bin/git exports its + // own `error` over glibc's), and mcpp's own `kind = "shared"` + // dependencies can produce a benign near-miss, so a hard gate on its + // first release would stop builds that are fine. + for (auto const& finding : + mcpp::build::runtime_validation::check_symbol_provision( + plan, runtimeBefore)) { + if (!finding.report.actionable()) continue; + mcpp::diag::degraded( + "build/symbol-provision", + finding.report.explain(finding.artifact.filename().string()), + "the shared library's own copy of those symbols is never " + "called; it runs against the definitions inside this " + "executable instead", + "see docs/05 `[build] dependency_linkage`"); + } + stage("symbol-provision"); if (opts.verbose && !out.empty()) std::fputs(out.c_str(), stdout); std::set want(opts.ninjaTargets.begin(), opts.ninjaTargets.end()); diff --git a/src/build/plan.cppm b/src/build/plan.cppm index a6f110ef..33f487ef 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -176,6 +176,18 @@ struct BuildPlan { // readers, one derivation. Deriving it separately in the backend and in the // executor is how the BMI-equivalence check and the job count drifted into // disagreeing about what a module edge is. + // Does this build need position-independent code? + // + // ONE DERIVATION, THREE READERS — the flag assembly, the whole-project + // fingerprint, and the per-package cache key. It used to be derived by + // `flags.cppm` alone, by scanning the link units, and the other two did + // not know about it at all. That was survivable only while a package's + // form was fixed by its author: once a consumer can ask for the shared + // form (`[build] dependency_linkage`), the same cache entry can serve + // non-PIC objects to a link that puts them inside a shared object, and + // the failure is `relocation R_X86_64_32S ... can not be used when making + // a shared object` on a file nobody edited. + bool needsPic = false; std::string scheduleTag = "none"; // What to hand ninja. Under detach-codegen a compiler stops holding a slot // when it publishes, so this must exceed the real compiler cap or the ready @@ -1812,6 +1824,14 @@ make_plan(const mcpp::manifest::Manifest& manifest, plan.linkUnits.push_back(std::move(lu)); } + // The single derivation. Deliberately at the END of make_plan, after every + // producer of a link unit has run: a dependency resolved to the shared + // form arrives as an ordinary SharedLibrary unit, so this one predicate + // covers the package's own shared targets and its dependencies' alike. + for (auto const& lu : plan.linkUnits) { + if (lu.kind == LinkUnit::SharedLibrary) { plan.needsPic = true; break; } + } + return plan; } diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 7c385064..32db6885 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -46,6 +46,7 @@ import mcpp.freestanding.linkline; // the ISA profile, for the std module comman import mcpp.toolchain.post_install; import mcpp.toolchain.abi; import mcpp.toolchain.triple; +import mcpp.build.linkage_form; // #519 — which form each dependency takes import mcpp.build.plan; import mcpp.build.schedule.policy; import mcpp.build.flags; // compute_flags — the per-role contracts (#418) @@ -1449,6 +1450,12 @@ prepare_build(bool print_fingerprint, // payloads ship without the LTO plugin; enable via [profile.dist].) else { pr.optLevel = "2"; } // release if (auto it = m->profiles.find(pname); it != m->profiles.end()) pr = it->second; + // #519 — a profile may override the whole-graph form. OPTIONAL, so a + // profile that does not mention it leaves `[build]` standing; a plain + // value would reset it, because the block above REPLACES `pr` wholesale + // with the declared profile. + if (pr.dependencyLinkage) + m->buildConfig.dependencyLinkage = *pr.dependencyLinkage; m->buildConfig.optLevel = pr.optLevel; m->buildConfig.debug = pr.debug; m->buildConfig.lto = pr.lto; @@ -5746,6 +5753,34 @@ prepare_build(bool print_fingerprint, it != pkg.manifest.featureRequires.end()) for (auto& cap : it->second) capRequires.emplace_back(cap, pcap); } + // `[targets.*] required_features` on a DEPENDENCY. + // + // ⚠️ THIS GATE EXISTED ONLY FOR THE ROOT. The root's targets are + // filtered further down against the root's own active features; + // a dependency's were never filtered at all, so a descriptor that + // wrote `required_features` on a target got the opposite of what + // it asked for: the target was built for EVERY consumer, whether + // or not the feature was active. For a `kind = "shared"` target + // that is not a cosmetic difference — its mere presence changes + // how the whole package is linked into every consumer. + // + // Gated against THIS package's active set, not the root's. A + // feature name is package-scoped, so the root's set is a different + // vocabulary that happens to share a type. + // + // ⚠️ ONE EXCEPTION, and it is not a second rule: a target + // requested as a host tool is what was ASKED FOR, so its + // `required_features` become that sub-build's inputs instead of a + // gate (docs/05 §2.2). That path re-enters prepare_build with the + // dependency as the ROOT, so it never reaches this code. + std::erase_if(pkg.manifest.targets, + [&](const mcpp::manifest::Target& t) { + for (auto const& rf : t.requiredFeatures) + if (std::find(active.begin(), active.end(), rf) == active.end()) + return true; + return false; + }); + for (auto& f : active) { auto def = "-DMCPP_FEATURE_" + sanitize(f); pkg.manifest.buildConfig.cflags.push_back(def); @@ -7705,6 +7740,111 @@ prepare_build(bool print_fingerprint, } } + // ── #519: which FORM does each dependency take in this build ──────────── + // + // The decision itself lives in `mcpp.build.linkage_form`, which is a pure, + // table-driven function with no filesystem and no manifest knowledge. What + // happens here is only the two halves that need this scope: collecting the + // facts, and MATERIALISING the answer. + // + // ⭐ MATERIALISED AS A TARGET KIND, on purpose. A dependency resolved to + // the shared form becomes an ordinary `SharedLibrary` target, so every + // emitter mcpp already has applies to it unchanged — the ELF soname and + // `$ORIGIN`, the PE import library and generated `.def`, the Mach-O + // install name. That is the whole reason this axis needs no new backend + // code on any of the three formats. It also means `make_plan` READS the + // answer instead of deriving it a second time. + { + namespace lf = mcpp::build::linkage_form; + + lf::Request request; + if (auto parsed = lf::parse(m->buildConfig.dependencyLinkage)) + request.whole = *parsed; + request.wholeIsExplicit = !m->buildConfig.dependencyLinkage.empty(); + // ⚠️ ONLY THE ROOT MANIFEST'S EDGES. See DependencySpec::linkage — a + // package deep in the graph imposing a whole-image layout on its + // consumer is a supply-chain property, not a convenience. + for (auto const& [depName, spec] : m->dependencies) { + if (spec.linkage.empty()) continue; + if (auto parsed = lf::parse(spec.linkage)) { + request.perPackage[depName] = *parsed; + auto shortKey = spec.shortName.empty() ? depName : spec.shortName; + request.perPackage.emplace(shortKey, *parsed); + } + } + // A non-root edge that writes the key gets its request IGNORED, and + // says so — a silently dropped knob is how a knob becomes decoration. + for (std::size_t i = 1; i < packages.size(); ++i) + for (auto const& [depName, spec] : packages[i].manifest.dependencies) + if (!spec.linkage.empty()) + mcpp::diag::warning("build/dependency-linkage", std::format( + "'{}' asks for dependency '{}' to be linked as '{}'; only " + "the root project decides link forms, so this is ignored", + packages[i].manifest.package.name, depName, spec.linkage)); + + lf::TargetFacts targetFacts; + if (auto t = mcpp::toolchain::triple::parse(tc->targetTriple)) + targetFacts.hasLoader = !t->is_freestanding(); + // The libc axis. Spelled exactly as `compute_flags` spells it, because + // the two must agree about what `-static` means: an image linked that + // way has no interpreter, so no shared object can ever be loaded into + // it. Two keys with `linkage` in the name, and they are NOT independent. + targetFacts.fullStaticLibc = + m->buildConfig.linkage == "static" + && mcpp::toolchain::target_supports_full_static( + tc->targetTriple, mcpp::platform::supports_full_static); + + std::set packagesWithSources; + for (auto const& unit : scan.graph.units) + packagesWithSources.insert(unit.packageName); + + for (std::size_t i = 1; i < packages.size(); ++i) { + auto& pkg = packages[i].manifest; + const std::string fq = pkg.package.namespace_.empty() + ? pkg.package.name + : std::format("{}.{}", pkg.package.namespace_, pkg.package.name); + + lf::PackageFacts facts; + facts.label = std::format("{}@{}", fq, pkg.package.version); + facts.hasSources = packagesWithSources.contains(fq) + || packagesWithSources.contains(pkg.package.name); + facts.carriesForeignLinkInputs = + lf::carries_foreign_link_inputs(pkg.buildConfig.ldflags); + facts.isDistribution = mcpp::pack::is_distribution_package(pkg); + for (auto const& artifact : pkg.runtimeConfig.artifacts) { + if (artifact.role == "static-library") facts.shipsStatic = true; + if (artifact.role == "shared-library") facts.shipsShared = true; + } + std::vector libraryTargets; + for (auto& t : pkg.targets) { + if (t.kind == mcpp::manifest::Target::SharedLibrary) + facts.declaredShared = true; + if (t.kind == mcpp::manifest::Target::Library) + libraryTargets.push_back(&t); + } + + // The request is addressed by whatever the root wrote, so both + // spellings are looked up. `resolve` takes the label; give it the + // one the request can match. + lf::PackageFacts addressed = facts; + for (auto const& key : { fq, pkg.package.name }) { + if (request.perPackage.contains(key)) { addressed.label = key; break; } + } + auto allowed = lf::admissible(addressed, targetFacts); + auto answer = lf::resolve(addressed, allowed, request); + + if (!answer.diagnostic.empty()) + mcpp::diag::degraded("build/dependency-linkage", answer.diagnostic, + "this dependency is linked in the other form, which changes " + "whether its code travels inside the images that use it"); + + if (answer.linkage != lf::DepLinkage::Shared) continue; + if (facts.isDistribution) continue; // nothing here to build + for (auto* t : libraryTargets) + t->kind = mcpp::manifest::Target::SharedLibrary; + } + } + auto planResult = mcpp::build::make_plan(*m, *tc, fp, scan.graph, report.topoOrder, packages, *root, ctx.outputDir, stdBmiPath, stdObjectPath, storeRoots); @@ -8323,7 +8463,12 @@ prepare_build(bool print_fingerprint, // The GLOBAL registry root — the same one `fill_package_config` // relativizes against below, so both halves of the key describe // payload paths the same way. - storeRoots.empty() ? std::filesystem::path{} : storeRoots.front()); + storeRoots.empty() ? std::filesystem::path{} : storeRoots.front(), + // The bit `make_plan` decided and `compute_flags` emits. Reading + // it here rather than re-deriving is what keeps the objects a + // cache entry HOLDS and the objects a build ASKS FOR describable + // by one sentence. + ctx.plan.needsPic); // Sources belonging to each package, package-root-relative and sorted. std::vector> pkgSources(packages.size()); diff --git a/src/build/prepare_inputs.cppm b/src/build/prepare_inputs.cppm index 2a9eae1c..b0add588 100644 --- a/src/build/prepare_inputs.cppm +++ b/src/build/prepare_inputs.cppm @@ -272,6 +272,21 @@ std::string canonical_compile_flags(const mcpp::manifest::Manifest& m) { s += " debug="; s += m.buildConfig.debug ? "1" : "0"; s += " lto="; s += m.buildConfig.lto ? "1" : "0"; s += " strip="; s += m.buildConfig.strip ? "1" : "0"; + // #519 — the same reasoning as the profile knobs above, one axis later. + // The REQUEST is folded in rather than the derived `-fPIC`, because this + // string is built before the plan exists; the request is what a user + // edits and the flag is a function of it. Without this, flipping + // `dependency_linkage` reuses the previous configuration's output + // directory — measured on a two-package fixture, where both builds landed + // in `target/x86_64-linux-gnu/5d4a4a8a584ba471/` and the shared build's + // `libcore.so` was left sitting in the static build's `bin/`. + // + // Only appended when non-empty, so every existing build directory keeps + // its identity and this release rebuilds nothing. + if (!m.buildConfig.dependencyLinkage.empty()) { + s += " deplinkage="; + s += m.buildConfig.dependencyLinkage; + } return s; } diff --git a/src/build/runtime_validation.cppm b/src/build/runtime_validation.cppm index f80a171a..e7bc1f8a 100644 --- a/src/build/runtime_validation.cppm +++ b/src/build/runtime_validation.cppm @@ -11,6 +11,7 @@ export module mcpp.build.runtime_validation; import std; import mcpp.build.loader_contract; import mcpp.build.plan; +import mcpp.build.symbol_provision; import mcpp.manifest; import mcpp.libs.json; import mcpp.platform; @@ -121,6 +122,25 @@ std::vector check_and_record_loader_tags(const mcpp::build::BuildPlan& plan, const ArtifactSnapshot& before); +// One image's answer to "is every symbol provided once" (issue #519). +struct SymbolProvisionFinding { + std::filesystem::path artifact; + mcpp::build::symbol_provision::Report report; +}; + +// Evaluate the symbol-provision invariant on the images this run produced. +// +// ⚠️ A SEPARATE ENTRY POINT rather than more of validate_changed_artifacts, +// and the reason is a gate rather than tidiness: that function returns early +// unless the runtime binding's provider is glibc, because everything it checks +// is glibc closure physics. This check is ELF physics — a dynamically linked +// musl image has exactly the same flat namespace — so inheriting that gate +// would make it silently never run there. Same snapshot, same recording, its +// own applicability. +std::vector +check_symbol_provision(const mcpp::build::BuildPlan& plan, + const ArtifactSnapshot& before); + } // namespace mcpp::build::runtime_validation namespace mcpp::build::runtime_validation { @@ -683,6 +703,185 @@ check_and_record_loader_tags(const mcpp::build::BuildPlan& plan, return findings; } +std::vector +check_symbol_provision(const mcpp::build::BuildPlan& plan, + const ArtifactSnapshot& before) { + namespace sp = mcpp::build::symbol_provision; + std::vector findings; + if constexpr (!mcpp::platform::is_linux) return findings; + + // The flags this build hands the linker, as ONE vector, because the + // question is whether ANY of them took the export decision away from + // mcpp. Per-unit flags join below; these are the whole-build ones. + std::vector globalFlags = plan.manifest.buildConfig.ldflags; + + auto searchDirs = runtime_search_dirs(plan); + + // Symbol tables of closure objects, parsed at most once per file. Several + // images in one build share almost their whole closure. + std::map> closureCache; + auto defines_of = [&](const std::filesystem::path& object) + -> const std::vector& { + auto it = closureCache.find(object); + if (it != closureCache.end()) return it->second; + std::vector names; + if (auto symbols = mcpp::platform::elf::inspect_dynamic_symbols(object)) { + names.reserve(symbols->defined.size()); + for (auto const& symbol : symbols->defined) names.push_back(symbol.name); + std::ranges::sort(names); + } + return closureCache.emplace(object, std::move(names)).first->second; + }; + + for (auto const& [artifact, oldStamp] : before) { + auto now = stamp(artifact); + if (!now.exists) continue; + // Only images this run actually produced. An unchanged artifact was + // judged by an earlier run and re-parsing it every build would make a + // no-op build pay for a symbol-table walk it cannot learn from. + if (now == oldStamp) continue; + + // Which link unit is this? Its own flags matter as much as the + // global ones, and the static side of any report is attributed from + // the objects it links. + const mcpp::build::LinkUnit* unit = nullptr; + for (auto const& lu : plan.linkUnits) { + if ((plan.outputDir / lu.output).lexically_normal() == artifact) { + unit = &lu; + break; + } + } + + auto facts = mcpp::platform::elf::inspect_elf_runtime(artifact); + if (!facts) continue; // not ELF: no flat namespace + // ⚠️ PT_INTERP, not the ELF type. A PIE executable is ET_DYN, exactly + // like a shared library, and whether mcpp's toolchain emits PIE is the + // payload compiler's default — mcpp passes neither -pie nor -no-pie. + // Keying on ET_EXEC would make this check read "nothing to inspect" + // the day that default flips, which is indistinguishable from "clean". + // An interpreter is what makes an image a program the loader starts. + if (facts->interp.empty()) continue; + + std::vector flags = globalFlags; + if (unit) flags.insert(flags.end(), + unit->linkFlags.begin(), unit->linkFlags.end()); + if (sp::export_dynamic_requested(flags)) { + findings.push_back({artifact, sp::not_applicable( + "the link requests exported dynamic symbols")}); + continue; + } + + auto symbols = mcpp::platform::elf::inspect_dynamic_symbols(artifact); + if (!symbols) { + findings.push_back({artifact, sp::not_evaluated(symbols.error())}); + continue; + } + if (!symbols->present) { + findings.push_back({artifact, sp::not_applicable( + "statically linked: there is no dynamic symbol table")}); + continue; + } + auto exported = sp::exported_definitions(*symbols); + if (!exported) { + findings.push_back({artifact, sp::not_evaluated( + "this machine's copy-relocation type is not known to mcpp")}); + continue; + } + + sp::Report report; + report.total = symbols->total; + report.exported = exported->size(); + if (exported->empty()) { + report.status = sp::Status::Clean; + findings.push_back({artifact, std::move(report)}); + continue; + } + + // Stage two. Only reached when the image exports something, which a + // normal build does not. + auto resolution = mcpp::platform::elf::resolve_runtime_closure( + artifact, plan.runtimeBinding, searchDirs); + std::vector providers; + for (auto const& object : resolution.objects) { + if (object.artifact == artifact) continue; + auto const& names = defines_of(object.artifact); + if (names.empty()) continue; + providers.push_back(sp::Provider{ + .label = object.artifact.string(), + .defines = names, + }); + } + report.conflicts = sp::conflicting_exports(*exported, providers); + report.status = report.conflicts.empty() ? sp::Status::Clean + : sp::Status::Conflict; + findings.push_back({artifact, std::move(report)}); + } + + // Record, for the same reason the loader-tag contract records: a warning + // scrolls past, and `resolution.json` is what CI, `mcpp why runtime` and a + // test can read. It also gives a test a FIELD to assert on instead of a + // substring of a message — a message whose wording is free to improve. + if (!findings.empty()) { + const auto path = plan.outputDir / "resolution.json"; + nlohmann::json resolution; + { + std::ifstream input(path); + resolution = nlohmann::json::parse(input, nullptr, false); + } + if (!resolution.is_discarded() && resolution.is_object()) { + if (auto runtime = resolution.find("runtime"); + runtime != resolution.end() && runtime->is_object()) { + nlohmann::json entries = nlohmann::json::array(); + for (auto const& finding : findings) { + std::error_code ec; + auto rel = std::filesystem::relative( + finding.artifact, plan.outputDir, ec); + nlohmann::json entry{ + {"path", (ec ? finding.artifact : rel) + .lexically_normal().generic_string()}, + {"status", std::string( + sp::to_string(finding.report.status))}, + // Always both numbers. `exported: 0` is only evidence + // when `dynamic_symbols` is beside it. + {"exported", finding.report.exported}, + {"dynamic_symbols", finding.report.total}, + }; + if (!finding.report.reason.empty()) + entry["reason"] = finding.report.reason; + if (!finding.report.conflicts.empty()) { + nlohmann::json conflicts = nlohmann::json::array(); + for (auto const& conflict : finding.report.conflicts) + conflicts.push_back({ + {"symbol", conflict.name}, + {"kind", conflict.isFunc ? "func" : "object"}, + {"also_provided_by", conflict.alsoProvidedBy}, + }); + entry["conflicts"] = std::move(conflicts); + } + entries.push_back(std::move(entry)); + } + (*runtime)["symbol_provision"] = std::move(entries); + + std::error_code ec; + auto tmp = path; + tmp += ".tmp"; + if (std::ofstream output(tmp); output) { + output << resolution.dump(2) << '\n'; + output.close(); + std::filesystem::rename(tmp, path, ec); + if (ec) { + ec.clear(); + std::filesystem::remove(path, ec); + ec.clear(); + std::filesystem::rename(tmp, path, ec); + } + } + } + } + } + return findings; +} + std::optional latest_stored_verdict(const std::filesystem::path& targetRoot) { std::error_code ec; diff --git a/src/build/symbol_provision.cppm b/src/build/symbol_provision.cppm new file mode 100644 index 00000000..f54fe065 --- /dev/null +++ b/src/build/symbol_provision.cppm @@ -0,0 +1,243 @@ +// mcpp.build.symbol_provision — is every symbol in this image provided ONCE? +// +// THE INVARIANT +// +// One library, one provider, one form. +// +// mcpp enforces it at two altitudes, because it has two kinds of knowledge. +// `mcpp.build.linkage_form` enforces it over what mcpp DECIDED — packages, +// targets, forms — at plan time, on every platform. This module enforces it +// over what the linker actually PRODUCED, and that is the only altitude that +// can see a library mcpp never knew about: one vendored inside a prebuilt +// package, one arriving through pkg-config, one belonging to the host. +// +// WHY AN IMAGE CAN HIJACK A LIBRARY IT DID NOT MEAN TO +// +// ELF has one flat symbol namespace and the executable is searched first. When +// an executable statically contains `inflate` and a shared object it links +// declares an undefined `inflate`, the linker adds the executable's definition +// to `.dynsym` so that reference will bind — no `-rdynamic` required, and mcpp +// passes none. At run time the shared object then calls the STATIC copy, and +// whatever `libz.so.1` sits beside it is dead weight. The link is silent, the +// loader is silent, and the program usually works. +// +// The mechanism is not a defect; it is what makes `malloc` interposition work. +// What was missing is anyone asking whether it happened on purpose. +// +// THE PREDICATE, IN TWO STAGES +// +// EXPORTED = defined entries of .dynsym, minus copy relocations +// CONFLICT = EXPORTED ∩ ⋃ defines(closure) +// +// Stage one is cheap and runs on every changed image; it is empty for a normal +// mcpp binary (measured: 0 of 217 dynamic symbols). Stage two runs only when +// stage one is not, and it is the stage that makes the report TRUE. +// +// ⚠️ STAGE TWO IS NOT OPTIONAL, and the reason is mcpp's own doing. A +// `kind = "shared"` dependency's link unit receives only ITS OWN objects +// (mcpp.build.plan), so a static package underneath it lands in the CONSUMER's +// executable instead, and the shared library binds back to it at run time. +// That is the shape above — arranged by mcpp, with exactly one copy of the +// code in the process, and completely benign. Reporting stage one alone would +// warn about a correct build that the user cannot do anything about, which is +// precisely the noise `mcpp.build.distribution` refuses to emit. +// +// Design: .agents/docs/2026-08-28-issue519-dependency-linkage-form.md §2. + +export module mcpp.build.symbol_provision; + +import std; +import mcpp.platform.elf_runtime; + +export namespace mcpp::build::symbol_provision { + +// A definition this image contributes to the process-wide namespace. +struct Export { + std::string name; + bool isFunc = false; +}; + +// One object that could also supply a symbol, as the report will name it. +struct Provider { + std::string label; // a path, or " (static, merged)" + std::vector defines; // symbol names it defines +}; + +// A symbol with more than one provider in one image. +struct Conflict { + std::string name; + bool isFunc = false; + std::vector alsoProvidedBy; +}; + +// FOUR-VALUED, and the last two are why. +// +// Clean the predicate applies, was evaluated, and held +// Conflict a symbol has two providers and the static one wins +// NotApplicable the predicate does not apply here (a static link, a shared +// library, an image whose author asked for exports) +// NotEvaluated it applies but could not be computed +// +// `NotApplicable` and `NotEvaluated` exist separately from `Clean` for the +// reason this repository keeps rediscovering: a check that reports "no +// findings" when it never ran is a check that goes green forever. +enum class Status { Clean, Conflict, NotApplicable, NotEvaluated }; + +std::string_view to_string(Status status); + +struct Report { + Status status = Status::NotApplicable; + // |EXPORTED| and the size of the whole dynamic symbol table. Both are + // reported, always: "0" means nothing without the denominator beside it. + std::size_t exported = 0; + std::size_t total = 0; + std::vector conflicts; + // Why, for the two non-answers. Empty for Clean and Conflict. + std::string reason; + + bool actionable() const { return status == Status::Conflict; } + // Human-readable body. Empty unless there is something to say. + std::string explain(std::string_view artifact) const; +}; + +// Did this link ask for its symbols to be exported? Then the whole predicate +// is void: the author requested exactly the thing it detects. +// +// A FUNCTION over the flags rather than a boolean threaded from far away, so +// the vocabulary is written down once and can be tested without a linker. +bool export_dynamic_requested(std::span flags); + +// Stage one. Every defined FUNC counts; a defined OBJECT counts unless a copy +// relocation sits at its address (libc data moved into this image). +// +// Returns nullopt when `symbols.copyRelocationsKnown` is false: without the +// machine's COPY relocation type every data symbol would look like a +// conflict, and answering wrongly is worse than declining. +std::optional> +exported_definitions(const mcpp::platform::elf::DynamicSymbols& symbols); + +// Stage two. `closure` is every object the image's loader will consult. +std::vector conflicting_exports(std::span exports, + std::span closure); + +// The report an image with no dynamic symbol table gets, and the one an image +// whose author asked for exports gets. Named constructors rather than raw +// struct literals so every non-answer carries its reason. +Report not_applicable(std::string reason); +Report not_evaluated(std::string reason); + +} // namespace mcpp::build::symbol_provision + +namespace mcpp::build::symbol_provision { + +std::string_view to_string(Status status) { + switch (status) { + case Status::Clean: return "clean"; + case Status::Conflict: return "conflict"; + case Status::NotApplicable: return "not-applicable"; + case Status::NotEvaluated: return "not-evaluated"; + } + return "not-evaluated"; +} + +bool export_dynamic_requested(std::span flags) { + // Spellings, not a substring sweep: `--export-dynamic-symbol=foo` and + // `--dynamic-list=x.txt` restrict the export set rather than requesting + // one wholesale, but either way the author has taken over the decision. + static constexpr std::string_view kExact[] = { + "-rdynamic", "-export-dynamic", "--export-dynamic", + "-Wl,--export-dynamic", "-Wl,-export-dynamic", "-Wl,-E", + }; + static constexpr std::string_view kPrefixes[] = { + "-Wl,--dynamic-list", "--dynamic-list", + "-Wl,--export-dynamic-symbol", "--export-dynamic-symbol", + }; + for (auto const& flag : flags) { + for (auto exact : kExact) if (flag == exact) return true; + for (auto prefix : kPrefixes) if (flag.starts_with(prefix)) return true; + } + return false; +} + +std::optional> +exported_definitions(const mcpp::platform::elf::DynamicSymbols& symbols) { + if (!symbols.copyRelocationsKnown) return std::nullopt; + std::vector out; + for (auto const& symbol : symbols.defined) { + // A copy relocation is never a function, so the address lookup is + // only asked about data — which also keeps a FUNC that happens to + // share an address with relocated data from being excused. + if (!symbol.isFunc && symbols.copyRelocations.contains(symbol.value)) + continue; + out.push_back(Export{ .name = symbol.name, .isFunc = symbol.isFunc }); + } + std::ranges::sort(out, {}, &Export::name); + return out; +} + +std::vector conflicting_exports(std::span exports, + std::span closure) { + std::vector out; + for (auto const& exported : exports) { + Conflict conflict{ .name = exported.name, .isFunc = exported.isFunc }; + for (auto const& provider : closure) { + if (std::ranges::find(provider.defines, exported.name) + != provider.defines.end()) + conflict.alsoProvidedBy.push_back(provider.label); + } + if (!conflict.alsoProvidedBy.empty()) out.push_back(std::move(conflict)); + } + return out; +} + +Report not_applicable(std::string reason) { + return Report{ .status = Status::NotApplicable, .reason = std::move(reason) }; +} + +Report not_evaluated(std::string reason) { + return Report{ .status = Status::NotEvaluated, .reason = std::move(reason) }; +} + +std::string Report::explain(std::string_view artifact) const { + if (status != Status::Conflict || conflicts.empty()) return {}; + + // Cap the list. The names are evidence, not an inventory — a zlib pulled + // in twice contributes 86 of them, and a diagnostic nobody finishes + // reading is a diagnostic nobody acts on. + constexpr std::size_t kShown = 6; + std::string body = std::format( + "{}: {} symbol{} in this image {} also provided by a library it loads.\n", + artifact, conflicts.size(), conflicts.size() == 1 ? "" : "s", + conflicts.size() == 1 ? "is" : "are"); + + std::set providers; + for (std::size_t i = 0; i < conflicts.size(); ++i) { + auto const& conflict = conflicts[i]; + for (auto const& label : conflict.alsoProvidedBy) providers.insert(label); + if (i < kShown) + body += std::format(" {}{}\n", conflict.name, + conflict.isFunc ? "()" : ""); + else if (i == kShown) + body += std::format(" ... and {} more\n", conflicts.size() - kShown); + } + + body += " Also provided by:\n"; + for (auto const& label : providers) body += std::format(" {}\n", label); + + // WHY it matters, in the terms the reader can check, then what to do. The + // three ways out are not alternatives to taste: the first changes which + // form mcpp builds, the second removes a provider, the third makes the two + // providers the same file. + body += + " The executable is searched first, so the copy inside it wins for\n" + " every symbol both provide — the library's own copy is never called,\n" + " and code inside that library now runs against a build it was not\n" + " linked against.\n" + " Ways out: give the duplicated package a single form with\n" + " `[build] dependency_linkage` or a per-dependency `linkage`; stop one\n" + " side from providing it; or give both the same SONAME so they resolve\n" + " to one file."; + return body; +} + +} // namespace mcpp::build::symbol_provision diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index 81a8822f..c20773fa 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -377,6 +377,9 @@ std::expected parse_string(std::string_view content, for (auto& v : it->second.as_array()) if (v.is_string()) out.push_back(v.as_string()); }; + if (auto it = tt.find("dependency_linkage"); + it != tt.end() && it->second.is_string()) + pr.dependencyLinkage = it->second.as_string(); read_list("cflags", pr.cflags); read_list("cxxflags", pr.cxxflags); read_list("ldflags", pr.ldflags); @@ -711,7 +714,8 @@ std::expected parse_string(std::string_view content, || k == "features" || k == "default-features" || k == "workspace" || k == "visibility" || k == "backend" || k == "tools" - || k == "host-module" || k == "reexport"; + || k == "host-module" || k == "reexport" + || k == "linkage"; }; // What makes a table an inline dep spec is that it names a SOURCE. This // used to be "every key is known", which quietly coupled two unrelated @@ -779,6 +783,21 @@ std::expected parse_string(std::string_view content, section, fqName))); } } + // #519: `linkage = "shared"` — this consumer wants THIS dependency as a + // separate shared library rather than merged into its own images. + // + // The bare word is unambiguous inside a dependency table (it is the + // vocabulary Zig, Conan and vcpkg all use on the edge), while the + // whole-graph default has to spell out `dependency_linkage` because + // `[target.].linkage` already means the C library there. + if (auto it = sub.find("linkage"); it != sub.end() && it->second.is_string()) { + spec.linkage = it->second.as_string(); + if (spec.linkage != "static" && spec.linkage != "shared") { + return std::unexpected(error(origin, std::format( + "[{}.\"{}\"] linkage must be 'static' or 'shared'", + section, fqName))); + } + } if (auto it = sub.find("features"); it != sub.end() && it->second.is_array()) { for (auto& fv : it->second.as_array()) if (fv.is_string()) spec.features.push_back(fv.as_string()); @@ -1218,6 +1237,18 @@ std::expected parse_string(std::string_view content, if (auto v = doc->get_string("build.default-profile")) m.buildConfig.defaultProfile = *v; else if (auto v = doc->get_string("build.profile")) m.buildConfig.defaultProfile = *v; // accepted alias if (auto v = doc->get_string("build.cache")) m.buildConfig.cacheMode = *v; + // #519. Validated HERE rather than in prepare_build because the vocabulary + // is closed and owned by mcpp: unlike `cache`, whose values interact with + // a build mode resolved much later, "static" and "shared" are the whole + // domain, and a typo that reaches the resolver would silently mean + // "static". + if (auto v = doc->get_string("build.dependency_linkage")) { + if (*v != "static" && *v != "shared") + return std::unexpected(error(origin, std::format( + "[build] dependency_linkage = '{}' is invalid; expected " + "'static' or 'shared'", *v))); + m.buildConfig.dependencyLinkage = *v; + } // [xlings] — build environment (L-1). Subsections mirror .xlings.json 1:1. if (auto v = doc->get_string_array("xlings.deps")) m.xlings.deps = *v; @@ -1246,6 +1277,7 @@ std::expected parse_string(std::string_view content, static constexpr std::string_view kKnownBuildKeys[] = { "allow_host_libs", "bmi_schedule", "build_program_timeout", "c_standard", "cache", "cflags", "cxxflags", "cxx_runtime", "default-profile", "defines", + "dependency_linkage", "dialect_cxxflags", "flags", "include_dirs", "include_dirs_after", "private_include_dirs", "jobs", "ldflags", "macos_deployment_target", "module_extensions", "profile", diff --git a/src/manifest/types.cppm b/src/manifest/types.cppm index 031d35a7..9d3ec6a0 100644 --- a/src/manifest/types.cppm +++ b/src/manifest/types.cppm @@ -589,6 +589,20 @@ struct BuildConfig : BuildInputs { // footgun): a project that defaults to dev should pass `--profile release` // when producing a distributable (a pack-time release guard is a follow-up). std::string defaultProfile; + // `[build] dependency_linkage` — "static" (default) | "shared" (#519). + // + // How this build wants its DEPENDENCIES to arrive: merged into the images + // that use them, or as separate shared libraries beside them. A separate + // axis from `[target.].linkage`, which answers the same-sounding + // question about the C LIBRARY — and they are not independent, because a + // statically linked image cannot load a shared object at all + // (mcpp.build.linkage_form). + // + // A SCALAR, so it is deliberately absent from the `cfg(...)` channel: + // that channel appends, and this needs last-wins. Overridable per profile + // and per dependency edge. Empty = "static", which is byte-for-byte what + // mcpp did before the key existed. + std::string dependencyLinkage; // `[build] cache` — "global" (default) | "local" | "off". Project-level // default for the global dependency cache; --cache and MCPP_BUILD_CACHE // both override it. Validated in prepare_build (unknown value: warning, or @@ -911,6 +925,14 @@ struct Profile { bool debug = false; bool lto = false; bool strip = false; + // `dependency_linkage`, per profile (#519). + // + // OPTIONAL, and that is load-bearing rather than stylistic: resolving a + // profile REPLACES the whole struct with the declared one, so a plain + // value would make `[profile.dev] opt = 0` silently reset a + // `[build] dependency_linkage = "shared"` back to the field default. + // Absent means "whatever [build] said". + std::optional dependencyLinkage; // Passthrough escape hatch (fixed keys, open values — I6 completeness): std::vector cflags; std::vector cxxflags; @@ -1137,8 +1159,24 @@ bool is_basename(std::string_view value) { std::optional validate_target_soname(const Target& t, std::string_view targetPath) { if (t.soname.empty()) return std::nullopt; - if (t.kind != Target::SharedLibrary) { - return std::format("{}soname is only valid for shared targets", targetPath); + // A LIBRARY may declare one, whatever form it is built in. + // + // This used to require `kind = "shared"`, which read as tidiness and was + // in fact a constraint on the ecosystem: a `soname` is the name a library + // is FOUND BY, and it is the only thing that lets mcpp's build of a + // package and a third party's copy of the same library resolve to one + // file rather than two. A package cannot state that unless it can write + // the name down while still being consumed as a static library — which is + // the normal case (mcpp-index: 84 `kind = "lib"` against 12 `"shared"`). + // + // ⚠️ RELAXED RATHER THAN MOVED: the old spelling made the whole manifest + // FAIL TO LOAD, in both parsers. Any descriptor that starts writing this + // key is therefore unreadable by every mcpp released before this change, + // so the ecosystem-side rollout is gated on the index's floor moving — + // see .agents/docs/2026-08-28-issue519-dependency-linkage-form.md §11.3. + // The engine accepting it is what makes that gate start counting down. + if (t.kind != Target::Library && t.kind != Target::SharedLibrary) { + return std::format("{}soname is only valid for library targets", targetPath); } if (!is_basename(t.soname)) { return std::format("{}soname must be a library basename, got '{}'", diff --git a/src/platform/elf_runtime.cppm b/src/platform/elf_runtime.cppm index d4af87a9..7a6592cd 100644 --- a/src/platform/elf_runtime.cppm +++ b/src/platform/elf_runtime.cppm @@ -128,6 +128,52 @@ struct RuntimeVerdict { std::expected inspect_elf_runtime(const std::filesystem::path& artifact); +// One entry of a dynamic symbol table that this object DEFINES. +struct DynamicSymbol { + std::string name; + // STT_FUNC (or STT_GNU_IFUNC). Load-bearing rather than informational: a + // copy relocation moves DATA out of a shared library into the image that + // links it, so it is never a function. A defined FUNC in an executable's + // dynamic symbol table therefore has exactly one cause — the linker + // exported it so that some shared object's reference would bind to it. + bool isFunc = false; + std::uint64_t value = 0; // st_value; the key a copy relocation matches +}; + +// What an ELF object's DYNAMIC symbol table says it provides. +// +// Deliberately not folded into ElfRuntimeFacts: every caller of +// inspect_elf_runtime would then pay for a symbol-table walk, and the runtime +// closure check — which runs on every changed artifact — does not need one. +struct DynamicSymbols { + // GLOBAL/WEAK entries with st_shndx != SHN_UNDEF. + std::vector defined; + // The whole table, including undefined imports. This is the DENOMINATOR a + // report quotes: "0 of 217" and "not measured" must not read the same. + std::size_t total = 0; + // Addresses named by an R__COPY relocation. + // + // ADDRESS, not name, and that distinction is measured rather than + // reasoned: glibc's `environ` is a WEAK alias of `__environ` at the same + // address, and only `__environ` appears in the relocation table. Matching + // by name reports `environ` as a hijacked symbol in every single + // dynamically linked executable. + std::set copyRelocations; + // Did we recognise this object's machine well enough to know its COPY + // relocation type? False means the copy-relocation set above is EMPTY + // BECAUSE IT WAS NOT COMPUTED, not because there are none — a caller must + // report "not evaluated" rather than "clean". + bool copyRelocationsKnown = false; + // Does this object have a dynamic symbol table at all? A fully static + // link has none, and "nothing to check" is a different answer from + // "checked and found nothing". + bool present = false; +}; + +// Read the dynamic symbol table and the copy relocations of one ELF object. +std::expected +inspect_dynamic_symbols(const std::filesystem::path& object); + RuntimeResolution resolve_runtime_closure( const std::filesystem::path& artifact, const mcpp::platform::runtime::RuntimeBinding& binding, @@ -167,6 +213,44 @@ constexpr std::uint64_t kDtVerdef = 0x6ffffffc; constexpr std::uint64_t kDtVerdefnum = 0x6ffffffd; constexpr std::uint64_t kDtVerneed = 0x6ffffffe; constexpr std::uint64_t kDtVerneednum = 0x6fffffff; +constexpr std::uint64_t kDtHash = 4; +constexpr std::uint64_t kDtSymtab = 6; +constexpr std::uint64_t kDtRela = 7; +constexpr std::uint64_t kDtRelasz = 8; +constexpr std::uint64_t kDtSyment = 11; +constexpr std::uint64_t kDtGnuHash = 0x6ffffef5; + +// Elf64_Sym / Elf64_Rela are both 24 bytes. This reader is ELF64-only (the +// dynamic-entry stride above is 16), so there is no 32-bit variant to carry. +constexpr std::uint64_t kSymEntrySize = 24; +constexpr std::uint64_t kRelaEntrySize = 24; + +constexpr unsigned char kSttObject = 1; +constexpr unsigned char kSttFunc = 2; +constexpr unsigned char kSttGnuIfunc = 10; +constexpr unsigned char kStbLocal = 0; + +// The COPY relocation type, per machine. +// +// Machine-dependent by nature, and the table is EXHAUSTIVE-BY-REFUSAL rather +// than by guessing: an unlisted machine returns nullopt, and the caller then +// says "not evaluated" instead of treating every defined data symbol as a +// conflict. A wrong constant here would report a clean image as broken on an +// architecture nobody tested, which is worse than declining to answer. +std::optional copy_relocation_type(std::uint16_t machine) { + switch (machine) { + case 3: return 5; // EM_386 R_386_COPY + case 20: return 19; // EM_PPC R_PPC_COPY + case 21: return 19; // EM_PPC64 R_PPC64_COPY + case 22: return 9; // EM_S390 R_390_COPY + case 40: return 20; // EM_ARM R_ARM_COPY + case 62: return 5; // EM_X86_64 R_X86_64_COPY + case 183: return 1024; // EM_AARCH64 R_AARCH64_COPY + case 243: return 4; // EM_RISCV R_RISCV_COPY + case 258: return 4; // EM_LOONGARCH R_LARCH_COPY + default: return std::nullopt; + } +} struct Reader { std::vector bytes; @@ -395,8 +479,218 @@ int compare_versions(std::span lhs, return 0; } +// How many entries does `.dynsym` have? +// +// The dynamic section does not say. Both hash tables do, in different ways, +// and which one exists is a link-time choice mcpp does not make: its own +// binaries carry BOTH (`--hash-style=both`), while distribution binaries on +// this machine carry GNU_HASH alone — measured on /usr/bin/git and /usr/bin/ls. +// So both readers are required; neither is a fallback for exotic cases. +std::optional dynsym_count_from_sysv_hash( + const Reader& reader, std::uint64_t hashOffset) { + // struct { uint32 nbucket; uint32 nchain; ... } — nchain IS the symbol + // count, because every symbol occupies one chain slot. + auto nchain = reader.u32(hashOffset + 4); + if (!nchain) return std::nullopt; + return *nchain; +} + +std::optional dynsym_count_from_gnu_hash( + const Reader& reader, std::uint64_t hashOffset) { + auto nbuckets = reader.u32(hashOffset); + auto symoffset = reader.u32(hashOffset + 4); + auto bloomSize = reader.u32(hashOffset + 8); + if (!nbuckets || !symoffset || !bloomSize) return std::nullopt; + if (*nbuckets > (1u << 24) || *bloomSize > (1u << 24)) return std::nullopt; + + // GNU_HASH omits the first `symoffset` symbols (the undefined ones), so + // with no hashed symbol at all the table is exactly that long. + const auto bucketsAt = hashOffset + 16 + + static_cast(*bloomSize) * 8; + std::uint32_t maxIndex = 0; + for (std::uint32_t i = 0; i < *nbuckets; ++i) { + auto bucket = reader.u32(bucketsAt + static_cast(i) * 4); + if (!bucket) return std::nullopt; + maxIndex = std::max(maxIndex, *bucket); + } + if (maxIndex < *symoffset) return *symoffset; + + // Walk the chain of the highest bucket to its terminator (low bit set). + const auto chainAt = bucketsAt + static_cast(*nbuckets) * 4; + std::uint32_t index = maxIndex; + for (std::uint32_t guard = 0; guard < (1u << 24); ++guard) { + auto word = reader.u32(chainAt + + static_cast(index - *symoffset) * 4); + if (!word) return std::nullopt; + if (*word & 1u) return static_cast(index) + 1; + ++index; + } + return std::nullopt; +} + } // namespace detail +std::expected +inspect_dynamic_symbols(const std::filesystem::path& object) { + detail::Reader reader; + std::ifstream input(object, std::ios::binary); + if (!input) return std::unexpected(std::format( + "cannot open ELF object '{}'", object.string())); + reader.bytes.assign(std::istreambuf_iterator(input), {}); + + if (reader.bytes.size() < 0x40 + || reader.bytes[0] != 0x7f || reader.bytes[1] != 'E' + || reader.bytes[2] != 'L' || reader.bytes[3] != 'F') + return std::unexpected(std::format( + "object '{}' is not ELF", object.string())); + if (reader.bytes[4] != 2 || reader.bytes[5] != 1) + return std::unexpected(std::format( + "object '{}' is not ELF64 little-endian", object.string())); + + auto machine = reader.u16(0x12); + auto phoff = reader.u64(0x20); + auto phentsize = reader.u16(0x36); + auto phnum = reader.u16(0x38); + if (!machine || !phoff || !phentsize || !phnum || *phentsize < 0x38 + || *phnum > 4096 + || !reader.range(*phoff, static_cast(*phentsize) * *phnum)) + return std::unexpected(std::format( + "object '{}' has a truncated ELF program table", object.string())); + + DynamicSymbols out; + + std::vector segments; + std::optional dynamic; + for (std::uint16_t i = 0; i < *phnum; ++i) { + auto off = *phoff + static_cast(i) * *phentsize; + auto ptype = reader.u32(off); + auto poff = reader.u64(off + 0x08); + auto pvaddr = reader.u64(off + 0x10); + auto pfilesz = reader.u64(off + 0x20); + if (!ptype || !poff || !pvaddr || !pfilesz + || !reader.range(*poff, *pfilesz)) + return std::unexpected(std::format( + "object '{}' has a truncated ELF segment", object.string())); + segments.push_back(detail::Segment{*ptype, *poff, *pvaddr, *pfilesz}); + if (*ptype == detail::kPtDynamic) + dynamic = segments.back(); + } + // No PT_DYNAMIC: a fully static link, or a relocatable object. There is no + // dynamic symbol table to read, and `present` stays false so the caller + // reports "not applicable" rather than "clean". + if (!dynamic || dynamic->filesz % 16 != 0) return out; + + std::vector> entries; + for (std::uint64_t off = dynamic->offset; + off + 16 <= dynamic->offset + dynamic->filesz; off += 16) { + auto tag = reader.u64(off); + auto value = reader.u64(off + 8); + if (!tag || !value) return std::unexpected(std::format( + "object '{}' has a truncated dynamic entry", object.string())); + if (*tag == detail::kDtNull) break; + entries.emplace_back(*tag, *value); + } + auto first = [&](std::uint64_t tag) -> std::optional { + for (auto const& [candidate, value] : entries) + if (candidate == tag) return value; + return std::nullopt; + }; + + auto strtabAddr = first(detail::kDtStrtab); + auto strtabSize = first(detail::kDtStrsz); + auto symtabAddr = first(detail::kDtSymtab); + if (!strtabAddr || !strtabSize || !symtabAddr) return out; + + auto strtab = detail::vaddr_to_offset(segments, *strtabAddr, *strtabSize); + if (!strtab || !reader.range(*strtab, *strtabSize)) + return std::unexpected(std::format( + "object '{}' has an unmappable dynamic string table", object.string())); + + const auto symEntrySize = first(detail::kDtSyment).value_or(detail::kSymEntrySize); + if (symEntrySize < detail::kSymEntrySize) return std::unexpected(std::format( + "object '{}' declares a {}-byte dynamic symbol entry", object.string(), + symEntrySize)); + + std::optional count; + if (auto hash = first(detail::kDtHash)) { + if (auto at = detail::vaddr_to_offset(segments, *hash, 8)) + count = detail::dynsym_count_from_sysv_hash(reader, *at); + } + if (!count) { + if (auto hash = first(detail::kDtGnuHash)) { + if (auto at = detail::vaddr_to_offset(segments, *hash, 16)) + count = detail::dynsym_count_from_gnu_hash(reader, *at); + } + } + // Neither hash table was readable. Refuse rather than guess a table + // length: a short guess reports a clean image, which is the failure mode + // this whole area is about. + if (!count) return std::unexpected(std::format( + "object '{}' has a dynamic symbol table whose length cannot be " + "determined (neither DT_HASH nor DT_GNU_HASH is usable)", + object.string())); + if (*count > (1u << 24)) return std::unexpected(std::format( + "object '{}' declares {} dynamic symbols", object.string(), *count)); + + auto symtab = detail::vaddr_to_offset(segments, *symtabAddr, + symEntrySize * *count); + if (!symtab || !reader.range(*symtab, symEntrySize * *count)) + return std::unexpected(std::format( + "object '{}' has an unmappable dynamic symbol table", object.string())); + + out.present = true; + out.total = static_cast(*count); + for (std::uint64_t i = 0; i < *count; ++i) { + const auto at = *symtab + i * symEntrySize; + auto nameOffset = reader.u32(at); + auto shndx = reader.u16(at + 6); + auto value = reader.u64(at + 8); + if (!nameOffset || !shndx || !value) return std::unexpected(std::format( + "object '{}' has a truncated dynamic symbol", object.string())); + if (*shndx == 0) continue; // SHN_UNDEF — imported + const unsigned char info = reader.bytes[at + 4]; + const unsigned char bind = static_cast(info >> 4); + const unsigned char type = static_cast(info & 0x0f); + if (bind == detail::kStbLocal) continue; // not participating + if (type != detail::kSttObject && type != detail::kSttFunc + && type != detail::kSttGnuIfunc) continue; // sections, files, TLS + auto name = *nameOffset < *strtabSize + ? reader.cstr(*strtab + *nameOffset, *strtabSize - *nameOffset) + : std::nullopt; + if (!name || name->empty()) continue; + out.defined.push_back(DynamicSymbol{ + .name = std::move(*name), + .isFunc = (type == detail::kSttFunc || type == detail::kSttGnuIfunc), + .value = *value, + }); + } + + // Copy relocations. Absent DT_RELA simply means there are none, which is a + // real answer — unlike an unrecognised machine, which is not. + if (auto type = detail::copy_relocation_type(*machine)) { + out.copyRelocationsKnown = true; + auto relaAddr = first(detail::kDtRela); + auto relaSize = first(detail::kDtRelasz); + if (relaAddr && relaSize) { + auto rela = detail::vaddr_to_offset(segments, *relaAddr, *relaSize); + if (!rela || !reader.range(*rela, *relaSize)) + return std::unexpected(std::format( + "object '{}' has an unmappable relocation table", object.string())); + for (std::uint64_t off = 0; + off + detail::kRelaEntrySize <= *relaSize; + off += detail::kRelaEntrySize) { + auto offset = reader.u64(*rela + off); + auto info = reader.u64(*rela + off + 8); + if (!offset || !info) return std::unexpected(std::format( + "object '{}' has a truncated relocation", object.string())); + if (static_cast(*info & 0xffffffffull) == *type) + out.copyRelocations.insert(*offset); + } + } + } + return out; +} + std::expected inspect_elf_runtime(const std::filesystem::path& artifact) { detail::Reader reader; diff --git a/src/pm/dep_spec.cppm b/src/pm/dep_spec.cppm index 2c88f0f0..9dae4b80 100644 --- a/src/pm/dep_spec.cppm +++ b/src/pm/dep_spec.cppm @@ -39,6 +39,18 @@ struct DependencySpec { std::string gitRev; // commit / tag / branch (any one) std::string gitRefKind; // "rev" / "tag" / "branch" (for clarity) std::string visibility = "public"; // public / private / interface + // #519 — "static" | "shared" | "" (no request). How the consumer wants + // this dependency to arrive in its images. + // + // ⚠️ HONOURED ONLY ON THE ROOT MANIFEST'S EDGES, and that is a + // supply-chain property rather than a simplification. A form is a + // whole-image decision: if a dependency four levels down could impose one, + // any package at any depth could change how the final program is laid out + // without its author saying so — the same reasoning that kept `reexport` + // from riding the default-public `visibility` edge. A middle package that + // genuinely must be a single shared copy says so on ITS OWN target + // (`kind = "shared"`), which is a constraint rather than a request. + std::string linkage; std::vector features; // requested feature set (long-form dep spec) // #355: HOST tools this consumer wants from the dependency — the names of // its `kind = "bin"` targets. Requesting one makes mcpp build that target diff --git a/src/version.cppm b/src/version.cppm index 6b1b42c1..d3728182 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.28.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.28.2"; } // namespace mcpp diff --git a/tests/e2e/306_dependency_linkage_axis.sh b/tests/e2e/306_dependency_linkage_axis.sh new file mode 100755 index 00000000..da0b12bc --- /dev/null +++ b/tests/e2e/306_dependency_linkage_axis.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# requires: elf +# 306_dependency_linkage_axis.sh — `[build] dependency_linkage` (issue #519). +# +# The consumer, not the package author, decides whether a dependency arrives +# merged into the image or as a separate shared library. Three things have to +# hold and each of them has bitten before: +# +# 1. the DEFAULT changes nothing — an existing project must keep its output +# directory and its build.ninja, or every user pays for a key they did +# not write; +# 2. asking for `shared` actually produces a shared object the program LOADS +# (not merely one that gets built and then ignored beside a statically +# merged copy); +# 3. the two configurations live in DIFFERENT output directories. They do +# not merely differ in flags: `-fPIC` is whole-build, so sharing a +# directory means sharing objects between two incompatible compilations. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +mkdir -p core/src app/src + +cat > core/mcpp.toml <<'EOF' +[package] +name = "core" +version = "0.1.0" +[build] +c_standard = "c11" +sources = ["src/*.c"] +[targets.core] +kind = "lib" +EOF +cat > core/src/core.c <<'EOF' +int core_answer(void) { return 42; } +EOF + +cat > app/mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" +[build] +c_standard = "c11" +[targets.app] +kind = "bin" +main = "src/main.c" +[dependencies] +core = { path = "../core" } +EOF +cat > app/src/main.c <<'EOF' +#include +int core_answer(void); +int main(void) { printf("%d\n", core_answer()); return 0; } +EOF + +cd app + +# ── 1. the default is the old behaviour ──────────────────────────────────── +"$MCPP" build > static.log 2>&1 || { cat static.log; exit 1; } +# ⚠️ The FINGERPRINT directory, `target///`, not the triple one. +# `ls target/*/ -d` yields the triple, and comparing a fingerprint against it +# in step 3 would pass no matter what — a criterion aimed at the wrong object +# does not report an error, it quietly answers a different question. +static_fp="$(basename "$(ls -d target/*/*/ | head -1)")" +[[ "$static_fp" != "$(basename "$(ls -d target/*/ | head -1)")" ]] || { + echo "FAIL: could not locate the fingerprint directory"; ls -R target | head; exit 1; } +count_before="$(ls -d target/*/*/ | wc -l)" +[[ "$count_before" == 1 ]] || { echo "FAIL: expected exactly one build dir, got $count_before"; exit 1; } + +bin="$(find target -name app -type f | head -1)" +[[ -n "$bin" ]] || { cat static.log; echo "no executable"; exit 1; } +"$bin" | grep -qx 42 || { echo "static build does not run"; exit 1; } + +# No shared object at all: a `kind = "lib"` dependency stays merged. +if find target -name 'libcore.so' | grep -q .; then + echo "FAIL: the default produced a shared library"; exit 1 +fi + +# ── 2. asking for shared produces one, and the program loads it ──────────── +sed -i 's/^c_standard = "c11"$/c_standard = "c11"\ndependency_linkage = "shared"/' mcpp.toml +grep -q 'dependency_linkage = "shared"' mcpp.toml || { echo "sed did not apply"; exit 1; } + +"$MCPP" build > shared.log 2>&1 || { cat shared.log; exit 1; } +so="$(find target -name 'libcore.so' -type f | head -1)" +[[ -n "$so" ]] || { cat shared.log; echo "FAIL: no shared library was built"; exit 1; } +file "$so" | grep -q 'ELF.*shared object' || { echo "FAIL: not an ELF .so"; exit 1; } + +sbin="$(find target -name app -type f -newer "$so" | head -1)" +[[ -n "$sbin" ]] || sbin="$(dirname "$so")/app" +[[ -x "$sbin" ]] || { echo "FAIL: no executable beside the .so"; exit 1; } + +# The WHOLE point: the symbol must be imported, not merged. Asserted on the +# artifact rather than on a log line — a message is free to be reworded, and a +# "shared library exists" check passes just as well when the executable also +# contains a static copy of every symbol in it. +readelf --dyn-syms -W "$sbin" | grep -qE 'UND +core_answer' || { + echo "FAIL: core_answer is not an imported symbol in the executable" + readelf --dyn-syms -W "$sbin" | grep core_answer || true + exit 1 +} +readelf -d -W "$sbin" | grep -q 'NEEDED.*libcore.so' || { + echo "FAIL: the executable does not declare libcore.so as NEEDED"; exit 1; } + +"$sbin" | grep -qx 42 || { echo "FAIL: the shared build does not run"; exit 1; } + +# ── 3. the two configurations do not share an output directory ───────────── +count_after="$(ls -d target/*/*/ | wc -l)" +[[ "$count_after" == 2 ]] || { + echo "FAIL: expected two build directories (static + shared), got $count_after" + ls -d target/*/*/ + exit 1 +} +shared_fp="" +for d in target/*/*/; do + b="$(basename "$d")" + [[ "$b" != "$static_fp" ]] && shared_fp="$b" +done +[[ -n "$shared_fp" ]] || { + echo "FAIL: both configurations landed in one output directory ($static_fp)" + exit 1 +} + +echo "ok: default static ($static_fp), shared ($shared_fp), symbol imported" diff --git a/tests/e2e/307_symbol_provision.sh b/tests/e2e/307_symbol_provision.sh new file mode 100755 index 00000000..ccdec3fc --- /dev/null +++ b/tests/e2e/307_symbol_provision.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# requires: elf +# 307_symbol_provision.sh — one library, one provider (issue #519). +# +# ⚠️ THE SILENT CASE IS THE IMPORTANT ONE, and it is why this test has two +# halves that differ by a single package. +# +# mcpp's own `kind = "shared"` mechanism produces the exact shape the +# diagnostic looks for: a shared dependency's link unit takes only ITS OWN +# objects, so a static package underneath it lands in the consumer's +# executable and the shared library binds back to it at run time. There is one +# copy of the code in the process and nothing is wrong. A check that reported +# "this image exports a symbol a library it loads binds to" would fire on +# every correct build of that shape, and the user could do nothing about it. +# +# The finding is the SECOND provider, not the export. So: +# +# half 1 wrap.so (shared) → core (static) must stay SILENT +# half 2 ... plus alt.so, which defines the same name must REPORT both +# +# Asserted against `resolution.json` rather than stdout: the message is free +# to improve, the recorded verdict is the contract. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +mkdir -p core/src wrap/src alt/src app/src + +cat > core/mcpp.toml <<'EOF' +[package] +name = "core" +version = "0.1.0" +[build] +c_standard = "c11" +sources = ["src/*.c"] +[targets.core] +kind = "lib" +EOF +echo 'int shared_answer(void) { return 7; }' > core/src/core.c + +# A shared library that CALLS the static package's symbol without defining it. +cat > wrap/mcpp.toml <<'EOF' +[package] +name = "wrap" +version = "0.1.0" +[build] +c_standard = "c11" +sources = ["src/*.c"] +[targets.wrap] +kind = "shared" +[dependencies] +core = { path = "../core" } +EOF +cat > wrap/src/wrap.c <<'EOF' +int shared_answer(void); +int wrap_call(void) { return shared_answer() + 1; } +EOF + +cat > app/mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" +[build] +c_standard = "c11" +[targets.app] +kind = "bin" +main = "src/main.c" +[dependencies] +wrap = { path = "../wrap" } +EOF +cat > app/src/main.c <<'EOF' +#include +int wrap_call(void); +int main(void) { printf("%d\n", wrap_call()); return 0; } +EOF + +verdict() { # + "$PYTHON" - "$1" "$2" <<'PY' +import json, sys +doc = json.load(open(sys.argv[1])) +entries = doc.get("runtime", {}).get("symbol_provision") +if not entries: + print("MISSING"); raise SystemExit(0) +app = [e for e in entries if e["path"].endswith("app")] +if not app: + print("NO-APP-ENTRY"); raise SystemExit(0) +print(app[0].get(sys.argv[2], "MISSING")) +PY +} + +PYTHON="$(command -v python3 || command -v python || true)" +[[ -n "$PYTHON" ]] || { echo "skip: no python for JSON assertions"; exit 0; } + +cd app + +# ── half 1: the legitimate arrangement must stay silent ──────────────────── +"$MCPP" build > silent.log 2>&1 || { cat silent.log; exit 1; } +res="$(find target -name resolution.json | head -1)" +[[ -n "$res" ]] || { echo "FAIL: no resolution.json"; exit 1; } + +status="$(verdict "$res" status)" +[[ "$status" == "clean" ]] || { + echo "FAIL: the legitimate shared→static arrangement reported '$status'" + cat "$res"; exit 1; } + +# It must have been MEASURED, not skipped: the export is real and the +# denominator is real. "0 findings" and "never looked" must not read alike. +exported="$(verdict "$res" exported)" +total="$(verdict "$res" dynamic_symbols)" +[[ "$exported" -ge 1 ]] || { + echo "FAIL: expected the executable to export shared_answer, got $exported" + exit 1; } +[[ "$total" -gt "$exported" ]] || { + echo "FAIL: implausible denominator ($exported of $total)"; exit 1; } + +grep -qi "also provided by" silent.log && { + echo "FAIL: a correct build printed a conflict warning"; exit 1; } + +# ── half 2: a second provider turns it into a finding ────────────────────── +cat > ../alt/mcpp.toml <<'EOF' +[package] +name = "alt" +version = "0.1.0" +[build] +c_standard = "c11" +sources = ["src/*.c"] +[targets.alt] +kind = "shared" +EOF +cat > ../alt/src/alt.c <<'EOF' +int shared_answer(void) { return 999; } +int alt_ping(void) { return shared_answer(); } +EOF + +cat > mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" +[build] +c_standard = "c11" +[targets.app] +kind = "bin" +main = "src/main.c" +[dependencies] +wrap = { path = "../wrap" } +alt = { path = "../alt" } +EOF +cat > src/main.c <<'EOF' +#include +int wrap_call(void); +int alt_ping(void); +int main(void) { printf("%d %d\n", wrap_call(), alt_ping()); return 0; } +EOF + +"$MCPP" build > conflict.log 2>&1 || { cat conflict.log; exit 1; } + +# ⚠️ `target//` ACCUMULATES one directory per fingerprint, and adding a +# dependency changed the fingerprint — so half 1's directory is still there +# with its own `app` and its own clean verdict. Taking "the first" of either +# would answer about the build that is not under test. Everything below is +# keyed off the directory that contains the conflicting verdict. +found=0 +conflict_dir="" +for r in $(find target -name resolution.json); do + if [[ "$(verdict "$r" status)" == "conflict" ]]; then + conflict_dir="$(dirname "$r")" + "$PYTHON" - "$r" <<'PY' || exit 1 +import json, sys +doc = json.load(open(sys.argv[1])) +app = [e for e in doc["runtime"]["symbol_provision"] if e["path"].endswith("app")][0] +conflicts = app["conflicts"] +names = {c["symbol"] for c in conflicts} +assert "shared_answer" in names, f"expected shared_answer, got {names}" +providers = {p for c in conflicts for p in c["also_provided_by"]} +assert any("libalt.so" in p for p in providers), \ + f"expected libalt.so among providers, got {providers}" +assert all(c["kind"] == "func" for c in conflicts), conflicts +PY + found=1 + fi +done +[[ "$found" == 1 ]] || { + echo "FAIL: two providers of shared_answer were not reported" + cat conflict.log; exit 1; } + +# The defect is real, not theoretical: alt's 999 is unreachable because the +# executable's merged copy wins for every caller in the process. +cbin="$conflict_dir/bin/app" +[[ -x "$cbin" ]] || { echo "FAIL: no executable in $conflict_dir"; exit 1; } +"$cbin" | grep -qx "8 7" || { + echo "FAIL: expected '8 7' (the merged copy winning), got: $("$cbin")"; exit 1; } + +# ── --strict turns the finding into a failure, in ONE place ──────────────── +touch src/main.c +if "$MCPP" build --strict > strict.log 2>&1; then + echo "FAIL: --strict did not fail on a reported conflict"; cat strict.log; exit 1 +fi + +echo "ok: silent on the legitimate arrangement, reported on a real conflict" diff --git a/tests/e2e/308_dependency_required_features.sh b/tests/e2e/308_dependency_required_features.sh new file mode 100755 index 00000000..96e34754 --- /dev/null +++ b/tests/e2e/308_dependency_required_features.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# requires: elf +# 308_dependency_required_features.sh — a DEPENDENCY's target gate (issue #519). +# +# `[targets.] required_features` was filtered for the root package only. +# A dependency that wrote it got the OPPOSITE of what it asked for: the target +# was emitted for every consumer, active feature or not. +# +# That is not cosmetic when the gated target is `kind = "shared"`. A package +# with any shared target has ALL of its objects taken out of every consumer's +# link and put behind a shared library instead — so an "optional" target +# silently changed how the package was linked into everyone. +# +# The criterion is the ARTIFACT, not a log line: with the feature off there +# must be no shared object; with it on there must be one. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +mkdir -p dep/src app/src + +cat > dep/mcpp.toml <<'EOF' +[package] +name = "dep" +version = "0.1.0" +[build] +c_standard = "c11" +sources = ["src/*.c"] +[features] +plugin = [] +[targets.dep] +kind = "lib" +[targets.depshared] +kind = "shared" +required_features = ["plugin"] +EOF +cat > dep/src/dep.c <<'EOF' +int dep_value(void) { return 5; } +EOF + +cat > app/mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" +[build] +c_standard = "c11" +[targets.app] +kind = "bin" +main = "src/main.c" +[dependencies] +dep = { path = "../dep" } +EOF +cat > app/src/main.c <<'EOF' +#include +int dep_value(void); +int main(void) { printf("%d\n", dep_value()); return 0; } +EOF + +cd app + +# ── the gate is CLOSED: no shared object, and the program still works ─────── +"$MCPP" build > off.log 2>&1 || { cat off.log; exit 1; } +if find target -name 'libdepshared.so' | grep -q .; then + echo "FAIL: a feature-gated dependency target was built with the feature off" + find target -name 'libdepshared.so' + exit 1 +fi +bin="$(find target -name app -type f | head -1)" +[[ -n "$bin" ]] || { cat off.log; echo "FAIL: no executable"; exit 1; } +"$bin" | grep -qx 5 || { echo "FAIL: the gated-off build does not run"; exit 1; } + +# ── the gate is OPEN: the target appears ─────────────────────────────────── +cat > mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" +[build] +c_standard = "c11" +[targets.app] +kind = "bin" +main = "src/main.c" +[dependencies] +dep = { path = "../dep", features = ["plugin"] } +EOF + +"$MCPP" build > on.log 2>&1 || { cat on.log; exit 1; } +so="$(find target -name 'libdepshared.so' -type f | head -1)" +[[ -n "$so" ]] || { + echo "FAIL: requesting the feature did not build the gated target" + cat on.log; exit 1; } + +echo "ok: a dependency's required_features gate opens and closes" diff --git a/tests/unit/test_cache_key.cpp b/tests/unit/test_cache_key.cpp index 20051e55..4f23c863 100644 --- a/tests/unit/test_cache_key.cpp +++ b/tests/unit/test_cache_key.cpp @@ -456,3 +456,33 @@ TEST(CacheKey, TheHeaderSetCarriesNoAbsoluteHome) { EXPECT_NE(a.targetHeaderSet, b.targetHeaderSet); EXPECT_NE(ck::key_hex(a, pkg()), ck::key_hex(b, pkg())); } + +// ⚠️ Position-independent code was ABSENT from this key (issue #519). +// +// `-fPIC` is whole-build — one shared link unit anywhere and every object in +// the graph carries it — but the key hashes a package's DECLARED flags, not +// the ones the flag builder computes, so the two configurations collided. +// Survivable only while a package's form was fixed by its author. Once a +// consumer can write `dependency_linkage = "shared"`, a hit across that line +// hands non-PIC objects to a shared link, and the message names a relocation +// in a file nobody edited. +TEST(CacheKey, PicChangesTheKey) { + auto b = axes(); + auto p = pkg(); + const auto without = ck::key_hex(b, p); + b.pic = true; + EXPECT_NE(ck::key_hex(b, p), without); +} + +TEST(CacheKey, PicIsRecordedInEntryJsonSoAHitCanBeAudited) { + auto b = axes(); + b.pic = true; + auto j = ck::to_json(b, pkg()); + ASSERT_TRUE(j["profile"].contains("pic")); + EXPECT_EQ(j["profile"]["pic"], true); +} + +TEST(CacheKey, PicDefaultsOffSoExistingEntriesKeepTheirIdentity) { + ck::BuildAxes fresh; + EXPECT_FALSE(fresh.pic); +} diff --git a/tests/unit/test_elf_runtime.cpp b/tests/unit/test_elf_runtime.cpp index 208e4525..c45738b6 100644 --- a/tests/unit/test_elf_runtime.cpp +++ b/tests/unit/test_elf_runtime.cpp @@ -5,6 +5,7 @@ import mcpp.platform; import mcpp.platform.elf_runtime; import mcpp.platform.runtime_binding; import mcpp.toolchain.post_install; +import mcpp.build.symbol_provision; namespace elf = mcpp::platform::elf; namespace runtime = mcpp::platform::runtime; @@ -545,3 +546,67 @@ TEST(RuntimePhysics, NonLinuxValidatorIsATypedNoop) { } } // namespace + +// ── the dynamic symbol table (issue #519) ───────────────────────────────── +// +// These read THIS TEST BINARY. A hand-built ELF fixture can assert that the +// parser walks the structures it is given; only a real link can assert that +// the RULE is right about what a linker actually produces — and the rule was +// wrong twice before it was measured. + +TEST(ElfRuntime, ReadsThisBinarysOwnDynamicSymbols) { + if constexpr (!mcpp::platform::is_linux) + GTEST_SKIP() << "ELF dynamic symbol tables only exist on Linux here"; + + auto self = std::filesystem::path("/proc/self/exe"); + if (!std::filesystem::exists(self)) GTEST_SKIP() << "no /proc/self/exe"; + + auto symbols = elf::inspect_dynamic_symbols(self); + ASSERT_TRUE(symbols.has_value()) << symbols.error(); + if (!symbols->present) + GTEST_SKIP() << "this test binary is statically linked"; + + // The DENOMINATOR has to be real. `exported = 0` out of an unread table + // is the reading this whole area exists to make impossible. + EXPECT_GT(symbols->total, 0u); + EXPECT_TRUE(symbols->copyRelocationsKnown) + << "x86_64/aarch64/riscv64 must all be in the COPY relocation table"; +} + +TEST(ElfRuntime, ThisBinaryExportsNothingOfItsOwn) { + if constexpr (!mcpp::platform::is_linux) + GTEST_SKIP() << "ELF dynamic symbol tables only exist on Linux here"; + + auto self = std::filesystem::path("/proc/self/exe"); + if (!std::filesystem::exists(self)) GTEST_SKIP() << "no /proc/self/exe"; + + auto facts = elf::inspect_elf_runtime(self); + ASSERT_TRUE(facts.has_value()) << facts.error(); + // ⚠️ PT_INTERP, not the ELF type: a PIE executable is ET_DYN exactly like + // a shared library, and whether this binary is PIE is the payload + // compiler's default rather than mcpp's decision. + if (facts->interp.empty()) + GTEST_SKIP() << "statically linked: no loader, so no flat namespace"; + + auto symbols = elf::inspect_dynamic_symbols(self); + ASSERT_TRUE(symbols.has_value()) << symbols.error(); + ASSERT_TRUE(symbols->present); + + auto exported = mcpp::build::symbol_provision::exported_definitions(*symbols); + ASSERT_TRUE(exported.has_value()); + + // Measured on every mcpp binary: the only defined entries are glibc's + // copy relocations (environ, __environ, stdout, stderr, + // __libc_single_threaded), and `environ` is a WEAK ALIAS AT THE SAME + // ADDRESS as `__environ` with no relocation entry of its own. A + // name-keyed filter reports it in every executable ever built. + std::vector names; + for (auto const& e : *exported) names.push_back(e.name); + EXPECT_TRUE(names.empty()) + << "an ordinary mcpp-linked test binary must export nothing of its " + "own; got: " << [&] { + std::string s; + for (auto const& n : names) { s += n; s += ' '; } + return s; + }(); +} diff --git a/tests/unit/test_linkage_form.cpp b/tests/unit/test_linkage_form.cpp new file mode 100644 index 00000000..b7ebfa4d --- /dev/null +++ b/tests/unit/test_linkage_form.cpp @@ -0,0 +1,227 @@ +// Which form does a dependency take (issue #519)? +// +// The assertions are about the TABLE — its totality, and the identity of each +// cell — rather than about a build looking right afterwards. Two of them cover +// rules that were WRONG in the first draft of the design and were corrected +// against measurements, so they are regression tests for a decision as much as +// for code: the `-L` rule (§4.2) and the libc-linkage coupling (§12.3). + +#include + +import std; +import mcpp.build.linkage_form; + +namespace lf = mcpp::build::linkage_form; + +namespace { + +lf::PackageFacts source_package() { + lf::PackageFacts p; + p.label = "compat.zlib@1.3.2"; + p.hasSources = true; + return p; +} + +lf::TargetFacts hosted() { return lf::TargetFacts{}; } + +lf::Request want(lf::DepLinkage linkage) { + lf::Request r; + r.whole = linkage; + r.wholeIsExplicit = true; + return r; +} + +} // namespace + +// ── the default is byte-for-byte the old behaviour ───────────────────────── + +TEST(LinkageForm, DefaultRequestIsStaticAndSilent) { + lf::Request none; // nobody wrote anything + EXPECT_EQ(none.whole, lf::DepLinkage::Static); + EXPECT_FALSE(none.wholeIsExplicit); + + auto pkg = source_package(); + auto answer = lf::resolve(pkg, lf::admissible(pkg, hosted()), none); + EXPECT_EQ(answer.linkage, lf::DepLinkage::Static); + EXPECT_TRUE(answer.diagnostic.empty()); +} + +TEST(LinkageForm, ASourcePackageCanBeEitherForm) { + auto pkg = source_package(); + auto allowed = lf::admissible(pkg, hosted()); + EXPECT_TRUE(allowed.staticOk); + EXPECT_TRUE(allowed.sharedOk); + EXPECT_EQ(lf::resolve(pkg, allowed, want(lf::DepLinkage::Shared)).linkage, + lf::DepLinkage::Shared); +} + +// ── `kind = "shared"` is a constraint; `kind = "lib"` is not ─────────────── + +TEST(LinkageForm, DeclaredSharedIsAConstraintNotAPreference) { + auto pkg = source_package(); + pkg.declaredShared = true; + auto allowed = lf::admissible(pkg, hosted()); + EXPECT_FALSE(allowed.staticOk); + EXPECT_TRUE(allowed.sharedOk); + // Asking for static does not get it, and says so. + auto answer = lf::resolve(pkg, allowed, want(lf::DepLinkage::Static)); + EXPECT_EQ(answer.linkage, lf::DepLinkage::Shared); + EXPECT_FALSE(answer.diagnostic.empty()); +} + +TEST(LinkageForm, NotDeclaringSharedIsNotAConstraintToBeStatic) { + // `kind = "lib"` is the parser's DEFAULT, written as boilerplate by 84 of + // the 130 packages in mcpp-index. Reading it as "must be static" would + // lock the whole ecosystem out of this axis, so absence must stay silent. + auto pkg = source_package(); + ASSERT_FALSE(pkg.declaredShared); + EXPECT_TRUE(lf::admissible(pkg, hosted()).sharedOk); +} + +// ── the `-L` rule (round-two correction) ─────────────────────────────────── + +TEST(LinkageForm, ForeignLinkInputsAreRecognisedInEverySpelling) { + EXPECT_TRUE(lf::carries_foreign_link_inputs( + std::vector{"-Llib", "-l:libssl.a"})); + EXPECT_TRUE(lf::carries_foreign_link_inputs( + std::vector{"-L", "lib"})); + EXPECT_TRUE(lf::carries_foreign_link_inputs( + std::vector{"-Wl,-Llib"})); + EXPECT_TRUE(lf::carries_foreign_link_inputs( + std::vector{"/LIBPATH:lib"})); +} + +TEST(LinkageForm, SystemLibrariesAreNotForeignLinkInputs) { + // The 27 mcpp-index packages whose ldflags name only host libraries must + // stay eligible: a shared object depending on libm is ordinary. + EXPECT_FALSE(lf::carries_foreign_link_inputs( + std::vector{"-lm", "-lpthread", "-ldl", "-lrt"})); + EXPECT_FALSE(lf::carries_foreign_link_inputs( + std::vector{"-lws2_32", "-lbcrypt", "-ladvapi32"})); +} + +TEST(LinkageForm, APackageCarryingPrebuiltArchivesCannotBeShared) { + // compat.openssl's shape: one anchor TU that mcpp compiles, plus `.a` + // archives it ships. Wrapping somebody else's non-PIC archive in a shared + // object is not something mcpp can do, so the request is refused WITH a + // reason naming the cause. + auto pkg = source_package(); + pkg.label = "compat.openssl@3.5.0"; + pkg.carriesForeignLinkInputs = true; + auto allowed = lf::admissible(pkg, hosted()); + EXPECT_TRUE(allowed.staticOk); + EXPECT_FALSE(allowed.sharedOk); + EXPECT_NE(allowed.sharedRefusal.find("-L"), std::string::npos); + + auto answer = lf::resolve(pkg, allowed, want(lf::DepLinkage::Shared)); + EXPECT_EQ(answer.linkage, lf::DepLinkage::Static); + EXPECT_FALSE(answer.diagnostic.empty()); +} + +TEST(LinkageForm, APackageWithNoSourcesOfItsOwnCannotBeShared) { + lf::PackageFacts pkg; + pkg.label = "vendor.glib@2.80.0"; + pkg.hasSources = false; + EXPECT_FALSE(lf::admissible(pkg, hosted()).sharedOk); +} + +// ── the target's own veto (round-two correction) ─────────────────────────── + +TEST(LinkageForm, AFreestandingTargetHasNothingToLoadASharedLibraryWith) { + auto pkg = source_package(); + lf::TargetFacts bare; + bare.hasLoader = false; + auto allowed = lf::admissible(pkg, bare); + EXPECT_FALSE(allowed.sharedOk); + EXPECT_EQ(lf::resolve(pkg, allowed, want(lf::DepLinkage::Shared)).linkage, + lf::DepLinkage::Static); +} + +TEST(LinkageForm, AFullyStaticLibcVetoesTheSharedForm) { + // ⚠️ The two axes named `linkage` are NOT independent. A `-static` image + // has no interpreter, so it cannot load a shared object — and musl + // defaults to `linkage = "static"`, which makes this the COMMON path + // there rather than a corner. + auto pkg = source_package(); + lf::TargetFacts staticLibc; + staticLibc.fullStaticLibc = true; + auto allowed = lf::admissible(pkg, staticLibc); + EXPECT_FALSE(allowed.sharedOk); + EXPECT_NE(allowed.sharedRefusal.find("static"), std::string::npos); + + auto answer = lf::resolve(pkg, allowed, want(lf::DepLinkage::Shared)); + EXPECT_EQ(answer.linkage, lf::DepLinkage::Static); + // It must SAY why, or the user edits the wrong key forever. + EXPECT_FALSE(answer.diagnostic.empty()); +} + +TEST(LinkageForm, TheTargetVetoIsReportedBeforeThePackageOne) { + // Order is the order of the diagnostic: a reason the user cannot fix by + // editing the package must not point at the package. + lf::PackageFacts pkg = source_package(); + pkg.carriesForeignLinkInputs = true; + lf::TargetFacts bare; + bare.hasLoader = false; + auto allowed = lf::admissible(pkg, bare); + EXPECT_EQ(allowed.sharedRefusal.find("compat.zlib"), std::string::npos); +} + +// ── distribution packages answer from what they ship ─────────────────────── + +TEST(LinkageForm, ADistributionPackageOffersOnlyTheLegsItShips) { + lf::PackageFacts pkg; + pkg.label = "acme.mathkit@0.1.0"; + pkg.isDistribution = true; + pkg.shipsStatic = true; + auto allowed = lf::admissible(pkg, hosted()); + EXPECT_TRUE(allowed.staticOk); + EXPECT_FALSE(allowed.sharedOk); + EXPECT_NE(allowed.sharedRefusal.find("mathkit"), std::string::npos); + + pkg.shipsShared = true; + EXPECT_TRUE(lf::admissible(pkg, hosted()).sharedOk); +} + +// ── per-package request ──────────────────────────────────────────────────── + +TEST(LinkageForm, APerPackageRequestOverridesTheWholeGraphDefault) { + auto pkg = source_package(); + lf::Request request; // default static, not explicit + request.perPackage["compat.zlib@1.3.2"] = lf::DepLinkage::Shared; + EXPECT_EQ(lf::resolve(pkg, lf::admissible(pkg, hosted()), request).linkage, + lf::DepLinkage::Shared); +} + +TEST(LinkageForm, AnUnaskedForRefusalStaysSilent) { + // mcpp promised nothing when nobody asked. A default that cannot be + // honoured is not a broken promise, and warning about it on every build + // puts noise on correct manifests. + auto pkg = source_package(); + pkg.declaredShared = true; // constrained to shared + lf::Request none; // default static, NOT explicit + auto answer = lf::resolve(pkg, lf::admissible(pkg, hosted()), none); + EXPECT_EQ(answer.linkage, lf::DepLinkage::Shared); + EXPECT_TRUE(answer.diagnostic.empty()); +} + +// ── PIC ──────────────────────────────────────────────────────────────────── + +TEST(LinkageForm, PicFollowsAnySharedFormInTheGraph) { + using L = lf::DepLinkage; + std::vector allStatic{L::Static, L::Static}; + std::vector oneShared{L::Static, L::Shared}; + EXPECT_FALSE(lf::needs_pic(allStatic, /*anyOwnSharedTarget=*/false)); + EXPECT_TRUE(lf::needs_pic(allStatic, /*anyOwnSharedTarget=*/true)); + EXPECT_TRUE(lf::needs_pic(oneShared, /*anyOwnSharedTarget=*/false)); +} + +// ── vocabulary ───────────────────────────────────────────────────────────── + +TEST(LinkageForm, TheVocabularyIsClosed) { + EXPECT_EQ(lf::parse("static"), lf::DepLinkage::Static); + EXPECT_EQ(lf::parse("shared"), lf::DepLinkage::Shared); + EXPECT_FALSE(lf::parse("dynamic").has_value()); // the libc axis' word + EXPECT_FALSE(lf::parse("").has_value()); + EXPECT_EQ(lf::to_string(lf::DepLinkage::Static), "static"); + EXPECT_EQ(lf::to_string(lf::DepLinkage::Shared), "shared"); +} diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index d7beadfd..ffe3418a 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -133,7 +133,12 @@ soname = "libdep.so.1" EXPECT_EQ(m->targets[0].soname, "libdep.so.1"); } -TEST(Manifest, RejectsSonameOnNonSharedTarget) { +TEST(Manifest, RejectsSonameOnANonLibraryTarget) { + // ⚠️ NARROWED from "non-shared" to "non-library" (#519). A soname is the + // name a library is FOUND by, and a package needs to be able to state it + // while still being consumed as a static library — otherwise two copies + // of one library can never resolve to a single file. An EXECUTABLE still + // has no business declaring one. constexpr auto src = R"( [package] name = "app" @@ -145,7 +150,7 @@ soname = "libapp.so.1" )"; auto m = mcpp::manifest::parse_string(src); ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("soname is only valid for shared targets"), + EXPECT_NE(m.error().message.find("soname is only valid for library targets"), std::string::npos); } @@ -3332,7 +3337,7 @@ name = "depspeckeys" version = "0.1.0" [dependencies.compat] -everything = { version = "1.0.0", features = ["x"], default-features = false, visibility = "private", backend = "openblas", tools = ["t"], host-module = true, reexport = true } +everything = { version = "1.0.0", features = ["x"], default-features = false, visibility = "private", backend = "openblas", tools = ["t"], host-module = true, reexport = true, linkage = "shared" } bygit = { git = "https://example.invalid/x.git", tag = "v1", visibility = "interface" } bypath = { path = "../sibling" } )"; @@ -3354,6 +3359,133 @@ bypath = { path = "../sibling" } // `backend = "openblas"` is sugar for requesting the backend- feature. EXPECT_NE(std::find(all->features.begin(), all->features.end(), "backend-openblas"), all->features.end()); + EXPECT_EQ(all->linkage, "shared"); +} + +// ── #519: the dependency-form axis ──────────────────────────────────────── + +namespace { + +std::expected +load_inline(std::string_view name, std::string_view body) { + auto tmp = std::filesystem::temp_directory_path() + / std::format("mcpp_{}_{}", name, std::random_device{}()); + std::filesystem::create_directories(tmp); + auto path = tmp / "mcpp.toml"; + { std::ofstream os(path); os << body; } + return mcpp::manifest::load(path); +} + +} // namespace + +TEST(Manifest, DependencyLinkageDefaultsToEmptyMeaningStatic) { + auto m = load_inline("deplinkage_default", R"( +[package] +name = "x" +version = "0.1.0" +)"); + ASSERT_TRUE(m); + EXPECT_TRUE(m->buildConfig.dependencyLinkage.empty()); +} + +TEST(Manifest, DependencyLinkageIsAClosedVocabulary) { + auto ok = load_inline("deplinkage_ok", R"( +[package] +name = "x" +version = "0.1.0" +[build] +dependency_linkage = "shared" +)"); + ASSERT_TRUE(ok); + EXPECT_EQ(ok->buildConfig.dependencyLinkage, "shared"); + + // ⚠️ "dynamic" is the LIBC axis' word. Accepting it here would silently + // mean "static", because that is what an unparsed value resolves to. + auto bad = load_inline("deplinkage_bad", R"( +[package] +name = "x" +version = "0.1.0" +[build] +dependency_linkage = "dynamic" +)"); + EXPECT_FALSE(bad); +} + +TEST(Manifest, ProfileDependencyLinkageIsOptionalSoItDoesNotResetBuild) { + // Resolving a profile REPLACES the whole Profile struct, so a plain field + // would make any declared profile silently undo `[build]`. + auto m = load_inline("deplinkage_profile", R"( +[package] +name = "x" +version = "0.1.0" +[build] +dependency_linkage = "shared" +[profile.dev] +opt = "0" +[profile.fast] +dependency_linkage = "static" +)"); + ASSERT_TRUE(m); + ASSERT_TRUE(m->profiles.contains("dev")); + EXPECT_FALSE(m->profiles.at("dev").dependencyLinkage.has_value()); + ASSERT_TRUE(m->profiles.contains("fast")); + ASSERT_TRUE(m->profiles.at("fast").dependencyLinkage.has_value()); + EXPECT_EQ(*m->profiles.at("fast").dependencyLinkage, "static"); +} + +TEST(Manifest, ADependencyEdgeLinkageIsAClosedVocabularyToo) { + auto bad = load_inline("edge_linkage_bad", R"( +[package] +name = "x" +version = "0.1.0" +[dependencies] +zlib = { version = "1.0.0", linkage = "dynamic" } +)"); + EXPECT_FALSE(bad); +} + +TEST(Manifest, ALibraryTargetMayDeclareASoname) { + // ⚠️ This used to make the WHOLE MANIFEST FAIL TO LOAD, in both parsers. + // A soname is the name a library is FOUND by, and it is the only way two + // copies of one library can resolve to a single file — so a package has + // to be able to state it while still being consumed as a static library, + // which is the normal case. + auto m = load_inline("soname_on_lib", R"( +[package] +name = "z" +version = "0.1.0" +[targets.z] +kind = "lib" +soname = "libz.so.1" +)"); + ASSERT_TRUE(m) << (m ? "" : m.error().message); + ASSERT_EQ(m->targets.size(), 1u); + EXPECT_EQ(m->targets[0].soname, "libz.so.1"); +} + +TEST(Manifest, ASonameOnABinaryTargetIsStillRefused) { + auto m = load_inline("soname_on_bin", R"( +[package] +name = "z" +version = "0.1.0" +[targets.z] +kind = "bin" +main = "src/main.cpp" +soname = "libz.so.1" +)"); + EXPECT_FALSE(m); +} + +TEST(Manifest, ASonameMustStillBeABasename) { + auto m = load_inline("soname_path", R"( +[package] +name = "z" +version = "0.1.0" +[targets.z] +kind = "lib" +soname = "lib/libz.so.1" +)"); + EXPECT_FALSE(m); } // #359: a manifest written for a NEWER mcpp must still load. diff --git a/tests/unit/test_symbol_provision.cpp b/tests/unit/test_symbol_provision.cpp new file mode 100644 index 00000000..bd9a28d8 --- /dev/null +++ b/tests/unit/test_symbol_provision.cpp @@ -0,0 +1,193 @@ +// One library, one provider — evaluated on a produced image (issue #519). +// +// Two of these are regression tests for predicates that were WRONG before +// they were measured, and both would have failed silently rather than loudly: +// +// * matching copy relocations by NAME reports `environ` as hijacked in +// every dynamically linked executable, because it is a weak alias of +// `__environ` at the same address and only the latter is in the +// relocation table; +// * reporting on stage one alone flags mcpp's OWN `kind = "shared"` +// arrangement, where a shared dependency's static dependency legitimately +// lands in the consumer's executable with exactly one copy in the process. + +#include + +import std; +import mcpp.build.symbol_provision; +import mcpp.platform.elf_runtime; + +namespace sp = mcpp::build::symbol_provision; +namespace elf = mcpp::platform::elf; + +namespace { + +elf::DynamicSymbols image() { + elf::DynamicSymbols s; + s.present = true; + s.copyRelocationsKnown = true; + return s; +} + +elf::DynamicSymbol func(std::string name, std::uint64_t at = 0x1000) { + return elf::DynamicSymbol{ .name = std::move(name), .isFunc = true, .value = at }; +} + +elf::DynamicSymbol object(std::string name, std::uint64_t at) { + return elf::DynamicSymbol{ .name = std::move(name), .isFunc = false, .value = at }; +} + +std::vector names(const std::vector& exports) { + std::vector out; + for (auto const& e : exports) out.push_back(e.name); + return out; +} + +} // namespace + +// ── stage one ────────────────────────────────────────────────────────────── + +TEST(SymbolProvision, ADefinedFunctionIsAlwaysAnExport) { + auto s = image(); + s.defined.push_back(func("inflate")); + // Even at an address that carries a copy relocation: a copy relocation + // moves DATA, so a function sharing that address is not one. + s.copyRelocations.insert(0x1000); + auto exports = sp::exported_definitions(s); + ASSERT_TRUE(exports.has_value()); + EXPECT_EQ(names(*exports), std::vector{"inflate"}); +} + +TEST(SymbolProvision, CopyRelocatedDataIsNotAnExport) { + auto s = image(); + s.defined.push_back(object("stdout", 0xb1d888)); + s.copyRelocations.insert(0xb1d888); + auto exports = sp::exported_definitions(s); + ASSERT_TRUE(exports.has_value()); + EXPECT_TRUE(exports->empty()); +} + +TEST(SymbolProvision, CopyRelocationsMatchByAddressNotByName) { + // ⚠️ MEASURED, on every mcpp binary: glibc's `environ` is a WEAK alias of + // `__environ` at one address, and only `__environ` appears in `.rela.dyn`. + // A name-keyed filter reports `environ` as a hijacked symbol in every + // dynamically linked executable ever built. + auto s = image(); + s.defined.push_back(object("__environ", 0xb1d840)); + s.defined.push_back(object("environ", 0xb1d840)); // same address + s.copyRelocations.insert(0xb1d840); // only one entry + auto exports = sp::exported_definitions(s); + ASSERT_TRUE(exports.has_value()); + EXPECT_TRUE(exports->empty()) << "environ must not be reported"; +} + +TEST(SymbolProvision, AnUninitialisedDataSymbolWithNoCopyRelocationIsAnExport) { + // /usr/bin/ls's `obstack_alloc_failed_handler` — a function-pointer + // variable that gnulib defines and glibc also defines. It lives in .bss + // like a copy relocation would, and it is NOT one. + auto s = image(); + s.defined.push_back(object("obstack_alloc_failed_handler", 0x2000)); + auto exports = sp::exported_definitions(s); + ASSERT_TRUE(exports.has_value()); + EXPECT_EQ(names(*exports), + std::vector{"obstack_alloc_failed_handler"}); +} + +TEST(SymbolProvision, AnUnknownMachineDeclinesRatherThanGuessing) { + // Without the machine's COPY relocation type every data symbol looks like + // an export. "Could not evaluate" must not be spelled the same way as + // "evaluated and clean". + auto s = image(); + s.copyRelocationsKnown = false; + s.defined.push_back(object("stdout", 0x10)); + EXPECT_FALSE(sp::exported_definitions(s).has_value()); +} + +// ── stage two ────────────────────────────────────────────────────────────── + +TEST(SymbolProvision, AnExportWithNoSecondProviderIsNotAConflict) { + // ⚠️ mcpp's OWN arrangement. A `kind = "shared"` dependency's link unit + // takes only its own objects, so its static dependency lands in the + // consumer's executable and the shared library binds back to it. One copy + // in the process, entirely benign — and stage one alone would warn about + // it on every build that uses compat.x11 with a real static dependency. + std::vector exports{ sp::Export{"shared_answer", true} }; + std::vector closure{ + sp::Provider{"/lib/libc.so.6", {"printf", "malloc"}}, + }; + EXPECT_TRUE(sp::conflicting_exports(exports, closure).empty()); +} + +TEST(SymbolProvision, AnExportWithASecondProviderIsAConflictAndNamesIt) { + std::vector exports{ sp::Export{"inflate", true}, + sp::Export{"deflate", true} }; + std::vector closure{ + sp::Provider{"/pkg/lib/libz.so.1", {"deflate", "inflate", "crc32"}}, + sp::Provider{"/lib/libc.so.6", {"printf"}}, + }; + auto conflicts = sp::conflicting_exports(exports, closure); + ASSERT_EQ(conflicts.size(), 2u); + EXPECT_EQ(conflicts[0].name, "inflate"); + ASSERT_EQ(conflicts[0].alsoProvidedBy.size(), 1u); + EXPECT_EQ(conflicts[0].alsoProvidedBy[0], "/pkg/lib/libz.so.1"); +} + +TEST(SymbolProvision, TheReportNamesEveryProviderAndCapsTheSymbolList) { + sp::Report report; + report.status = sp::Status::Conflict; + report.total = 217; + for (int i = 0; i < 20; ++i) + report.conflicts.push_back(sp::Conflict{ + std::format("sym{}", i), true, {"/pkg/lib/libz.so.1"}}); + report.exported = report.conflicts.size(); + + auto text = report.explain("consumer"); + EXPECT_NE(text.find("20 symbols"), std::string::npos); + EXPECT_NE(text.find("and 14 more"), std::string::npos); + EXPECT_NE(text.find("/pkg/lib/libz.so.1"), std::string::npos); + // The three ways out are the point of the message. + EXPECT_NE(text.find("dependency_linkage"), std::string::npos); + EXPECT_NE(text.find("SONAME"), std::string::npos); +} + +TEST(SymbolProvision, OnlyAConflictIsActionable) { + EXPECT_FALSE(sp::not_applicable("static").actionable()); + EXPECT_FALSE(sp::not_evaluated("unknown machine").actionable()); + sp::Report clean; clean.status = sp::Status::Clean; + EXPECT_FALSE(clean.actionable()); + EXPECT_TRUE(clean.explain("x").empty()); +} + +TEST(SymbolProvision, ANonAnswerCarriesItsReason) { + // "Not checked" and "checked and clean" must never render the same. + auto na = sp::not_applicable("statically linked"); + EXPECT_EQ(na.status, sp::Status::NotApplicable); + EXPECT_EQ(na.reason, "statically linked"); + EXPECT_EQ(sp::to_string(sp::Status::NotApplicable), "not-applicable"); + EXPECT_EQ(sp::to_string(sp::Status::NotEvaluated), "not-evaluated"); + EXPECT_NE(sp::to_string(sp::Status::Clean), + sp::to_string(sp::Status::NotApplicable)); +} + +// ── the precondition ─────────────────────────────────────────────────────── + +TEST(SymbolProvision, AnAuthorRequestedExportSurfaceVoidsThePredicate) { + // /usr/bin/bash exports 2339 symbols on purpose, for loadable builtins. + // Detecting exactly the thing the author asked for is not a finding. + EXPECT_TRUE(sp::export_dynamic_requested( + std::vector{"-O2", "-rdynamic"})); + EXPECT_TRUE(sp::export_dynamic_requested( + std::vector{"-Wl,--export-dynamic"})); + EXPECT_TRUE(sp::export_dynamic_requested(std::vector{"-Wl,-E"})); + EXPECT_TRUE(sp::export_dynamic_requested( + std::vector{"-Wl,--dynamic-list=syms.txt"})); + EXPECT_TRUE(sp::export_dynamic_requested( + std::vector{"-Wl,--export-dynamic-symbol=foo"})); +} + +TEST(SymbolProvision, OrdinaryLinkFlagsDoNotVoidThePredicate) { + // A substring sweep would fire on any of these. + EXPECT_FALSE(sp::export_dynamic_requested(std::vector{ + "-O2", "-Wl,-rpath,$ORIGIN", "-lz", "-Wl,--as-needed", + "-Wl,--enable-new-dtags", "-static-libstdc++", "-shared"})); +} From 229a835073f5a1bd8e740cbf6a28680ab683cd54 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 28 Aug 2026 08:58:38 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(#519):=20=E8=AF=8A=E6=96=AD=E9=87=8C?= =?UTF-8?q?=E4=B8=89=E6=9D=A1=E5=87=BA=E8=B7=AF=E7=9A=84=E6=AC=A1=E5=BA=8F?= =?UTF-8?q?,=E8=A2=AB=E7=9C=9F=E5=AE=9E=E5=9C=BA=E6=99=AF=E7=9A=84?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E6=94=B9=E6=8E=89=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用生态自己的 glib 复现 issue §2(xim:glib + 整条闭包,零宿主依赖): exe 导出 88 个 zlib 符号,`LD_DEBUG` 显示 libgio 全部绑到 exe, 含 `inflateGetHeader' [ZLIB_1.2.2]` —— 带版本的引用绑到无版本的定义。 诊断指名了两个提供者。 ⚠️ 但按第一条出路(`linkage = "shared"`)走一遍之后:导出符号 88 → 0、 诊断静默,而进程里加载的 zlib 从 **1 份变成 2 份**(`libzlib.so` 没有 SONAME,libgio 仍然去找 `libz.so.1`)。 ⇒ 这根轴单独用会把一个可检测的缺陷换成一个不可检测的缺陷。三条出路重排: 永远正确的那条(让一方不再提供)排第一,形态切换排最后并写清它的前提。 单测断言的是次序,不是三个子串都在。中英文档同步。 --- ...-08-28-issue519-dependency-linkage-form.md | 58 +++++++++++++++++++ docs/05-mcpp-toml.md | 17 ++++-- docs/zh/05-mcpp-toml.md | 13 ++++- src/build/symbol_provision.cppm | 31 +++++++--- tests/unit/test_symbol_provision.cpp | 14 ++++- 5 files changed, 115 insertions(+), 18 deletions(-) diff --git a/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md b/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md index 2e48039f..0b919df3 100644 --- a/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md +++ b/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md @@ -806,3 +806,61 @@ mcpp 下限跨过 B1 的发布**,而不是「B1 合入了」。这是一条发 2. ⚠️ **「正确的 soname 就一定赢」**(§5.3 末)—— 取决于 `$ORIGIN` 与依赖包 `runtime_search_dirs` 在 `link_line::UnitTail` 里的相对次序,与 **#304** 合流。 两条都**不要写进设计当结论**,它们是待测项。 + +--- + +## 13. 真实验证:用生态自己的 glib 复现 issue §2 + +⚠️ 本节是**实测**,不是设计。用的是 `mcpplibs/mcpp-index#245`(eui-neo 的 Linux SNI +托盘)的需求场景 —— 但**不依赖宿主**:暂存的 glib 及其整条闭包全部取自 xim +(`xim:glib@2.80.0` / `zlib` / `pcre2` / `libselinux` / `util-linux` / `libffi`), +引擎里没有任何 glib / zlib / 托盘的知识。 + +### 13.1 场景 + +`libgio-2.0.so.0` 的 `DT_NEEDED` 里有 `libz.so.1`(实测,不是构造出来的), +而索引里 `compat.zlib` 是 `kind = "lib"`。一个同时依赖两者的工程 = issue §2。 + +### 13.2 读数 + +```console +$ mcpp build +warning: trayapp: 88 symbols in this image are also provided by a library it loads. + adler32() adler32_combine() ... and 82 more + Also provided by: + …/xpkgs/xim-x-zlib/1.3.1/lib/libz.so.1.3.1 +``` + +| | issue §2 报的 | 本次实测 | +|---|---|---| +| exe 里 DEFINED 的 zlib 符号 | 86 | **88** | +| `LD_DEBUG=bindings` 中 libgio 绑到 exe | 12 次 | 同形状,**含 `inflateGetHeader' [ZLIB_1.2.2]`** —— 带版本的引用绑到无版本的定义 | +| 有没有任何工具说话 | **没有** | **mcpp 在构建期指名了两个提供者** | + +⭐ issue 里那条「同一情形在链接期(跨 `.so` 时)是硬错误,在这里静默通过」 +现在有了构建期的声音。 + +### 13.3 ⚠️⚠️ 出路的次序被这次实测改掉了 + +按 `linkage = "shared"` 走一遍: + +| | 之前 | 之后 | +|---|---|---| +| exe 导出的 zlib 符号 | 88 | **0** | +| 诊断 | 报 | 静默 | +| **进程里加载的 zlib** | **1 份** | ⚠️ **2 份**(`libzlib.so` + `libz.so.1`) | + +`readelf -d bin/libzlib.so | grep SONAME` → **没有**。这正是 §5.3 推导出的结果, +现在是实测:**这根轴单独用,会把一个可检测的缺陷换成一个不可检测的缺陷。** + +⇒ 诊断文案里三条出路的**次序**因此改了:第一条必须是永远正确的那条 +(让一方不再提供),形态切换排最后并写清它的前提。单测断言的是**次序**, +不是三个子串都在。 + +### 13.4 这次验证证明了什么 + +1. 引擎在**真实 ELF 闭包**上工作(glib/gio + zlib/pcre2/selinux/mount/ffi), + 不只是在人造 fixture 上; +2. 两段式谓词在真实图上**没有误报**(vendor 包自身的 `.so` 之间不报); +3. `dependency_linkage` 的逃生口在真实图上**确实生效**(88 → 0); +4. ⚠️ 而它的**局限也是真的**,并因此改了文案 —— 这是本次验证最有价值的产出。 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index f8ed6770..679972ce 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -278,10 +278,19 @@ An arrangement with one copy in the process is silent. The verdict is recorded in `target///resolution.json` under `runtime.symbol_provision`, with the count and its denominator, so CI can read it without `readelf`. -It is a warning by default and an error under `--strict`. Ways out, in the -order they usually apply: give the duplicated package a single form with -`dependency_linkage`; stop one side from providing the library; or give both -the same `soname` so they resolve to one file. +It is a warning by default and an error under `--strict`. The ways out are +ordered, and the order matters: + +1. **Stop one side from providing it** — usually a package shipping a copy of a + library the graph already builds. Always correct. +2. **Make both resolve to one file** by declaring the library's real `soname` + on its target. +3. **`dependency_linkage`** changes which form mcpp builds. It removes *this* + finding, but on its own it can leave **two** copies loaded instead of one: + measured on a graph staging glib (whose `libgio` needs `libz.so.1`) beside a + statically built `compat.zlib`, switching the form dropped the executable's + 88 exported symbols and then loaded both `libzlib.so` and `libz.so.1`. It + unifies the two providers only when (2) holds as well. `private_include_dirs` names the entries **of `include_dirs`** that stop at this package's own boundary: this package compiles with them, and a consumer never diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 555f6040..16398453 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -251,9 +251,16 @@ dependency_linkage = "shared" # 按 profile 覆盖 `target///resolution.json` 的 `runtime.symbol_provision` 下,带计数 与分母,CI 不需要 `readelf` 就能读。 -默认是警告,`--strict` 下升级为错误。三条出路,按通常适用的顺序:用 -`dependency_linkage` 给重复的包一个统一形态;让其中一方不再提供这个库;或者给 -两者相同的 `soname`,让它们解析到同一个文件。 +默认是警告,`--strict` 下升级为错误。三条出路**有次序**,而次序是要紧的: + +1. **让其中一方不再提供这个库** —— 通常是那个携带了依赖图已经在构建的库的副本 + 的包。永远正确。 +2. **让两者解析到同一个文件**:在库的 target 上声明它真正的 `soname`。 +3. **`dependency_linkage`** 改变 mcpp 构建的形态。它会消掉**这一条**报告,但单 + 独用可能把一份变成**两份**:实测在一个暂存了 glib(其 `libgio` 需要 + `libz.so.1`)、同时静态构建 `compat.zlib` 的图上,切换形态让可执行文件的 88 + 个导出符号归零,然后 `libzlib.so` 与 `libz.so.1` **两个都被加载**。只有在 + (2) 同时成立时它才真的把两个提供者合成一个。 `private_include_dirs` 指出 **`include_dirs` 中**在本包边界处停住的那些条目: 本包用它们编译,消费者永远收不到。 diff --git a/src/build/symbol_provision.cppm b/src/build/symbol_provision.cppm index f54fe065..956940a0 100644 --- a/src/build/symbol_provision.cppm +++ b/src/build/symbol_provision.cppm @@ -224,19 +224,34 @@ std::string Report::explain(std::string_view artifact) const { body += " Also provided by:\n"; for (auto const& label : providers) body += std::format(" {}\n", label); - // WHY it matters, in the terms the reader can check, then what to do. The - // three ways out are not alternatives to taste: the first changes which - // form mcpp builds, the second removes a provider, the third makes the two - // providers the same file. + // WHY it matters, then what to do — IN THE ORDER THAT ACTUALLY WORKS. + // + // ⚠️ `dependency_linkage = "shared"` is deliberately NOT first, and that + // ordering was corrected against a measurement rather than reasoned. On a + // real graph (a package staging glib, whose libgio pulls libz.so.1, plus a + // statically built compat.zlib) switching the form does remove the + // executable's 88 exported symbols — and then TWO zlibs load, because the + // library mcpp builds is `libzlib.so` while the reference is to + // `libz.so.1`. That is a worse state than the one being reported: two + // copies instead of one, and no diagnostic at all, since the check + // described above only looks at executables. + // + // So the first suggestion is the one that is always correct, and the form + // switch is offered with the condition that makes it work. body += " The executable is searched first, so the copy inside it wins for\n" " every symbol both provide — the library's own copy is never called,\n" " and code inside that library now runs against a build it was not\n" " linked against.\n" - " Ways out: give the duplicated package a single form with\n" - " `[build] dependency_linkage` or a per-dependency `linkage`; stop one\n" - " side from providing it; or give both the same SONAME so they resolve\n" - " to one file."; + " Ways out, in the order they apply:\n" + " 1. stop one side from providing it — usually the package that\n" + " ships a copy of a library the graph already builds;\n" + " 2. make both resolve to ONE file: declare the library's real\n" + " SONAME on its target, so the shared copy and the built one are\n" + " the same name;\n" + " 3. `[build] dependency_linkage` / a per-dependency `linkage`\n" + " changes which form mcpp builds — it removes THIS finding, but\n" + " it only unifies the two providers when (2) holds as well."; return body; } diff --git a/tests/unit/test_symbol_provision.cpp b/tests/unit/test_symbol_provision.cpp index bd9a28d8..b58fcc99 100644 --- a/tests/unit/test_symbol_provision.cpp +++ b/tests/unit/test_symbol_provision.cpp @@ -145,9 +145,17 @@ TEST(SymbolProvision, TheReportNamesEveryProviderAndCapsTheSymbolList) { EXPECT_NE(text.find("20 symbols"), std::string::npos); EXPECT_NE(text.find("and 14 more"), std::string::npos); EXPECT_NE(text.find("/pkg/lib/libz.so.1"), std::string::npos); - // The three ways out are the point of the message. - EXPECT_NE(text.find("dependency_linkage"), std::string::npos); - EXPECT_NE(text.find("SONAME"), std::string::npos); + // The three ways out are the point of the message, and their ORDER is + // load-bearing: switching the form removes this finding while leaving two + // copies loaded unless the SONAMEs also match, so it must not be first. + auto stop = text.find("stop one side"); + auto soname = text.find("SONAME"); + auto form = text.find("dependency_linkage"); + ASSERT_NE(stop, std::string::npos); + ASSERT_NE(soname, std::string::npos); + ASSERT_NE(form, std::string::npos); + EXPECT_LT(stop, soname); + EXPECT_LT(soname, form); } TEST(SymbolProvision, OnlyAConflictIsActionable) { From 8aad64d1c8f2015b1fc0e868e3a6f5be09264a8c Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 28 Aug 2026 09:09:29 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(#519):=20=E8=87=AA=E6=88=91=20review=20?= =?UTF-8?q?=E7=9A=84=E4=B8=89=E5=A4=84,=E5=85=B6=E4=B8=AD=E4=B8=80?= =?UTF-8?q?=E5=A4=84=E8=AE=A9=E6=89=93=E5=87=BA=E6=9D=A5=E7=9A=84=E5=8C=85?= =?UTF-8?q?=E8=B5=B7=E4=B8=8D=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ⚠️⚠️ `mcpp pack` 不收合成的共享库,包解开就起不来 判据 12 是为「这是推理不是测量」写的,而推理错了。实测: $ ./app error while loading shared libraries: libcore.so `ldd_parse` 追的是**暂存目录里的副本**,而那个库是靠 `$ORIGIN` 找到的 —— 副本旁边的 `bin/` 是空的,于是它从不出现在闭包里,也就从不被打包。 构建、打包、上传全程无话,失败发生在用户机器上。 ⚠️ **不是本 PR 引入的**:在 mcpp 2026.8.26.1 上用作者声明的 `kind = "shared"` 依赖复现,同样起不来。但这根轴把它从「12 个自称 shared 的包」 变成「任何一个包」都可达 —— 与 PIC 进缓存键同一条理由,所以在这里修。 改为追**构建出来的**二进制:暂存文件是它的逐字节副本,此刻两者都没被改过 (patchelf 在更后面),变的只是 `$ORIGIN` 展开到哪个目录。 ## ⚠️ 未变更产物的判定会从记录里消失 跳过 stat 未动的产物是对的(loader-tag 那条实测过 158.7s/190s),但不能连 判定一起丢:workspace 一次只重链一个成员,`mcpp test` 在已构建的树上一测一驱动。 照 loader-tag 的既有做法从 `resolution.json` 读回,而「没有条目」恰好与 「检查过且干净」读数相同 —— 这正是要避免的。 ## ⚠️ 链接单元的匹配拼法与快照不一致 `snapshot_link_artifacts` 用 `outputDir / output`,我这边多了一次 `lexically_normal()`。不匹配时静默丢掉该单元自己的 ldflags。改为逐字同拼。 判据:e2e 306 增加第四段(打包 → 解开 → **跑起来**);14 个适用的 pack e2e 全绿。 --- src/build/runtime_validation.cppm | 94 ++++++++++++++++++++---- src/pack/pack.cppm | 27 ++++++- tests/e2e/306_dependency_linkage_axis.sh | 29 +++++++- 3 files changed, 131 insertions(+), 19 deletions(-) diff --git a/src/build/runtime_validation.cppm b/src/build/runtime_validation.cppm index e7bc1f8a..1a170dc3 100644 --- a/src/build/runtime_validation.cppm +++ b/src/build/runtime_validation.cppm @@ -717,6 +717,38 @@ check_symbol_provision(const mcpp::build::BuildPlan& plan, auto searchDirs = runtime_search_dirs(plan); + // ⚠️ WHAT AN UNCHANGED ARTIFACT KEEPS. + // + // Re-parsing every image on every drive is what made the loader-tag check + // cost 158.7s of a 190s hot run, so an artifact whose stat did not move is + // skipped here too. But skipping it must not DROP its verdict: `mcpp test` + // drives the backend once per test on an already-built tree, and a + // workspace relinks one member at a time. Without this read-back the + // record would shrink to "whatever moved last", a conflict found on + // Monday would stop being reported on Tuesday, and — worse — the absence + // of an entry would read exactly like "checked and clean". + const auto resolutionPath = plan.outputDir / "resolution.json"; + nlohmann::json resolution; + { + std::ifstream input(resolutionPath); + resolution = nlohmann::json::parse(input, nullptr, false); + } + const auto recorded = [&]() -> nlohmann::json { + auto rt = resolution.is_object() ? resolution.find("runtime") + : resolution.end(); + if (rt == resolution.end() || !rt->is_object()) + return nlohmann::json::array(); + auto entries = rt->find("symbol_provision"); + if (entries == rt->end() || !entries->is_array()) + return nlohmann::json::array(); + return *entries; + }(); + auto stored_for = [&](const std::string& rel) -> const nlohmann::json* { + for (auto const& entry : recorded) + if (entry.is_object() && entry.value("path", "") == rel) return &entry; + return nullptr; + }; + // Symbol tables of closure objects, parsed at most once per file. Several // images in one build share almost their whole closure. std::map> closureCache; @@ -733,23 +765,58 @@ check_symbol_provision(const mcpp::build::BuildPlan& plan, return closureCache.emplace(object, std::move(names)).first->second; }; + bool anyFresh = false; for (auto const& [artifact, oldStamp] : before) { auto now = stamp(artifact); if (!now.exists) continue; - // Only images this run actually produced. An unchanged artifact was - // judged by an earlier run and re-parsing it every build would make a - // no-op build pay for a symbol-table walk it cannot learn from. - if (now == oldStamp) continue; + + std::error_code relEc; + auto rel = std::filesystem::relative(artifact, plan.outputDir, relEc); + auto relStr = (relEc ? artifact : rel).lexically_normal().generic_string(); + + // Only images this run actually produced get re-read. An unchanged one + // keeps the verdict already on file (see the note above); with none on + // file it falls through and is read, so the first build after this + // record appeared does not report "never checked" forever. + if (now == oldStamp) { + if (auto const* prev = stored_for(relStr)) { + sp::Report kept; + auto status = prev->value("status", ""); + kept.status = status == "clean" ? sp::Status::Clean + : status == "conflict" ? sp::Status::Conflict + : status == "not-applicable" ? sp::Status::NotApplicable + : sp::Status::NotEvaluated; + kept.exported = prev->value("exported", std::size_t{0}); + kept.total = prev->value("dynamic_symbols", std::size_t{0}); + kept.reason = prev->value("reason", ""); + if (auto c = prev->find("conflicts"); + c != prev->end() && c->is_array()) { + for (auto const& entry : *c) { + sp::Conflict conflict; + conflict.name = entry.value("symbol", ""); + conflict.isFunc = entry.value("kind", "") == "func"; + if (auto by = entry.find("also_provided_by"); + by != entry.end() && by->is_array()) + for (auto const& label : *by) + if (label.is_string()) + conflict.alsoProvidedBy.push_back(label); + kept.conflicts.push_back(std::move(conflict)); + } + } + findings.push_back({artifact, std::move(kept)}); + continue; + } + } + anyFresh = true; // Which link unit is this? Its own flags matter as much as the // global ones, and the static side of any report is attributed from - // the objects it links. + // the objects it links. Spelled EXACTLY as `snapshot_link_artifacts` + // spells it, so the two cannot disagree about which key names which + // artifact — a mismatch here loses the unit's own flags silently. const mcpp::build::LinkUnit* unit = nullptr; for (auto const& lu : plan.linkUnits) { - if ((plan.outputDir / lu.output).lexically_normal() == artifact) { - unit = &lu; - break; - } + if (plan.outputDir / lu.output == artifact) { unit = &lu; break; } } auto facts = mcpp::platform::elf::inspect_elf_runtime(artifact); @@ -821,13 +888,8 @@ check_symbol_provision(const mcpp::build::BuildPlan& plan, // scrolls past, and `resolution.json` is what CI, `mcpp why runtime` and a // test can read. It also gives a test a FIELD to assert on instead of a // substring of a message — a message whose wording is free to improve. - if (!findings.empty()) { - const auto path = plan.outputDir / "resolution.json"; - nlohmann::json resolution; - { - std::ifstream input(path); - resolution = nlohmann::json::parse(input, nullptr, false); - } + if (!findings.empty() && anyFresh) { + const auto& path = resolutionPath; if (!resolution.is_discarded() && resolution.is_object()) { if (auto runtime = resolution.find("runtime"); runtime != resolution.end() && runtime->is_object()) { diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index 1ce351a7..99f9d01c 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -1126,9 +1126,32 @@ run(const Plan& plan, const mcpp::config::GlobalConfig& cfg) // point becomes `run.sh` which invokes the bundled ld with // --library-path → fully portable across glibc versions. if (plan.opts.mode != Mode::Static) { - auto deps = ldd_parse(bundledBinary); + // ⚠️ THE BUILT BINARY, NOT THE STAGED COPY, and the difference is + // `$ORIGIN`. + // + // The closure is resolved by running the artifact under its own + // loader. A dependency that mcpp itself built as a shared library + // lives beside the artifact in `bin/` and is found through the + // `$ORIGIN` in its RUNPATH — but the copy in the staging directory has + // an empty `bin/` next to it, so that entry resolves to nothing, the + // library never appears in the closure, and it is therefore never + // bundled. The bundle builds, packs and uploads without complaint, and + // fails on the user's machine with + // `error while loading shared libraries: libfoo.so`. + // + // Measured on mcpp 2026.8.26.1 with an author-declared + // `kind = "shared"` dependency, so this predates the consumer-side + // form axis (#519) — but that axis is what makes it reachable for + // EVERY package rather than the handful that declare themselves + // shared, which is why it is fixed here. + // + // Tracing the built binary is safe: the staged file is a byte copy of + // it and nothing has modified either one at this point (patchelf runs + // further down). What changes is only which directory `$ORIGIN` + // expands to while the loader is looking. + auto deps = ldd_parse(plan.builtBinary); if (!deps) return std::unexpected(Error{std::format( - "ldd failed on {}: {}", bundledBinary.string(), deps.error())}); + "ldd failed on {}: {}", plan.builtBinary.string(), deps.error())}); std::vector toBundle; for (auto& d : *deps) { diff --git a/tests/e2e/306_dependency_linkage_axis.sh b/tests/e2e/306_dependency_linkage_axis.sh index da0b12bc..e7627e15 100755 --- a/tests/e2e/306_dependency_linkage_axis.sh +++ b/tests/e2e/306_dependency_linkage_axis.sh @@ -122,4 +122,31 @@ done exit 1 } -echo "ok: default static ($static_fp), shared ($shared_fp), symbol imported" +# ── 4. the bundle a shared build produces must actually start ────────────── +# +# ⚠️ A `.so` that is built and then not shipped is the worst outcome of this +# axis: everything is green locally, the archive uploads, and it fails on the +# user's machine with `error while loading shared libraries`. The closure is +# resolved by RUNNING the artifact, and the library is found through `$ORIGIN` +# — which points at an empty directory if the copy in the staging tree is the +# one being traced. +"$MCPP" pack > pack.log 2>&1 || { cat pack.log; echo "FAIL: pack"; exit 1; } +archive="$(find target/dist -name '*.tar.gz' | head -1)" +[[ -n "$archive" ]] || { cat pack.log; echo "FAIL: no archive"; exit 1; } + +extract="$(mktemp -d)" +tar xzf "$archive" -C "$extract" +bundled="$(find "$extract" -name 'libcore.so' | head -1)" +[[ -n "$bundled" ]] || { + echo "FAIL: the shared dependency was not bundled" + find "$extract" -type f | head -20 + rm -rf "$extract"; exit 1 +} +packed="$(find "$extract" -name app -type f -perm -u+x | head -1)" +[[ -n "$packed" ]] || { echo "FAIL: no executable in the bundle"; rm -rf "$extract"; exit 1; } +out="$("$(cd "$(dirname "$packed")" && pwd)/$(basename "$packed")" 2>&1)" || { + echo "FAIL: the extracted bundle does not start: $out"; rm -rf "$extract"; exit 1; } +[[ "$out" == "42" ]] || { echo "FAIL: bundle printed '$out'"; rm -rf "$extract"; exit 1; } +rm -rf "$extract" + +echo "ok: default static ($static_fp), shared ($shared_fp), symbol imported, bundle runs" From ab778e871ca39da764450735c8efaa34b1042403 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 28 Aug 2026 09:19:29 +0800 Subject: [PATCH 4/8] =?UTF-8?q?docs(#519):=20=E8=AE=B0=E4=B8=8B=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E5=A4=84=E8=A2=AB=E5=AE=9E=E6=B5=8B=E6=8E=A8=E7=BF=BB?= =?UTF-8?q?=E7=9A=84=E6=8E=A8=E7=90=86(pack=20=E4=B8=8D=E6=94=B6=E5=90=88?= =?UTF-8?q?=E6=88=90=E7=9A=84=E5=85=B1=E4=BA=AB=E5=BA=93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-08-28-issue519-dependency-linkage-form.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md b/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md index 0b919df3..7c751ba4 100644 --- a/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md +++ b/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md @@ -864,3 +864,32 @@ warning: trayapp: 88 symbols in this image are also provided by a library it loa 2. 两段式谓词在真实图上**没有误报**(vendor 包自身的 `.so` 之间不报); 3. `dependency_linkage` 的逃生口在真实图上**确实生效**(88 → 0); 4. ⚠️ 而它的**局限也是真的**,并因此改了文案 —— 这是本次验证最有价值的产出。 + +### 13.5 ⚠️⚠️ 第三处:打出来的包起不来 + +§11.1 第 5 条写的是「大概率经 `$ORIGIN` 的闭包被捞到 —— 但这是推理不是测量」。 +判据 12 就是为它写的,而**推理错了**: + +```console +$ mcpp pack && tar xzf … && ./app +error while loading shared libraries: libcore.so +``` + +`pack` 的闭包来自**运行产物**(`LD_TRACE_LOADED_OBJECTS`),而它运行的是 +**暂存目录里的副本** —— 那个副本旁边的 `bin/` 是空的,`$ORIGIN` 解析不到, +库于是从不出现在闭包里,也就从不被打包。构建、打包、上传全程无话。 + +⚠️ **不是这根轴引入的**:在 mcpp 2026.8.26.1 上用作者声明的 `kind = "shared"` +依赖复现,同样起不来。但轴把它从「12 个自称 shared 的包」变成「任何一个包」 +都可达 —— 与 PIC 进缓存键完全同型,所以同一个 PR 修掉。 + +### 13.6 三条被实测推翻的推理,一次会话 + +| # | 我写的 | 实测 | +|---|---|---| +| §11.1-5 | 「大概率被 `$ORIGIN` 闭包捞到」 | ⚠️ 没有。包解开就起不来 | +| §12.7 文案 | 三条出路,`dependency_linkage` 排第一 | ⚠️ 它会把 1 份变 2 份;次序要倒过来 | +| §12.2 | (推导)shared→static 是良性的 | ✅ 实测确认,且**第一轮的谓词会误报它** | + +⭐ 前两条都是**推理写在文档里、判据写在测试里、然后测试推翻了推理**。 +第三条是推理被证实 —— 但只有把它写成判据才发现第一轮的谓词错了。 From 7072ea68652b8ad474d827e9eacc0e7f83cb4a96 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 28 Aug 2026 09:34:55 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(#519):=20=E4=BE=9D=E8=B5=96=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E7=9A=84=20feature=20=E9=97=A8=E5=8F=AA=E7=AE=A1?= =?UTF-8?q?=E5=BA=93=E7=9B=AE=E6=A0=87=20=E2=80=94=E2=80=94=20=E4=B8=BB?= =?UTF-8?q?=E6=9C=BA=E5=B7=A5=E5=85=B7=E4=B8=8D=E6=98=AF=E8=A2=AB=E9=97=A8?= =?UTF-8?q?=E6=8E=A7=E7=9A=84=E5=AF=B9=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `187_dep_host_tool.sh` 红。上一版的门抹掉了依赖包里**任何**种类的 feature-gated 目标,并附了一句「主机工具那条路会以依赖为根重进 prepare_build,所以到不了这里」—— 那句是**凭印象写的,不是读出来的**: 工具的查找在这段代码**下面几百行**,对着的正是这份 manifest,于是它读到 一个空的目标表。 被请求为主机工具的目标是**被要求的东西**,它的 `required_features` 是那次 子构建的**输入**而不是门(docs/05 §2.2 原文如此)。 收窄到库目标不是绕过,而是规则本身:依赖包的 `bin` 目标在本次构建里不产生 任何链接单元(make_plan 只走根的目标),留着它零成本;门要管的是「一个目标 仅仅存在就改变整个包对所有消费者的链接方式」,而那恰好就是 `shared` / `lib`。 本机:187 与 308 同时绿。 --- src/build/prepare.cppm | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 32db6885..d3319245 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -5768,13 +5768,29 @@ prepare_build(bool print_fingerprint, // feature name is package-scoped, so the root's set is a different // vocabulary that happens to share a type. // - // ⚠️ ONE EXCEPTION, and it is not a second rule: a target - // requested as a host tool is what was ASKED FOR, so its - // `required_features` become that sub-build's inputs instead of a - // gate (docs/05 §2.2). That path re-enters prepare_build with the - // dependency as the ROOT, so it never reaches this code. + // ⚠️⚠️ LIBRARY TARGETS ONLY, and the exclusion is load-bearing. + // + // A target requested as a HOST TOOL is what was ASKED FOR, so its + // `required_features` become that sub-build's INPUTS instead of a + // gate — docs/05 §2.2 says so in as many words. An earlier + // revision of this gate erased every kind, with a comment claiming + // the tool path "re-enters prepare_build with the dependency as + // the ROOT, so it never reaches this code". That was written from + // memory rather than read: the tool LOOKUP runs several hundred + // lines BELOW this point, against this very manifest, and it found + // an empty target list. `187_dep_host_tool.sh` caught it. + // + // Restricting the gate to libraries is not a workaround, it is the + // rule: a dependency's `bin` target produces no link unit in this + // build (make_plan only walks the ROOT's targets), so leaving it in + // place costs nothing. What the gate exists for is the shape where + // a target's mere presence changes how the package is linked into + // every consumer — and that is exactly a `shared` or `lib` target. std::erase_if(pkg.manifest.targets, [&](const mcpp::manifest::Target& t) { + if (t.kind != mcpp::manifest::Target::Library + && t.kind != mcpp::manifest::Target::SharedLibrary) + return false; for (auto const& rf : t.requiredFeatures) if (std::find(active.begin(), active.end(), rf) == active.end()) return true; @@ -7840,6 +7856,15 @@ prepare_build(bool print_fingerprint, if (answer.linkage != lf::DepLinkage::Shared) continue; if (facts.isDistribution) continue; // nothing here to build + // ⚠️ A package that ALREADY declares a shared target has decided + // for itself, and its remaining library targets are not part of + // that decision. Flipping them would change what such a package + // builds under the DEFAULT request, which is the one property this + // axis promises never to touch. (No package in mcpp-index has both + // shapes at once — compat.vulkan's `lib` is overridden to `shared` + // on Linux rather than joined by it — but "unreachable today" is + // how the last few of these got in.) + if (facts.declaredShared) continue; for (auto* t : libraryTargets) t->kind = mcpp::manifest::Target::SharedLibrary; } From 7c7031992b6872cb41f8a1deb247285df3b47083 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 28 Aug 2026 09:38:14 +0800 Subject: [PATCH 6/8] =?UTF-8?q?refactor(#519):=20=E8=AE=A9=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E8=AE=A4=E5=BE=97=E5=B8=A6=E7=89=88=E6=9C=AC=E7=9A=84?= =?UTF-8?q?=E6=A0=87=E7=AD=BE,=E8=80=8C=E4=B8=8D=E6=98=AF=E6=8A=8A?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E6=8D=A2=E6=88=90=E8=A3=B8=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消费者用 FQN 或裸名寻址依赖,而每条消息都想要版本号。原先的做法是把 label 换成命中的那个拼法 —— 于是每条拒绝消息都丢掉了版本。改成让请求 也认得描述性的 label。 复验:306/307/308/187 全绿;`"compat.zlib" = { linkage = "shared" }` 仍然产出 libzlib.so。 --- src/build/prepare.cppm | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index d3319245..3460cd2d 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -7839,15 +7839,21 @@ prepare_build(bool print_fingerprint, libraryTargets.push_back(&t); } - // The request is addressed by whatever the root wrote, so both - // spellings are looked up. `resolve` takes the label; give it the - // one the request can match. - lf::PackageFacts addressed = facts; + // A consumer addresses a dependency by whatever it wrote in + // `[dependencies]` — the fully-qualified name or the bare one — + // while every message wants the version too. Rather than swapping + // the label to whichever spelling matches (which drops the version + // from every refusal), make the request answer to the descriptive + // label as well. for (auto const& key : { fq, pkg.package.name }) { - if (request.perPackage.contains(key)) { addressed.label = key; break; } + if (auto it = request.perPackage.find(key); + it != request.perPackage.end()) { + request.perPackage.emplace(facts.label, it->second); + break; + } } - auto allowed = lf::admissible(addressed, targetFacts); - auto answer = lf::resolve(addressed, allowed, request); + auto allowed = lf::admissible(facts, targetFacts); + auto answer = lf::resolve(facts, allowed, request); if (!answer.diagnostic.empty()) mcpp::diag::degraded("build/dependency-linkage", answer.diagnostic, From b50a8d4c2bac4b937b29e94eac1b04fd4d22a8ae Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 28 Aug 2026 09:41:28 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs(#519):=20CHANGELOG=20=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=20`mcpp=20pack`=20=E4=B8=8D=E6=94=B6=E5=90=88?= =?UTF-8?q?=E6=88=90=E5=85=B1=E4=BA=AB=E5=BA=93=E9=82=A3=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 它是用户可见的修复,而且是本轮实测推翻推理最贵的一条:构建、打包、上传 全程无话,失败发生在用户机器上。 --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cd993f8..472363af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,21 @@ `dependency_linkage` 同样进了工程指纹 —— 否则切换开关会复用上一次配置的 构建目录(实测:两次构建落在同一个 `target/x86_64-linux-gnu//`)。 +- **⚠️⚠️ `mcpp pack` 不收合成的共享库,包解开就起不来。** + + ```console + $ ./app + error while loading shared libraries: libcore.so + ``` + + 闭包来自**运行产物**,而运行的是**暂存目录里的副本** —— 副本旁边的 `bin/` + 是空的,`$ORIGIN` 解析不到,库于是从不出现在闭包里,也就从不被打包。 + 构建、打包、上传全程无话,失败发生在用户机器上。 + + ⚠️ **不是本版引入的**:在 2026.8.26.1 上用作者声明的 `kind = "shared"` + 依赖同样复现。但 `dependency_linkage` 把它从「12 个自称 shared 的包」 + 变成「任何一个包」都可达,所以在这里修。 + - **⚠️ 在非 shared 目标上写 `soname` 会让整份 manifest 加载失败。** `soname` 是一个库被**找到**时用的名字,也是 mcpp 构建的那份与第三方携带的 From f40799f1577c89ddc99bd289d5a8a1ed615a88a0 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 28 Aug 2026 10:16:03 +0800 Subject: [PATCH 8/8] =?UTF-8?q?docs(#519):=20=E8=90=BD=E5=9C=B0=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=20=E2=80=94=E2=80=94=20=E4=B8=A4=E6=9D=A1=E8=A2=AB?= =?UTF-8?q?=E4=B8=8B=E9=99=90=E6=8C=A1=E4=BD=8F=E7=9A=84=E5=90=8E=E7=BB=AD?= =?UTF-8?q?,=E4=B8=8E=E7=94=9F=E6=80=81=E4=BE=A7=E7=9A=84=20CN=20=E9=95=9C?= =?UTF-8?q?=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §14.6 记下 mcpp#522(feature 带不了 ldflags,所以 eui-neo 的托盘做不成 feature)与 mcpp-index#270(compat.zlib 的 soname)。两条是同一个形状: 一个键能不能发,判据是索引 latest 的下限,不是引擎支持了没有。 §14.7 记下 CN 镜像,并推翻一条照抄的理由 —— gitcode 的 tarball 保留 wrap 层, 与 GitHub 是同一份字节。 --- ...-08-28-issue519-dependency-linkage-form.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md b/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md index 7c751ba4..c3225dfc 100644 --- a/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md +++ b/.agents/docs/2026-08-28-issue519-dependency-linkage-form.md @@ -893,3 +893,92 @@ error while loading shared libraries: libcore.so ⭐ 前两条都是**推理写在文档里、判据写在测试里、然后测试推翻了推理**。 第三条是推理被证实 —— 但只有把它写成判据才发现第一轮的谓词错了。 + +--- + +## 14. 落地记录 + +发布为 **mcpp 2026.8.28.2**(PR `mcpp-community/mcpp#521`), +生态侧 `mcpplibs/mcpp-index#269`(eui-neo Linux SNI 托盘)。 + +### 14.1 新增的两个模块 + +| 模块 | 是什么 | +|---|---| +| `mcpp.build.linkage_form` | 纯函数、表驱动:`admissible(pkg, format, libcLinkage)` ∩ 请求 → `DepLinkage`。无文件系统、无 manifest 知识 | +| `mcpp.build.symbol_provision` | 纯策略:两段式谓词 + 归因 + 文案。ELF 解析在 `elf_runtime`,I/O 在 `runtime_validation` | + +⭐ **形态被物化成 target kind**,于是 ELF 的 soname/`$ORIGIN`、PE 的导入库与 +自动 `.def`、Mach-O 的 install name **一行新代码都不用写** —— 三种格式的 +既有发射器原样适用。这是「不设第四层」那个决定买到的东西。 + +### 14.2 一个新键名,两张已有的表 + +`[build] dependency_linkage` + `[profile.*]` 覆盖 + 依赖边上的 `linkage` +(**仅根工程**)。`[target.]` 那张表在第二轮 review 里撤回了(§12.4)。 + +**索引描述符零新键。** + +### 14.3 修掉的四条,其中三条不是这根轴引入的 + +| | 谁引入的 | 为什么在这个 PR 里修 | +|---|---|---| +| 依赖包的 `required_features` 从不生效 | 既有 | 轴让「一个目标仅仅存在就改变链接方式」变成常态 | +| `-fPIC` 不在缓存键里 | 既有(良性) | 轴让它**可达**:非 PIC 对象喂给共享链接 | +| `mcpp pack` 不收合成的共享库 | 既有(2026.8.26.1 复现) | 同上:从 12 个包变成任何一个包 | +| `soname` 在非 shared 目标上硬失败 | 既有 | 它是「两个提供者合成一个」的唯一途径 | + +⭐ 三条都是**同一个形状**:一个缺陷在旧模型下不可达或良性,而新的自由度把它 +变成日常路径。判据不是「是不是我写坏的」,而是「这根轴有没有让它变得可达」。 + +### 14.4 这次会话里被实测推翻的推理 + +| # | 写下的推理 | 实测 | 代价 | +|---|---|---|---| +| 1 | `$ORIGIN` 闭包大概率捞得到合成的 `.so` | ⚠️ 捞不到,包解开就起不来 | 判据 12 | +| 2 | 三条出路里 `dependency_linkage` 排第一 | ⚠️ 它把 1 份变 2 份 | 文案重排 + 单测断言次序 | +| 3 | 主机工具那条路到不了这段代码 | ⚠️ 到得了,查找在下面几百行 | `187_dep_host_tool` 红 | +| 4 | 「零 diff」用旧两版的二进制对照 | ⚠️ 对照物错了(路径不同 + 版本差两代) | 重做后指纹逐字节相同 | + +⭐⭐ 第 3 条和第 4 条是同一条教训的两面:**理由凭印象写、判据施加在错误的对象上**。 +仓库的记忆里两条都有名字,而它们在一次会话里各自又发生了一遍。 + +### 14.5 生态侧 + +`compat.eui-neo` 的 Linux 腿接上 SNI 托盘,glib 取自 `xim:glib`, +它的闭包(zlib/pcre2/libffi/libselinux/util-linux)**不暂存**,经 subos 库视图解析。 +⚠️ 不暂存 `libz` 是为了不制造第二个提供者 —— 也就是不制造这条 issue 本身。 + +实测 `eui_tray_init -> 1`(真的在会话总线上注册)。 + +⚠️ 遗留:同时用到 `compat.zlib` 的消费者仍会收到冲突警告。根治要 +`compat.zlib` 声明 `soname = "libz.so.1"`,而那要等索引 `latest` 的 mcpp 下限 +跨过 2026.8.28.2 —— 正是 §11.3 预判的那条发布顺序。 + +### 14.6 已开出的两条被下限挡住的后续 + +| issue | 内容 | 前置判据 | +|---|---|---| +| `mcpplibs/mcpp-index#270` | `compat.zlib` 声明 `soname = "libz.so.1"` | ⚠️ 索引 `latest` 的 mcpp 下限跨过 2026.8.28.2 —— 旧客户端读到的是**加载失败**,不是忽略 | +| `mcpp-community/mcpp#522` | xpkg 的 feature 能带 `ldflags` / `include_dirs` | 实现后同样要等下限;⚠️ 还要先答「feature 的 ldflags 是私有还是沿 Public 边传播」 | + +⭐ 两条是同一个形状,而这个形状本身是这条 issue 的第三个产物: +**一个键能不能发,判据不是「引擎支持了吗」,而是「索引 `latest` 的下限跨过去了吗」。** +`soname` 在这里第一次以「硬失败」的形式把它演示了一遍。 + +⭐ `#522` 还回答了生态侧一个具体问题:eui-neo 的托盘**能不能做成 feature**。 +不能 —— feature 带得了 `defines`,带不了 `ldflags`,于是关掉 feature 的消费者 +照样链 glib:代价一样,托盘没有。所以它默认开启,理由写进了描述符。 + +### 14.7 生态侧的一个副产物:CN 镜像 + +`compat.eui-neo` 的 0.5.5 与 0.5.7 此前在 gitcode 上 404(0.5.5 的注释还写着 +「从未发布到 mcpp-res」)。本地 `gtc` 各发一次并**重新下载核对**: +sha256 与描述符声明一致,wrap 层一致。三个平台腿都改成 `{ GLOBAL, CN }`。 + +⚠️ 顺带推翻了一条照抄的理由:`#245` 说「CN 镜像的 tarball 不保留 wrap 层」。 +去查了 —— gitcode 的 `eui-neo-0.5.6.tar.gz` 解出来就是 `EUI-NEO-0.5.6/`, +与 GitHub 的是同一份字节(一个 `sha256` 服务两个 URL,本来就要求如此)。 + +⚠️ 诚实的边界:CN 资产**已发布并逐字节核过**,但没有再做一次「把 GLOBAL 弄坏 +再构建」的强制走 CN 路径实验 —— 那次尝试把磁盘写满了,而它能加的证据有限。