Skip to content

Commit d4dd905

Browse files
committed
fix: stop poisoning the run target's LD_LIBRARY_PATH with the private glibc (#291)
Also corrects the pin invariant this branch's own guard got wrong, which is what turned every CI job red. #291 — private glibc payload on LD_LIBRARY_PATH ----------------------------------------------- LD_LIBRARY_PATH is inherited by the entire process subtree. When the run target is something that shells out — a course provider calling popen("mcpp test ...") — /bin/sh is a HOST binary: its PT_INTERP is baked in, so it loads the HOST ld.so, while this variable hands it the PAYLOAD libc.so.6. glibc's libc and ld.so are version-locked through GLIBC_PRIVATE (verified: the payload libc.so.6 carries a GLIBC_PRIVATE dependency on ld-linux-x86-64.so.2), so on any host whose glibc differs from the payload's the shell dies of SIGSEGV inside the dynamic linker, before main, with empty stdout and no diagnostic. This is NOT "compiled on a different build machine, ABI-incompatible" as originally reported — it is plain version mismatch, and it hits every user whose host glibc differs. It survived this long precisely because it does not reproduce when the two happen to match. The payload dir is the one entry on LD_LIBRARY_PATH that is not also in the executable's RUNPATH — flags.cppm excludes it deliberately to keep static and musl links clean. Its only purpose is letting a dlopen()'d library, whose own DT_NEEDED closure never consults the executable's RUNPATH, resolve the same libc. So emit it only when the build actually has such a library (depRuntimeLibraryDirs non-empty). The real host-GL passthrough case reaches mcpp through compat.glx-runtime's `[runtime] library_dirs`, which populates exactly that vector, so it is unaffected; a zero-dependency binary no longer gets it. process.cppm's strip_private_glibc already protects mcpp's own children. It cannot reach one hop further out — mcpp sets the variable for the target on purpose, and what the target spawns is beyond its control. Not emitting it when unnecessary is within its control. e2e 166 pins both directions: a zero-dependency project must NOT receive it, a project with `[runtime] library_dirs` MUST. It asserts on the emitted environment rather than on whether a shell crashes — a crash-based test passes for the wrong reason wherever host and payload glibc match, which is how this went unnoticed. e2e 65 dropped its glibc-payload assertion: its fixture has no dependencies, so it never modelled the dlopen case its own header describes. Pin guard: the bootstrap pin is SUPPOSED to lag ----------------------------------------------- The guard added earlier in this branch asserted that all four version locations are equal. That is wrong, and CI proved it — every job failed with `package 'mcpp@2026.7.27.1' not found`. .xlings.json and MCPP_PIN name a mcpp that is already published, so they must agree with each other but must NOT track the version being built; they are bumped in a separate commit after the release lands in xim-pkgindex (as ci-fresh-install.yml's own comment says). Both are back to 0.0.109. The guard now checks three invariants instead: mcpp.toml == MCPP_VERSION; the two bootstrap sites agree; and the bootstrap pin is never NEWER than the version being built — a four-key numeric sort, so the date scheme orders correctly. That last check reproduces the exact CI failure locally in under a second.
1 parent 45d514f commit d4dd905

7 files changed

Lines changed: 179 additions & 14 deletions

File tree

.github/tools/check_version_pins.sh

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,18 +107,41 @@ v_xl=$(grep -oE '"mcpp"[[:space:]]*:[[:space:]]*"[^"]+"' .xlings.json \
107107
v_pin=$(grep -oE "MCPP_PIN:[[:space:]]*'[^']+'" .github/workflows/ci-fresh-install.yml \
108108
| grep -oE "'[^']+'" | tr -d "'" | head -1)
109109

110-
note "mcpp version: mcpp.toml=$v_toml fingerprint=$v_src .xlings.json=$v_xl MCPP_PIN=$v_pin"
111-
112-
for pair in "mcpp.toml:$v_toml" \
113-
"src/toolchain/fingerprint.cppm:$v_src" \
114-
".xlings.json:$v_xl" \
115-
".github/workflows/ci-fresh-install.yml (MCPP_PIN):$v_pin"; do
116-
where="${pair%:*}"; val="${pair##*:}"
117-
[ -n "$val" ] || { bad "$where — could not read the mcpp version"; continue; }
118-
[ "$val" = "$v_toml" ] || bad "$where has '$val' but mcpp.toml has '$v_toml'"
110+
note "mcpp version: building=$v_toml (fingerprint=$v_src) bootstrap pin=$v_xl (MCPP_PIN=$v_pin)"
111+
112+
for n in "mcpp.toml:$v_toml" "src/toolchain/fingerprint.cppm:$v_src" \
113+
".xlings.json:$v_xl" "ci-fresh-install.yml MCPP_PIN:$v_pin"; do
114+
[ -n "${n##*:}" ] || bad "${n%:*} — could not read the mcpp version"
119115
done
120116

117+
# (a) The version being BUILT: mcpp.toml and the compiled-in constant are the
118+
# same number by definition — release.yml derives the tag from the former
119+
# and the smoke test greps the latter out of `mcpp --version`.
120+
[ -z "$v_src" ] || [ "$v_src" = "$v_toml" ] \
121+
|| bad "src/toolchain/fingerprint.cppm has '$v_src' but mcpp.toml has '$v_toml'"
122+
123+
# (b) The version BOOTSTRAPPED FROM: both sites name a mcpp that is already
124+
# published, so they must agree with each other — but they are NOT required
125+
# to equal the version being built. They deliberately lag, and are bumped in
126+
# a separate commit AFTER the release exists in xim-pkgindex (see the
127+
# MCPP_PIN comment in ci-fresh-install.yml). Requiring equality here is what
128+
# an earlier revision of this script got wrong: it sent CI to install a
129+
# version that did not exist yet, and every job died with
130+
# `package 'mcpp@<unreleased>' not found`.
131+
[ -z "$v_xl" ] || [ -z "$v_pin" ] || [ "$v_xl" = "$v_pin" ] \
132+
|| bad ".xlings.json pins '$v_xl' but ci-fresh-install.yml MCPP_PIN is '$v_pin' — both bootstrap the same released mcpp"
133+
134+
# (c) …and the bootstrap pin must never run AHEAD of the version being built.
135+
# Four-key numeric sort, so the date scheme orders correctly (a plain
136+
# sort would put 2026.7.27.10 below 2026.7.27.9).
137+
if [ -n "$v_xl" ] && [ -n "$v_toml" ] && [ "$v_xl" != "$v_toml" ]; then
138+
newest=$(printf '%s\n%s\n' "$v_xl" "$v_toml" \
139+
| sort -t. -k1,1n -k2,2n -k3,3n -k4,4n | tail -1)
140+
[ "$newest" = "$v_toml" ] \
141+
|| bad "bootstrap pin '$v_xl' is NEWER than the version being built ('$v_toml') — CI would try to install an unreleased mcpp"
142+
fi
143+
121144
if [ "$fail" = 0 ]; then
122-
echo "OK: xlings pins all at $XLINGS_EXPECTED; mcpp version $v_toml consistent in 4 places" >&2
145+
echo "OK: xlings pins all at $XLINGS_EXPECTED; building mcpp $v_toml, bootstrapping from $v_xl" >&2
123146
fi
124147
exit "$fail"

.github/workflows/ci-fresh-install.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ concurrency:
4040
# fails with `version not found` instead of silently testing an older
4141
# binary. Bump together with the .xlings.json workspace pin at release.
4242
env:
43-
MCPP_PIN: '2026.7.27.1'
43+
MCPP_PIN: '0.0.109'
4444

4545
jobs:
4646
# ──────────────────────────────────────────────────────────────────

.xlings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"workspace": {
3-
"mcpp": "2026.7.27.1"
3+
"mcpp": "0.0.109"
44
}
55
}

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@
1515

1616
### 修复
1717

18+
- **[#291](https://github.com/mcpp-community/mcpp/issues/291) 私有 glibc payload 不再无条件出现在运行目标的 `LD_LIBRARY_PATH` 里。** 该变量会被**整棵进程子树**继承。当运行目标是个会 shell out 的程序(例如课程 provider 调 `popen("mcpp test ...")`)时,`/bin/sh`**宿主二进制** —— 它的 `PT_INTERP` 烙死在文件里,装载它的永远是**宿主 ld.so**,而这个变量却把 **payload 的 `libc.so.6`** 递给它。
19+
20+
glibc 的 libc 与 ld.so 之间通过 `GLIBC_PRIVATE` 版本锁定(实测:payload `libc.so.6``ld-linux-x86-64.so.2``GLIBC_PRIVATE` 依赖),二者必须同一次构建。于是**只要宿主 glibc 与 payload 不同版本**,shell 就在 `main` 之前死于装载器内的 SIGSEGV —— stdout 全空,没有任何诊断。报告者是 Ubuntu 22.04(glibc 2.35)对 payload 2.39。
21+
22+
注意这**不是**「构建机不同导致 ABI 不兼容」:它是纯粹的版本错配,任何宿主 glibc ≠ payload 的用户都会中招。也正因为版本相同就不复现,它一直没被发现。
23+
24+
payload 目录是 `LD_LIBRARY_PATH`**唯一不同时出现在可执行文件 RUNPATH 中**的一项(`flags.cppm` 刻意把它排除,以保持静态/musl 链接干净)。它存在的唯一理由是:被 `dlopen()` 的库其自身的 DT_NEEDED 闭包**不会**查主程序的 RUNPATH。因此现在只在构建确实存在这类依赖库时才注入(`depRuntimeLibraryDirs` 非空)——真实的 host-GL 透传场景走 `compat.glx-runtime``[runtime] library_dirs`,正好落在这个条件内,行为不变;而零依赖的二进制不再拿到它。
25+
26+
`process.cppm``strip_private_glibc` 早已保护 mcpp **自己的**子进程,但它够不到再外一层:变量是 mcpp 有意设给目标的,目标之后再 fork 什么已超出 mcpp 的控制 —— 能控制的是**不必要时就不发**
27+
28+
e2e 166 双向锁住(零依赖工程必须拿不到、有 `[runtime] library_dirs` 的工程必须拿得到)。它断言的是**发出的环境变量**而非「shell 是否崩溃」:后者在宿主与 payload 版本相同的机器上会因为错误的理由通过,那正是这个 bug 长期存活的原因。
29+
1830
- **4 段版本号在比较时被静默截断,致 E0006 索引底线检查失效。** `version_req::Version` 原本是严格三段,`parse_version("2026.7.27.1")` 解析出 `{2026, 7, 27}`**丢弃第 4 段且不报错** —— 同一天内的所有版本互相比较相等。
1931

2032
后果落在 `pm/index_contract.cppm`:索引写 `min_mcpp = "2026.7.27.5"`、用户跑 `2026.7.27.1`,两者比较相等,`have >= need` 成立,**底线检查放行**。用户拿着不够新的 mcpp 去读新索引,得到的是描述符读取返回空这类难以归因的次生故障 —— 而挡住这种情况正是该检查存在的唯一理由。

src/build/plan.cppm

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,28 @@ make_plan(const mcpp::manifest::Manifest& manifest,
460460
for (auto const& dir : tc.linkRuntimeDirs) {
461461
append_unique_path(plan.runtimeLibraryDirs, dir);
462462
}
463-
if (tc.payloadPaths) {
463+
// The private glibc payload is the ONE entry that is not also in the
464+
// executable's RUNPATH (flags.cppm excludes it deliberately, so static and
465+
// musl links stay clean). It is here purely so a dlopen()'d library — whose
466+
// own DT_NEEDED closure never consults the main executable's RUNPATH — can
467+
// still resolve the same libc the executable was linked against.
468+
//
469+
// So add it ONLY when this build actually has such a library. mcpp#291:
470+
// LD_LIBRARY_PATH is inherited by the whole process subtree, and a child
471+
// that is a HOST binary (/bin/sh, reached via a provider's popen()) loads
472+
// the HOST loader — PT_INTERP is baked into the executable and no
473+
// environment variable can override it — while this variable hands it the
474+
// payload libc.so.6. libc and ld.so are version-locked to each other
475+
// through GLIBC_PRIVATE, so on any host whose glibc differs from the
476+
// payload's the shell dies of SIGSEGV inside the dynamic linker, before
477+
// main, with empty stdout and no diagnostic. (It does NOT reproduce when
478+
// host and payload glibc happen to match, which is why this survived.)
479+
//
480+
// process.cppm's strip_private_glibc already removes this entry from
481+
// mcpp's OWN children. It cannot help one hop further out: mcpp sets the
482+
// variable for the target deliberately, and what the target then spawns is
483+
// beyond mcpp's reach. Not emitting it unless it is needed is.
484+
if (tc.payloadPaths && !plan.depRuntimeLibraryDirs.empty()) {
464485
append_unique_path(plan.runtimeLibraryDirs, tc.payloadPaths->glibcLib);
465486
}
466487

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env bash
2+
# requires:
3+
# mcpp#291 — a plain binary must NOT be handed the private glibc payload on
4+
# LD_LIBRARY_PATH.
5+
#
6+
# That variable is inherited by the entire process subtree. When the target is
7+
# something like a course provider that shells out (popen("mcpp test ...")),
8+
# /bin/sh is a HOST binary: its PT_INTERP is baked in, so it loads the HOST
9+
# ld.so while this variable hands it the PAYLOAD libc.so.6. glibc's libc and
10+
# ld.so are version-locked to each other via GLIBC_PRIVATE, so on any host
11+
# whose glibc differs from the payload's the shell dies of SIGSEGV inside the
12+
# dynamic linker — before main, with empty stdout and no diagnostic.
13+
#
14+
# The payload dir belongs on LD_LIBRARY_PATH only when the build actually has
15+
# a dlopen()-reachable dependency library (whose own DT_NEEDED closure cannot
16+
# see the executable's RUNPATH). A project with no such dependency must get
17+
# nothing.
18+
#
19+
# ASSERTS ON THE EMITTED ENVIRONMENT, not on whether a shell crashes: the crash
20+
# needs host glibc != payload glibc. On a matching host (the common CI case)
21+
# a crash-based test passes for the wrong reason and would never have caught
22+
# this in the first place.
23+
set -e
24+
25+
TMP=$(mktemp -d)
26+
trap "rm -rf $TMP" EXIT
27+
28+
cd "$TMP"
29+
mkdir -p pkg/src
30+
cd pkg
31+
cat > mcpp.toml <<'EOF'
32+
[package]
33+
name = "envprobe"
34+
version = "0.1.0"
35+
standard = "c++23"
36+
EOF
37+
38+
# Print the loader path variable exactly as the run target receives it.
39+
cat > src/main.cpp <<'EOF'
40+
#include <cstdio>
41+
#include <cstdlib>
42+
int main() {
43+
const char* p = std::getenv("LD_LIBRARY_PATH");
44+
std::printf("LDLP=[%s]\n", p ? p : "");
45+
return 0;
46+
}
47+
EOF
48+
49+
out=$("$MCPP" run 2>&1) || { echo "FAIL: mcpp run failed"; echo "$out"; exit 1; }
50+
51+
line=$(printf '%s\n' "$out" | grep -oE 'LDLP=\[[^]]*\]' | head -1)
52+
[ -n "$line" ] || { echo "FAIL: probe never printed LDLP"; echo "$out"; exit 1; }
53+
echo "observed: $line"
54+
55+
# The specific poison: the private glibc payload store directory.
56+
if printf '%s' "$line" | grep -q 'xim-x-glibc'; then
57+
echo "FAIL: the run target was handed the private glibc payload on LD_LIBRARY_PATH."
58+
echo " $line"
59+
echo " A binary with no dlopen-reachable dependency must not get it —"
60+
echo " it propagates to every descendant process, including host shells."
61+
exit 1
62+
fi
63+
64+
# ── The other half: when a dlopen-reachable dependency library DOES exist,
65+
# the payload dir must still be there. Without this, a later change could drop
66+
# the entry entirely and the negative assertion above would happily pass.
67+
GLIBC_STORE=$(ls -d "$HOME"/.mcpp/registry/data/xpkgs/xim-x-glibc/*/ 2>/dev/null | head -1)
68+
if [ -z "$GLIBC_STORE" ]; then
69+
echo "SKIP (positive half): no private glibc payload installed"
70+
echo OK
71+
exit 0
72+
fi
73+
74+
cd "$TMP"
75+
mkdir -p pkg2/src pkg2/runtime
76+
cd pkg2
77+
cat > mcpp.toml <<'EOF'
78+
[package]
79+
name = "envprobe2"
80+
version = "0.1.0"
81+
standard = "c++23"
82+
83+
[runtime]
84+
library_dirs = ["runtime"]
85+
EOF
86+
cp ../pkg/src/main.cpp src/main.cpp
87+
88+
out2=$("$MCPP" run 2>&1) || { echo "FAIL: mcpp run failed (positive half)"; echo "$out2"; exit 1; }
89+
line2=$(printf '%s\n' "$out2" | grep -oE 'LDLP=\[[^]]*\]' | head -1)
90+
echo "observed (with [runtime] library_dirs): $line2"
91+
92+
printf '%s' "$line2" | grep -q 'xim-x-glibc' || {
93+
echo "FAIL: a build WITH a dlopen-reachable dependency library dir lost the"
94+
echo " private glibc payload from LD_LIBRARY_PATH. dlopen'd libraries do"
95+
echo " not consult the executable's RUNPATH, so they need it here."
96+
echo " $line2"
97+
exit 1; }
98+
99+
echo OK

tests/e2e/65_toolchain_runtime_dirs_for_run.sh

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@
33
# dlopen() providers such as GLX drivers do not use the main executable's
44
# RUNPATH for their own DT_NEEDED closure. mcpp run must therefore expose the
55
# toolchain runtime directories in LD_LIBRARY_PATH as well.
6+
#
7+
# Scope note (mcpp#291): this fixture has NO dependencies, so it no longer
8+
# asserts the private glibc payload dir. That entry is not a toolchain runtime
9+
# dir — flags.cppm deliberately keeps it out of RUNPATH — and it is now emitted
10+
# only when the build actually has a dlopen-reachable dependency library, i.e.
11+
# when depRuntimeLibraryDirs is non-empty. The real case this test's headline
12+
# describes (host-GL passthrough) reaches mcpp through compat.glx-runtime's
13+
# `[runtime] library_dirs`, which populates exactly that vector, so it still
14+
# gets the payload dir; a zero-dependency binary like the one below does not.
15+
# Emitting it unconditionally is what poisoned every descendant process and
16+
# segfaulted host shells reached via popen(). See tests/e2e/166.
617
set -e
718

819
OS="$(uname -s)"
@@ -49,7 +60,6 @@ int main() {
4960
5061
std::string path(value);
5162
if (path.find("@LLVM_LIB_SUBSTR@") == std::string::npos) return 11;
52-
if (path.find("xim-x-glibc/2.39/lib64") == std::string::npos) return 12;
5363
return 0;
5464
}
5565
EOF

0 commit comments

Comments
 (0)