From dce5acde35f78fb634bae8f9fbc36abbeea81552 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 17:32:14 +0800 Subject: [PATCH 01/15] =?UTF-8?q?feat(toolchain):=20linkmodel=20=E2=80=94?= =?UTF-8?q?=20single=20resolver=20for=20the=20C-library=20link=20axis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToolchainLinkModel (CRT dir / lib dirs / loader / system includes, payload-first with --sysroot fallback) + ClangDriverModel (cfg-bypass driver flags), replacing the four divergent copies of this knowledge. Loader names come from data: declared payload exports (.xpkg-exports.json) -> per-arch triple map -> glob, never a hardcoded x86_64 string. Part of the hermetic toolchain link model (.agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md, issue #195). --- src/toolchain/linkmodel.cppm | 293 ++++++++++++++++++++++++++++++++++ tests/unit/test_linkmodel.cpp | 226 ++++++++++++++++++++++++++ 2 files changed, 519 insertions(+) create mode 100644 src/toolchain/linkmodel.cppm create mode 100644 tests/unit/test_linkmodel.cpp diff --git a/src/toolchain/linkmodel.cppm b/src/toolchain/linkmodel.cppm new file mode 100644 index 0000000..62d41d8 --- /dev/null +++ b/src/toolchain/linkmodel.cppm @@ -0,0 +1,293 @@ +// mcpp.toolchain.linkmodel — the single resolver for "how do we compile and +// link against this toolchain's C library" on Linux (glibc payload / sysroot +// worlds) plus the Clang cfg-bypass driver model. +// +// Motivation (issue #195, .agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md): +// this knowledge used to live in four divergent copies (flags.cppm, +// stdmod.cppm, build_program.cppm host_base_flags, post_install.cppm +// fixup_clang_cfg) — the link side of one copy lost the CRT discovery prefix +// (-B) and every copy hardcoded the x86_64 loader name. All consumers now +// derive their flags from ToolchainLinkModel / ClangDriverModel so they can +// never diverge again, and the loader comes from data (declared payload +// exports → triple map → glob), never from a hardcoded string. +// +// Scope: the C-library axis only — CRT dir, libc lib dirs, dynamic linker, +// libc/kernel headers. Driver-level flags that are not about the C library +// (opt level, modules, macOS deployment target, …) stay with the consumers. + +export module mcpp.toolchain.linkmodel; + +import std; +import mcpp.libs.json; +import mcpp.log; +import mcpp.platform; +import mcpp.toolchain.model; + +export namespace mcpp::toolchain { + +enum class CLibMode { + None, // nothing usable found — driver defaults (host) apply; + // the hermeticity check reports what actually leaked in. + PayloadFirst, // fine-grained glibc/linux-headers xpkg payloads + Sysroot, // --sysroot (GCC include-fixed world, musl, macOS SDK) +}; + +// Escaping differs per consumer (ninja `$`-escaping vs shell quoting), so the +// renderers take an escape callback instead of baking one in. The DEFAULT +// (identity) is only safe for paths already known to be quote-free. +using PathEscape = std::function; + +struct ToolchainLinkModel { + CLibMode mode = CLibMode::None; + + // PayloadFirst fields. + std::filesystem::path crtDir; // -B: Scrt1.o / crti.o / crtn.o discovery + std::vector libDirs; // -L (+ -rpath for clang) + std::filesystem::path loader; // -Wl,--dynamic-linker (clang only; GCC's + // specs fixup owns the loader there) + + // Sysroot fields. + std::filesystem::path sysroot; + + // Compile-side C library / kernel headers (payload dirs, or the + // linux-headers supplement for a sysroot that lacks them). + std::vector systemIncludes; + + // Rendering knobs derived from the toolchain at resolve time. + bool clangDriver = false; // clang: -isystem + rpath/loader on link + // gcc: -idirafter (…#include_next), -B/-L only + bool clangWithCfg = false; // sibling .cfg exists (bundled LLVM) + + // Render the compile-side flags (leading-space separated, matching the + // historical assembly style of flags.cppm/stdmod.cppm). + std::string compile_flags(const PathEscape& esc) const { + std::string out; + if (mode == CLibMode::Sysroot) + out += " --sysroot=" + esc(sysroot); + // PayloadFirst headers: clang takes -isystem; GCC needs -idirafter so + // libstdc++'s #include_next wrappers (which only search *after* the + // current dir, and GCC's built-ins are last) can still reach libc. + // A Sysroot-mode supplement (kernel headers missing from the sysroot) + // is -isystem for both: the libc headers come from the sysroot there. + const char* incFlag = (mode == CLibMode::Sysroot || clangDriver) + ? " -isystem" : " -idirafter"; + for (auto& inc : systemIncludes) + out += incFlag + esc(inc); + return out; + } + + // Render the link-side flags. `-B` is the CRT-discovery fix for #195: + // the driver resolves crt objects through -B prefixes and sysroot paths, + // never through -L. + std::string link_flags(const PathEscape& esc) const { + std::string out; + if (mode == CLibMode::Sysroot) { + out += " --sysroot=" + esc(sysroot); + return out; + } + if (mode != CLibMode::PayloadFirst) return out; + if (!crtDir.empty()) out += " -B" + esc(crtDir); + for (auto& dir : libDirs) { + out += " -L" + esc(dir); + if (clangDriver) out += " -Wl,-rpath," + esc(dir); + } + if (clangDriver && !loader.empty()) + out += " -Wl,--dynamic-linker=" + esc(loader); + return out; + } +}; + +// Clang cfg-bypass driver model: everything a consumer needs to emit so that +// `--no-default-config` (reproducible builds, no dependence on the +// install-time-generated cfg) still yields a working libc++ toolchain. +struct ClangDriverModel { + bool hasCfg = false; // sibling .cfg exists + std::filesystem::path cfgPath; + std::filesystem::path llvmRoot; // /../ + std::vector cxxIncludes; // libc++ header dirs + std::vector libDirs; // libc++/compiler-rt libs + + // " --no-default-config -nostdinc++ -isystem<...>" (compile side). + // -stdlib=libc++ is deliberately left to callers: compile commands for + // C files must not carry it. + std::string compile_flags(const PathEscape& esc) const { + std::string out = " --no-default-config -nostdinc++"; + for (auto& inc : cxxIncludes) out += " -isystem" + esc(inc); + return out; + } + + // Link-side driver selection, matching the cfg xlings generates. + static constexpr std::string_view kLinkDriverFlags = + " -stdlib=libc++ -fuse-ld=lld --rtlib=compiler-rt --unwindlib=libunwind"; +}; + +// ── loader resolution: data over hardcodes ─────────────────────────────── +// +// Priority: +// 1. Declared payload metadata: `/.xpkg-exports.json` +// { "runtime": { "loader": "lib64/ld-linux-x86-64.so.2" } } — written by +// xlings at install time from the package's `exports.runtime` (glibc.lua +// declares this precisely so consumers don't hardcode the name). +// 2. Triple map (x86_64/aarch64/riscv64/loongarch64 glibc + musl). +// 3. Glob: first `ld-*.so*` regular file in the lib dir. +// +// Returns the loader's absolute path, or empty when none was found (callers +// then omit --dynamic-linker and the hermeticity check reports the gap). + +// Loader *file name* for a target triple; empty when the arch is unknown. +std::string loader_filename(std::string_view targetTriple) { + const bool musl = targetTriple.find("musl") != std::string_view::npos; + struct ArchLoader { std::string_view arch, gnuName, muslArch; }; + static constexpr std::array kMap{{ + {"x86_64", "ld-linux-x86-64.so.2", "x86_64"}, + {"aarch64", "ld-linux-aarch64.so.1", "aarch64"}, + {"riscv64", "ld-linux-riscv64-lp64d.so.1", "riscv64"}, + {"loongarch64", "ld-linux-loongarch-lp64d.so.1", "loongarch64"}, + {"i686", "ld-linux.so.2", "i386"}, + }}; + for (auto& m : kMap) { + if (targetTriple.starts_with(m.arch)) { + if (musl) return std::format("ld-musl-{}.so.1", m.muslArch); + return std::string(m.gnuName); + } + } + return {}; +} + +// The distro-side loader path a *shipped* binary's PT_INTERP should point at +// (LSB layout), by target triple. Used by `mcpp pack`. +std::string distro_loader_path(std::string_view targetTriple) { + auto name = loader_filename(targetTriple); + if (name.empty()) return {}; + if (targetTriple.starts_with("x86_64")) + return "/lib64/" + name; // LSB-mandated symlink on glibc distros + return "/lib/" + name; +} + +std::filesystem::path resolve_loader(const std::filesystem::path& libDir, + std::string_view targetTriple) { + if (libDir.empty()) return {}; + std::error_code ec; + + // 1. Declared exports next to the payload lib dir. + auto exportsPath = libDir.parent_path() / ".xpkg-exports.json"; + if (std::filesystem::exists(exportsPath, ec)) { + std::ifstream is(exportsPath); + try { + nlohmann::json j; + is >> j; + if (auto rel = j.value("runtime", nlohmann::json::object()) + .value("loader", std::string{}); !rel.empty()) { + auto p = libDir.parent_path() / rel; + if (std::filesystem::exists(p, ec)) return p; + mcpp::log::verbose("linkmodel", std::format( + "declared loader '{}' missing on disk — falling through", p.string())); + } + } catch (...) { + mcpp::log::verbose("linkmodel", std::format( + "unparsable exports file '{}' — falling through", exportsPath.string())); + } + } + + // 2. Triple map. + if (auto name = loader_filename(targetTriple); !name.empty()) { + auto p = libDir / name; + if (std::filesystem::exists(p, ec)) return p; + } + + // 3. Glob fallback: ld-*.so* + for (auto it = std::filesystem::directory_iterator(libDir, ec); + !ec && it != std::filesystem::directory_iterator{}; it.increment(ec)) { + auto name = it->path().filename().string(); + if (name.starts_with("ld-") && name.find(".so") != std::string::npos + && it->is_regular_file(ec)) + return it->path(); + } + return {}; +} + +// Locate a glibc payload's lib dir (lib64 preferred, then lib) that actually +// carries a dynamic loader. Replaces the hand-rolled +// `exists(lib64/ld-linux-x86-64.so.2)` probes scattered through +// lifecycle/post_install. +std::filesystem::path payload_lib_dir_with_loader( + const std::filesystem::path& payloadVersionRoot, + std::string_view targetTriple = {}) { + for (auto sub : {"lib64", "lib"}) { + auto candidate = payloadVersionRoot / sub; + if (!resolve_loader(candidate, targetTriple).empty()) + return candidate; + } + return {}; +} + +ClangDriverModel resolve_clang_driver(const Toolchain& tc) { + ClangDriverModel dm; + if (!is_clang(tc) || tc.binaryPath.empty()) return dm; + dm.cfgPath = tc.binaryPath.parent_path() + / (tc.binaryPath.stem().string() + ".cfg"); + dm.hasCfg = std::filesystem::exists(dm.cfgPath); + if (!dm.hasCfg) return dm; + dm.llvmRoot = tc.binaryPath.parent_path().parent_path(); + auto libcxxInclude = dm.llvmRoot / "include" / "c++" / "v1"; + dm.cxxIncludes.push_back(libcxxInclude); + if (!tc.targetTriple.empty()) { + auto targetInclude = dm.llvmRoot / "include" / tc.targetTriple / "c++" / "v1"; + if (std::filesystem::exists(targetInclude)) + dm.cxxIncludes.push_back(targetInclude); + auto targetLib = dm.llvmRoot / "lib" / tc.targetTriple; + if (std::filesystem::exists(targetLib)) + dm.libDirs.push_back(targetLib); + } + return dm; +} + +ToolchainLinkModel resolve_link_model(const Toolchain& tc) { + ToolchainLinkModel lm; + lm.clangDriver = is_clang(tc); + lm.clangWithCfg = resolve_clang_driver(tc).hasCfg; + + auto payload_first = [&] { + auto& pp = *tc.payloadPaths; + lm.mode = CLibMode::PayloadFirst; + lm.crtDir = pp.glibcLib; + lm.libDirs.push_back(pp.glibcLib); + lm.systemIncludes.push_back(pp.glibcInclude); + if (!pp.linuxInclude.empty()) + lm.systemIncludes.push_back(pp.linuxInclude); + if (lm.clangDriver) + lm.loader = resolve_loader(pp.glibcLib, tc.targetTriple); + }; + auto sysroot_mode = [&](const std::filesystem::path& root) { + lm.mode = CLibMode::Sysroot; + lm.sysroot = root; + // Supplement kernel headers when the sysroot lacks them (glibc's + // local_lim.h needs ). Self-contained musl sysroots + // ship their own; a cross target must not see host-arch headers. + if (!is_musl_target(tc) && tc.payloadPaths + && !tc.payloadPaths->linuxInclude.empty() + && !std::filesystem::exists(root / "usr" / "include" / "linux" / "limits.h")) + lm.systemIncludes.push_back(tc.payloadPaths->linuxInclude); + }; + + if (lm.clangWithCfg) { + // Bundled LLVM: payload first (PR #62 principle — the sysroot comes + // from the toolchain payload, not from an environment directory), + // then the macOS SDK, then a probed sysroot. + if (tc.payloadPaths) payload_first(); + else if (auto sdk = mcpp::platform::macos::sdk_path()) { + lm.mode = CLibMode::Sysroot; + lm.sysroot = *sdk; + } + else if (!tc.sysroot.empty()) sysroot_mode(tc.sysroot); + } else if (!tc.sysroot.empty()) { + // GCC (or clang without cfg): --sysroot is required for GCC's + // include-fixed headers (stdlib.h wrapper). + sysroot_mode(tc.sysroot); + } else if (tc.payloadPaths) { + payload_first(); + } + return lm; +} + +} // namespace mcpp::toolchain diff --git a/tests/unit/test_linkmodel.cpp b/tests/unit/test_linkmodel.cpp new file mode 100644 index 0000000..7329488 --- /dev/null +++ b/tests/unit/test_linkmodel.cpp @@ -0,0 +1,226 @@ +#include + +import std; +import mcpp.toolchain.linkmodel; +import mcpp.toolchain.model; + +// The toolchain link model is the single resolver for "how do we compile and +// link against this toolchain's C library" (issue #195 / the hermetic link +// model design doc). These tests pin its three contracts: +// 1. loader names come from data (exports → triple map → glob), never a +// hardcoded x86_64 string; +// 2. the payload link flags include -B (CRT discovery — the driver never +// consults -L for Scrt1.o/crti.o/crtn.o); +// 3. mode selection mirrors the historical flags.cppm precedence. + +namespace { + +namespace tc = mcpp::toolchain; + +struct Tmp { + std::filesystem::path path; + Tmp() { + path = std::filesystem::temp_directory_path() + / std::format("mcpp_linkmodel_test_{}", std::random_device{}()); + std::filesystem::create_directories(path); + } + ~Tmp() { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } +}; + +void touch(const std::filesystem::path& p) { + std::filesystem::create_directories(p.parent_path()); + std::ofstream(p) << "x"; +} + +std::string ident(const std::filesystem::path& p) { return p.string(); } + +TEST(LoaderFilename, TripleMap) { + EXPECT_EQ(tc::loader_filename("x86_64-unknown-linux-gnu"), "ld-linux-x86-64.so.2"); + EXPECT_EQ(tc::loader_filename("aarch64-linux-gnu"), "ld-linux-aarch64.so.1"); + EXPECT_EQ(tc::loader_filename("x86_64-linux-musl"), "ld-musl-x86_64.so.1"); + EXPECT_EQ(tc::loader_filename("aarch64-linux-musl"), "ld-musl-aarch64.so.1"); + EXPECT_EQ(tc::loader_filename("wasm32-unknown-unknown"), ""); +} + +TEST(DistroLoaderPath, LsbLayoutPerArch) { + EXPECT_EQ(tc::distro_loader_path("x86_64-linux-gnu"), "/lib64/ld-linux-x86-64.so.2"); + EXPECT_EQ(tc::distro_loader_path("aarch64-linux-gnu"), "/lib/ld-linux-aarch64.so.1"); + EXPECT_EQ(tc::distro_loader_path("mips64-unknown"), ""); +} + +TEST(ResolveLoader, TripleMapHit) { + Tmp dir; + auto lib = dir.path / "lib64"; + touch(lib / "ld-linux-x86-64.so.2"); + EXPECT_EQ(tc::resolve_loader(lib, "x86_64-unknown-linux-gnu"), + lib / "ld-linux-x86-64.so.2"); +} + +TEST(ResolveLoader, DeclaredExportsWinOverTripleMap) { + Tmp dir; + auto lib = dir.path / "lib64"; + touch(lib / "ld-linux-x86-64.so.2"); + touch(dir.path / "custom" / "ld-linux-x86-64.so.2"); + std::ofstream(dir.path / ".xpkg-exports.json") + << R"({"runtime":{"loader":"custom/ld-linux-x86-64.so.2"}})"; + EXPECT_EQ(tc::resolve_loader(lib, "x86_64-unknown-linux-gnu"), + dir.path / "custom" / "ld-linux-x86-64.so.2"); +} + +TEST(ResolveLoader, GlobFallbackForUnknownTriple) { + Tmp dir; + auto lib = dir.path / "lib"; + touch(lib / "ld-linux-aarch64.so.1"); + auto got = tc::resolve_loader(lib, ""); // no triple info at all + EXPECT_EQ(got, lib / "ld-linux-aarch64.so.1"); +} + +TEST(ResolveLoader, EmptyWhenNothingFound) { + Tmp dir; + auto lib = dir.path / "lib64"; + std::filesystem::create_directories(lib); + EXPECT_TRUE(tc::resolve_loader(lib, "x86_64-linux-gnu").empty()); +} + +TEST(PayloadLibDir, PrefersLib64ThenLib) { + Tmp dir; + touch(dir.path / "lib" / "ld-linux-x86-64.so.2"); + EXPECT_EQ(tc::payload_lib_dir_with_loader(dir.path, "x86_64-linux-gnu"), + dir.path / "lib"); + touch(dir.path / "lib64" / "ld-linux-x86-64.so.2"); + EXPECT_EQ(tc::payload_lib_dir_with_loader(dir.path, "x86_64-linux-gnu"), + dir.path / "lib64"); +} + +// Fabricate a bundled-LLVM-style toolchain: bin/clang++ + bin/clang++.cfg, +// libc++ headers, and a glibc payload. +struct FakeClangWithPayload { + Tmp dir; + tc::Toolchain t; + std::filesystem::path glibcLib; + FakeClangWithPayload() { + auto llvm = dir.path / "llvm"; + touch(llvm / "bin" / "clang++"); + touch(llvm / "bin" / "clang++.cfg"); + touch(llvm / "include" / "c++" / "v1" / "version"); + auto glibc = dir.path / "glibc"; + glibcLib = glibc / "lib64"; + touch(glibc / "include" / "features.h"); + touch(glibcLib / "Scrt1.o"); + touch(glibcLib / "ld-linux-x86-64.so.2"); + t.compiler = tc::CompilerId::Clang; + t.binaryPath = llvm / "bin" / "clang++"; + t.targetTriple = "x86_64-unknown-linux-gnu"; + t.payloadPaths = tc::PayloadPaths{ + .glibcInclude = glibc / "include", + .glibcLib = glibcLib, + .linuxInclude = {}, + }; + } +}; + +TEST(LinkModel, ClangCfgPayloadFirstCarriesCrtDiscovery) { + FakeClangWithPayload fx; + auto lm = tc::resolve_link_model(fx.t); + EXPECT_EQ(lm.mode, tc::CLibMode::PayloadFirst); + EXPECT_TRUE(lm.clangWithCfg); + EXPECT_EQ(lm.crtDir, fx.glibcLib); + EXPECT_EQ(lm.loader, fx.glibcLib / "ld-linux-x86-64.so.2"); + + auto link = lm.link_flags(ident); + // -B is the #195 fix: CRT objects resolve via -B, never -L. + EXPECT_NE(link.find(" -B" + fx.glibcLib.string()), std::string::npos); + EXPECT_NE(link.find(" -L" + fx.glibcLib.string()), std::string::npos); + EXPECT_NE(link.find("--dynamic-linker=" + lm.loader.string()), std::string::npos); + + auto compile = lm.compile_flags(ident); + EXPECT_NE(compile.find("-isystem"), std::string::npos); + EXPECT_EQ(compile.find("-idirafter"), std::string::npos); +} + +TEST(LinkModel, ClangDriverModelExposesCfgAndHeaders) { + FakeClangWithPayload fx; + auto dm = tc::resolve_clang_driver(fx.t); + EXPECT_TRUE(dm.hasCfg); + ASSERT_FALSE(dm.cxxIncludes.empty()); + EXPECT_NE(dm.compile_flags(ident).find("--no-default-config"), std::string::npos); +} + +TEST(LinkModel, GccSysrootWinsOverPayload) { + Tmp dir; + auto sysroot = dir.path / "sysroot"; + touch(sysroot / "usr" / "include" / "stdlib.h"); + touch(sysroot / "usr" / "include" / "linux" / "limits.h"); + tc::Toolchain t; + t.compiler = tc::CompilerId::GCC; + t.binaryPath = dir.path / "bin" / "g++"; + t.targetTriple = "x86_64-linux-gnu"; + t.sysroot = sysroot; + t.payloadPaths = tc::PayloadPaths{ + .glibcInclude = dir.path / "glibc" / "include", + .glibcLib = dir.path / "glibc" / "lib64", + .linuxInclude = dir.path / "linux" / "include", + }; + auto lm = tc::resolve_link_model(t); + EXPECT_EQ(lm.mode, tc::CLibMode::Sysroot); + EXPECT_NE(lm.compile_flags(ident).find("--sysroot="), std::string::npos); + EXPECT_NE(lm.link_flags(ident).find("--sysroot="), std::string::npos); + // Kernel headers exist in the sysroot → no supplement. + EXPECT_TRUE(lm.systemIncludes.empty()); +} + +TEST(LinkModel, GccSysrootSupplementsMissingKernelHeaders) { + Tmp dir; + auto sysroot = dir.path / "sysroot"; + touch(sysroot / "usr" / "include" / "stdlib.h"); // no linux/limits.h + tc::Toolchain t; + t.compiler = tc::CompilerId::GCC; + t.targetTriple = "x86_64-linux-gnu"; + t.sysroot = sysroot; + t.payloadPaths = tc::PayloadPaths{ + .glibcInclude = dir.path / "glibc" / "include", + .glibcLib = dir.path / "glibc" / "lib64", + .linuxInclude = dir.path / "linux" / "include", + }; + auto lm = tc::resolve_link_model(t); + ASSERT_EQ(lm.systemIncludes.size(), 1u); + // Sysroot-mode supplement renders as -isystem even for GCC. + EXPECT_NE(lm.compile_flags(ident).find("-isystem"), std::string::npos); +} + +TEST(LinkModel, GccPayloadUsesIdirafterAndNoLoader) { + Tmp dir; + auto glibcLib = dir.path / "glibc" / "lib64"; + touch(glibcLib / "ld-linux-x86-64.so.2"); + tc::Toolchain t; + t.compiler = tc::CompilerId::GCC; + t.targetTriple = "x86_64-linux-gnu"; + t.payloadPaths = tc::PayloadPaths{ + .glibcInclude = dir.path / "glibc" / "include", + .glibcLib = glibcLib, + .linuxInclude = {}, + }; + auto lm = tc::resolve_link_model(t); + EXPECT_EQ(lm.mode, tc::CLibMode::PayloadFirst); + EXPECT_NE(lm.compile_flags(ident).find("-idirafter"), std::string::npos); + auto link = lm.link_flags(ident); + EXPECT_NE(link.find(" -B"), std::string::npos); + // GCC's loader/rpath is owned by the specs fixup, not the command line. + EXPECT_EQ(link.find("dynamic-linker"), std::string::npos); + EXPECT_EQ(link.find("-rpath"), std::string::npos); +} + +TEST(LinkModel, NothingUsableYieldsNoneAndEmptyFlags) { + tc::Toolchain t; + t.compiler = tc::CompilerId::Clang; + t.targetTriple = "x86_64-linux-gnu"; + auto lm = tc::resolve_link_model(t); + EXPECT_EQ(lm.mode, tc::CLibMode::None); + EXPECT_TRUE(lm.compile_flags(ident).empty()); + EXPECT_TRUE(lm.link_flags(ident).empty()); +} + +} // namespace From abbd821885619de674002c05900afb57c838a873 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 17:32:14 +0800 Subject: [PATCH 02/15] =?UTF-8?q?fix(build):=20CRT=20discovery=20via=20lin?= =?UTF-8?q?kmodel=20=E2=80=94=20payload=20link=20gains=20-B=20(fixes=20#19?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flags.cppm, stdmod.cppm and build_program.cppm host_base_flags now derive their sysroot/payload flags from the shared link model. The clang-with-cfg payload link path previously emitted only -L/-rpath/--dynamic-linker; the driver resolves Scrt1.o/crti.o/crtn.o through -B prefixes and sysroot paths only, so on hosts without a system toolchain it passed bare CRT names that lld cannot open (issue #195), and on hosts with one it silently linked the host's CRT. The model's link_flags carry -B, so CRT objects now resolve inside the payload everywhere. build.mcpp host compiles stop trusting the sibling clang cfg (an install-time-generated, per-machine artifact) and use the same explicit flags as the main build. --- src/build/build_program.cppm | 62 ++++++++++++---- src/build/flags.cppm | 133 ++++++++++------------------------- src/toolchain/stdmod.cppm | 79 +++++---------------- 3 files changed, 105 insertions(+), 169 deletions(-) diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 04db6c7..3fa9153 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -16,6 +16,7 @@ import std; import mcpp.manifest; import mcpp.platform.process; import mcpp.toolchain.fingerprint; // hash_file / hash_string (FNV-1a, 16 hex) +import mcpp.toolchain.linkmodel; // shared C-library / clang-cfg-bypass model import mcpp.toolchain.model; // Toolchain, PayloadPaths, is_clang/is_musl_target import mcpp.toolchain.registry; // archive_tool import mcpp.ui; @@ -116,21 +117,58 @@ std::string env_value(const std::string& name) { // only the native cases; these are passed as separate argv tokens (no shell). std::vector host_base_flags(const mcpp::toolchain::Toolchain& tc) { std::vector f; - // Clang reads its sibling `.cfg` by default, which wires libc++ + the - // sysroot. A simple host compile trusts it (the main build bypasses the cfg - // for reproducibility; here correctness on a fresh box is all we need). - if (mcpp::toolchain::is_clang(tc)) return f; + const auto lm = mcpp::toolchain::resolve_link_model(tc); + + // Clang with a bundled cfg: bypass it (--no-default-config) and provide + // everything explicitly, same as the main build — the cfg is an + // install-time-generated artifact whose content varies per machine and + // install path, so trusting it here while bypassing it in the main build + // meant two different toolchains for the same project. + if (mcpp::toolchain::is_clang(tc)) { + const auto dm = mcpp::toolchain::resolve_clang_driver(tc); + if (dm.hasCfg) { + f.push_back("--no-default-config"); + f.push_back("-nostdinc++"); + f.push_back("-stdlib=libc++"); + for (auto& inc : dm.cxxIncludes) f.push_back("-isystem" + inc.string()); + f.push_back("-fuse-ld=lld"); + f.push_back("--rtlib=compiler-rt"); + f.push_back("--unwindlib=libunwind"); + for (auto& d : dm.libDirs) { + f.push_back("-L" + d.string()); + f.push_back("-Wl,-rpath," + d.string()); + } + } + if (lm.mode == mcpp::toolchain::CLibMode::Sysroot) { + f.push_back("--sysroot=" + lm.sysroot.string()); + } else if (lm.mode == mcpp::toolchain::CLibMode::PayloadFirst) { + for (auto& inc : lm.systemIncludes) f.push_back("-isystem" + inc.string()); + f.push_back("-B" + lm.crtDir.string()); // Scrt1.o/crti.o discovery + for (auto& d : lm.libDirs) { + f.push_back("-L" + d.string()); + f.push_back("-Wl,-rpath," + d.string()); + } + if (!lm.loader.empty()) + f.push_back("-Wl,--dynamic-linker=" + lm.loader.string()); + } + // Runtime lib dirs so the produced program can load private libs in-tree. + for (auto& d : tc.linkRuntimeDirs) { + f.push_back("-L" + d.string()); + f.push_back("-Wl,-rpath," + d.string()); + } + return f; + } // GCC: a fresh sandbox g++ needs --sysroot to find the C library + the // include-fixed headers; without a sysroot, wire the glibc payload directly. - if (!tc.sysroot.empty()) { - f.push_back("--sysroot=" + tc.sysroot.string()); - } else if (tc.payloadPaths) { - auto& pp = *tc.payloadPaths; - f.push_back("-idirafter"); f.push_back(pp.glibcInclude.string()); - if (!pp.linuxInclude.empty()) { f.push_back("-idirafter"); f.push_back(pp.linuxInclude.string()); } - f.push_back("-B" + pp.glibcLib.string()); // crt1.o/crti.o discovery - f.push_back("-L" + pp.glibcLib.string()); // -lc/-lm resolution + if (lm.mode == mcpp::toolchain::CLibMode::Sysroot) { + f.push_back("--sysroot=" + lm.sysroot.string()); + } else if (lm.mode == mcpp::toolchain::CLibMode::PayloadFirst) { + for (auto& inc : lm.systemIncludes) { + f.push_back("-idirafter"); f.push_back(inc.string()); + } + f.push_back("-B" + lm.crtDir.string()); // crt1.o/crti.o discovery + for (auto& d : lm.libDirs) f.push_back("-L" + d.string()); // -lc/-lm } // binutils -B so the driver finds ld/as (GCC, non-musl; musl ships its own). if (!mcpp::toolchain::is_musl_target(tc)) { diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 79f3d12..21f08c3 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -16,6 +16,7 @@ import mcpp.build.plan; import mcpp.platform; import mcpp.toolchain.clang; import mcpp.toolchain.detect; +import mcpp.toolchain.linkmodel; import mcpp.toolchain.provider; import mcpp.toolchain.registry; @@ -153,34 +154,28 @@ CompileFlags compute_flags(const BuildPlan& plan) { include_flags += " -I" + escape_path(abs); } - // Sysroot / payload paths. - // - // Payload-first: when PayloadPaths are available (glibc + linux-headers - // xpkgs found), use -isystem for each payload include dir. This avoids - // dependency on xlings subos. - // - // For Clang with a cfg file: use --no-default-config to bypass - // potentially-stale paths, then provide all flags explicitly. - // - // Fallback: if no PayloadPaths, use --sysroot from probe_sysroot(). + // Sysroot / payload paths — resolved ONCE by the toolchain link model + // (mcpp.toolchain.linkmodel, the single source of truth shared with + // stdmod / build_program / the cfg fixup; see + // .agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md). + // Payload-first, --sysroot fallback; for Clang with a cfg file we bypass + // the (install-time-generated, non-reproducible) cfg with + // --no-default-config and provide everything explicitly. + const auto dm = mcpp::toolchain::resolve_clang_driver(plan.toolchain); + const auto lm = mcpp::toolchain::resolve_link_model(plan.toolchain); + const mcpp::toolchain::PathEscape ninjaEsc = + [](const std::filesystem::path& p) { return escape_path(p); }; + std::string compile_toolchain_flags; std::string link_toolchain_flags; - bool isClangWithCfg = false; - std::filesystem::path cfgPath; + const bool isClangWithCfg = dm.hasCfg; // LLVM root of a clang-with-cfg toolchain — used by the macOS link // path below to locate libc++.a/libc++abi.a for staticStdlib. std::filesystem::path llvmRootForStdlib; - if (mcpp::toolchain::is_clang(plan.toolchain)) { - cfgPath = plan.toolchain.binaryPath.parent_path() - / (plan.toolchain.binaryPath.stem().string() + ".cfg"); - isClangWithCfg = std::filesystem::exists(cfgPath); - } if (isClangWithCfg) { - // Clang with cfg: bypass cfg and provide all paths explicitly. - auto llvmRoot = plan.toolchain.binaryPath.parent_path().parent_path(); - auto libcxxInclude = llvmRoot / "include" / "c++" / "v1"; - compile_toolchain_flags = " --no-default-config -nostdinc++"; + // --no-default-config -nostdinc++ + libc++ headers. + compile_toolchain_flags = dm.compile_flags(ninjaEsc); // macOS deployment target: make the resolved value explicit on // the command line so (a) the ninja commands don't depend on env // propagation and (b) the value participates in the BMI @@ -193,71 +188,22 @@ CompileFlags compute_flags(const BuildPlan& plan) { compile_toolchain_flags += " -mmacosx-version-min=" + macosDeploymentTarget; } - llvmRootForStdlib = llvmRoot; - // libc++ headers - compile_toolchain_flags += " -isystem" + escape_path(libcxxInclude); - if (!plan.toolchain.targetTriple.empty()) { - auto targetInclude = llvmRoot / "include" - / plan.toolchain.targetTriple / "c++" / "v1"; - if (std::filesystem::exists(targetInclude)) - compile_toolchain_flags += " -isystem" + escape_path(targetInclude); - } - // C library + kernel headers from payload - if (plan.toolchain.payloadPaths) { - auto& pp = *plan.toolchain.payloadPaths; - compile_toolchain_flags += " -isystem" + escape_path(pp.glibcInclude); - if (!pp.linuxInclude.empty()) - compile_toolchain_flags += " -isystem" + escape_path(pp.linuxInclude); - } else if (auto sdk = mcpp::platform::macos::sdk_path()) { - auto sysroot_flag = " --sysroot=" + escape_path(*sdk); - compile_toolchain_flags += sysroot_flag; - link_toolchain_flags += sysroot_flag; - } else if (!plan.toolchain.sysroot.empty()) { - auto sysroot_flag = " --sysroot=" + escape_path(plan.toolchain.sysroot); - compile_toolchain_flags += sysroot_flag; - link_toolchain_flags += sysroot_flag; - } - // Linker flags that cfg normally provides - link_toolchain_flags = " --no-default-config" + link_toolchain_flags - + " -stdlib=libc++ -fuse-ld=lld --rtlib=compiler-rt --unwindlib=libunwind"; + llvmRootForStdlib = dm.llvmRoot; + // C library headers (payload -isystem, or --sysroot fallback). + compile_toolchain_flags += lm.compile_flags(ninjaEsc); + // Linker flags that cfg normally provides. The payload C-runtime + // flags (-B/-L/loader) are appended via payload_ld below. + link_toolchain_flags = " --no-default-config"; + if (lm.mode == mcpp::toolchain::CLibMode::Sysroot) + link_toolchain_flags += lm.link_flags(ninjaEsc); + link_toolchain_flags += + mcpp::toolchain::ClangDriverModel::kLinkDriverFlags; f.sysroot = link_toolchain_flags; - } else if (!plan.toolchain.sysroot.empty()) { - // GCC (or Clang without cfg): use --sysroot from probe. - // GCC requires --sysroot for include-fixed headers (stdlib.h wrapper). - // Supplement with -isystem for linux kernel headers from payload - // if the probed sysroot is missing them. - auto sysroot_flag = " --sysroot=" + escape_path(plan.toolchain.sysroot); - compile_toolchain_flags = sysroot_flag; - link_toolchain_flags = sysroot_flag; - // Self-contained musl toolchains ship their own kernel headers in the - // sysroot; for a cross target the host (x86) linux-headers payload is - // the wrong arch, so don't supplement it. - if (!mcpp::toolchain::is_musl_target(plan.toolchain) - && plan.toolchain.payloadPaths && !plan.toolchain.payloadPaths->linuxInclude.empty()) { - auto sysrootLinux = plan.toolchain.sysroot / "usr" / "include" / "linux" / "limits.h"; - if (!std::filesystem::exists(sysrootLinux)) - compile_toolchain_flags += " -isystem" + escape_path(plan.toolchain.payloadPaths->linuxInclude); - } - f.sysroot = link_toolchain_flags; - } else if (plan.toolchain.payloadPaths) { - // No usable sysroot: wire the C library headers from the payload. - // For GCC use -idirafter (appended after the built-in dirs) so that - // libstdc++'s #include_next wrappers can reach them; -isystem would - // place them BEFORE the built-ins, invisible to #include_next. - auto& pp = *plan.toolchain.payloadPaths; - const bool clangTc = mcpp::toolchain::is_clang(plan.toolchain); - auto inc_flag = [&](const std::filesystem::path& p) { - return (clangTc ? " -isystem" : " -idirafter") + escape_path(p); - }; - compile_toolchain_flags += inc_flag(pp.glibcInclude); - if (!pp.linuxInclude.empty()) - compile_toolchain_flags += inc_flag(pp.linuxInclude); - // Link-time C runtime: a usable --sysroot would have provided the - // startup objects and core libs implicitly. Without one, point the - // driver at the glibc payload lib dir: -B for crt1.o/crti.o discovery, - // -L for -lm/-lc resolution. - link_toolchain_flags += " -B" + escape_path(pp.glibcLib); - link_toolchain_flags += " -L" + escape_path(pp.glibcLib); + } else if (lm.mode != mcpp::toolchain::CLibMode::None) { + // GCC (or Clang without cfg): --sysroot from probe, or the payload + // headers + C runtime (-B for crt discovery, -L for -lc/-lm). + compile_toolchain_flags = lm.compile_flags(ninjaEsc); + link_toolchain_flags = lm.link_flags(ninjaEsc); f.sysroot = link_toolchain_flags; } @@ -358,16 +304,15 @@ CompileFlags compute_flags(const BuildPlan& plan) { } } - // For Clang with payload paths: add glibc lib + dynamic linker to link flags. + // For Clang with payload paths: the payload C runtime — -B so the driver + // resolves Scrt1.o/crti.o/crtn.o inside the payload (the driver never + // consults -L for CRT objects; without -B it silently falls back to the + // host's /lib or, on hosts without a system toolchain, passes bare names + // that lld cannot open — issue #195), -L/-rpath for -lc/-lm, and the + // payload's dynamic linker. std::string payload_ld; - if (isClangWithCfg && plan.toolchain.payloadPaths) { - auto& pp = *plan.toolchain.payloadPaths; - payload_ld += " -L" + escape_path(pp.glibcLib); - payload_ld += " -Wl,-rpath," + escape_path(pp.glibcLib); - auto loader = pp.glibcLib / "ld-linux-x86-64.so.2"; - if (std::filesystem::exists(loader)) - payload_ld += " -Wl,--dynamic-linker=" + escape_path(loader); - } + if (isClangWithCfg && lm.mode == mcpp::toolchain::CLibMode::PayloadFirst) + payload_ld = lm.link_flags(ninjaEsc); std::string link_extra; if (prof.lto) link_extra += " -flto"; diff --git a/src/toolchain/stdmod.cppm b/src/toolchain/stdmod.cppm index 15ef9a2..ecbb227 100644 --- a/src/toolchain/stdmod.cppm +++ b/src/toolchain/stdmod.cppm @@ -29,6 +29,7 @@ import mcpp.toolchain.clang; import mcpp.toolchain.detect; import mcpp.toolchain.fingerprint; import mcpp.toolchain.gcc; +import mcpp.toolchain.linkmodel; export namespace mcpp::toolchain { @@ -194,72 +195,24 @@ std::expected ensure_built( : mcpp::toolchain::gcc::std_bmi_path(sm.cacheDir); sm.objectPath = sm.cacheDir / "std.o"; - // Build sysroot + include flags for std module precompilation. - // - // Payload-first: use fine-grained -isystem paths from xpkgs payloads - // when available, falling back to --sysroot. - // - // For Clang with a cfg file: use --no-default-config to bypass - // potentially-stale paths, then provide all flags explicitly. + // Build sysroot + include flags for std module precompilation, derived + // from the shared toolchain link model (same resolver as flags.cppm — + // identical flags also keep the std_build_commands cache key honest). // Std module precompilation only needs compile flags (no linker flags), // so --no-default-config is safe here on all platforms. + const auto dm = resolve_clang_driver(tc); + const auto lm = resolve_link_model(tc); + const PathEscape shellEsc = [](const std::filesystem::path& p) { + return std::format("'{}'", p.string()); + }; std::string sysroot_flag; - if (is_clang(tc)) { - auto cfgPath = tc.binaryPath.parent_path() - / (tc.binaryPath.stem().string() + ".cfg"); - if (std::filesystem::exists(cfgPath)) { - auto llvmRoot = tc.binaryPath.parent_path().parent_path(); - auto libcxxInclude = llvmRoot / "include" / "c++" / "v1"; - sysroot_flag = " --no-default-config -nostdinc++ -stdlib=libc++"; - sysroot_flag += std::format(" -isystem'{}'", libcxxInclude.string()); - if (!tc.targetTriple.empty()) { - auto targetInclude = llvmRoot / "include" - / tc.targetTriple / "c++" / "v1"; - if (std::filesystem::exists(targetInclude)) - sysroot_flag += std::format(" -isystem'{}'", targetInclude.string()); - } - // C library + kernel headers from payload paths. - if (tc.payloadPaths) { - sysroot_flag += std::format(" -isystem'{}'", tc.payloadPaths->glibcInclude.string()); - if (!tc.payloadPaths->linuxInclude.empty()) - sysroot_flag += std::format(" -isystem'{}'", tc.payloadPaths->linuxInclude.string()); - } else if (auto sdk = mcpp::platform::macos::sdk_path()) { - sysroot_flag += std::format(" --sysroot='{}'", sdk->string()); - } else if (!tc.sysroot.empty()) { - sysroot_flag += std::format(" --sysroot='{}'", tc.sysroot.string()); - } - } else if (!tc.sysroot.empty()) { - sysroot_flag = std::format(" --sysroot='{}'", tc.sysroot.string()); - } - } else if (!tc.sysroot.empty()) { - // GCC: use --sysroot (required for include-fixed headers). - // Supplement with -isystem for linux kernel headers from payload - // if the probed sysroot is missing them. - sysroot_flag = std::format(" --sysroot='{}'", tc.sysroot.string()); - // Skip the external linux-headers payload for self-contained musl - // toolchains: their sysroot ships kernel headers, and for a cross - // target the host (x86) headers are the wrong arch. - if (!is_musl_target(tc) && tc.payloadPaths && !tc.payloadPaths->linuxInclude.empty()) { - auto sysrootLinux = tc.sysroot / "usr" / "include" / "linux" / "limits.h"; - if (!std::filesystem::exists(sysrootLinux)) - sysroot_flag += std::format(" -isystem'{}'", tc.payloadPaths->linuxInclude.string()); - } - } else if (tc.payloadPaths) { - // No usable sysroot: wire the C library headers from the payload. - // GCC's libstdc++ wraps libc headers via #include_next, which only - // searches directories AFTER the one the current header came from — - // and gcc's built-in dirs are LAST in the search order, so an - // -isystem payload dir (inserted before the built-ins) is unreachable - // from #include_next. -idirafter appends the payload to the very end, - // exactly where #include_next looks. - const bool clang = is_clang(tc); - auto add_inc = [&](const std::filesystem::path& p) { - if (clang) sysroot_flag += std::format(" -isystem'{}'", p.string()); - else sysroot_flag += std::format(" -idirafter'{}'", p.string()); - }; - add_inc(tc.payloadPaths->glibcInclude); - if (!tc.payloadPaths->linuxInclude.empty()) - add_inc(tc.payloadPaths->linuxInclude); + if (dm.hasCfg) { + sysroot_flag = " --no-default-config -nostdinc++ -stdlib=libc++"; + for (auto& inc : dm.cxxIncludes) + sysroot_flag += " -isystem" + shellEsc(inc); + sysroot_flag += lm.compile_flags(shellEsc); + } else { + sysroot_flag = lm.compile_flags(shellEsc); } // Deployment target must mirror what flags.cppm emits for normal TUs From f6baa5e82795cf273cf760d75474fe6c1145daa2 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 17:39:08 +0800 Subject: [PATCH 03/15] refactor(toolchain): one post-install fixup pipeline + deterministic cfg regeneration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensure_post_install_fixup(cfg, payloadRoot, pkg) is now the single entry for toolchain post-install fixups (gcc: patchelf + specs; llvm: patchelf(lib) + cfg), called from all three install paths — explicit `toolchain install`, default-toolchain auto-install, and manifest [toolchain] auto-install. The manifest path previously ran NO fixup, so a fresh llvm auto-install kept its stale install-time cfg and unpatched runtime libs (how the #195 reporter's cfg still carried an install-time --sysroot while other installs didn't). Idempotent via a content-fingerprinted marker (/.mcpp-fixup.json: schema + kind + fixup rev + glibc lib) — drifted inputs re-run the fixup. The ownership guard (inherited/symlinked payloads are not ours to patch) now covers llvm too. fixup_clang_cfg no longer line-patches whatever a given install produced: it regenerates the cfg deterministically from the link model, so the same payload yields byte-identical cfgs on every machine and install path — and a human running clang++ directly now gets hermetic CRT discovery (-B) too. All loader paths come from resolve_loader (no ld-linux-x86-64 hardcodes). --- src/build/prepare.cppm | 15 +- src/toolchain/lifecycle.cppm | 66 +------ src/toolchain/post_install.cppm | 330 ++++++++++++++++++-------------- 3 files changed, 201 insertions(+), 210 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 7f659a7..ab3bca6 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -714,6 +714,10 @@ prepare_build(bool print_fingerprint, "toolchain payload '{}' has no known C++ frontend in {}", pkg.target(), payload->binDir.string())); } + // Same post-install fixup as `mcpp toolchain install` — this manifest + // [toolchain] path previously ran none, so a freshly auto-installed + // payload kept its stale install-time cfg / unpatched runtime libs. + mcpp::toolchain::ensure_post_install_fixup(**cfg, payload->root, pkg); mcpp::ui::info("Resolved", std::format("{} → {}", *tcSpec, mcpp::ui::shorten_path(explicit_compiler, @@ -818,13 +822,12 @@ prepare_build(bool print_fingerprint, defaultPkg.target(), payload->binDir.string())); } - // The freshly-installed glibc gcc needs the SAME post-install fixup - // (patchelf + specs wiring against the sandbox glibc) that + // The freshly-installed toolchain needs the SAME post-install fixup + // (patchelf / specs / cfg wiring against the sandbox glibc) that // `mcpp toolchain install` performs — without it a fresh sandbox - // cannot find the C library (stdlib.h: No such file or directory). - if (defaultPkg.needsGccPostInstallFixup) { - mcpp::toolchain::gcc_post_install_fixup(**cfg, payload->root); - } + // gcc cannot find the C library (stdlib.h: No such file or + // directory) and a fresh llvm keeps its stale install-time cfg. + mcpp::toolchain::ensure_post_install_fixup(**cfg, payload->root, defaultPkg); // Persist the default so we don't ask again next time. if (auto wr = mcpp::config::write_default_toolchain(**cfg, defaultSpec); wr) { diff --git a/src/toolchain/lifecycle.cppm b/src/toolchain/lifecycle.cppm index cf21e36..83405fb 100644 --- a/src/toolchain/lifecycle.cppm +++ b/src/toolchain/lifecycle.cppm @@ -280,7 +280,6 @@ export int toolchain_list(const mcpp::config::GlobalConfig& cfg) { // `mcpp toolchain install ` — install + post-install fixups. export int toolchain_install(const mcpp::config::GlobalConfig& cfg, const std::string& pos0, const std::string& pos1) { - auto xlEnv = mcpp::config::make_xlings_env(cfg); // Toolchain install needs patchelf (ELF fixup) and ninja (build). // Fail early if bootstrap is incomplete rather than producing a // broken toolchain with missing fixups. @@ -356,67 +355,10 @@ export int toolchain_install(const mcpp::config::GlobalConfig& cfg, return 1; } - // For GNU gcc only: post-install ELF + specs fixup so the toolchain - // is self-contained in the sandbox. - // 1. patchelf walk: rewrites PT_INTERP of gcc/binutils binaries - // themselves (xim's elfpatch is supposed to but currently - // scans 0 files — see `patchelf_walk_set_interp` doc). - // 2. specs fixup: rewrites the linker-spec dynamic-linker/rpath - // so binaries gcc *compiles* use sandbox-local paths. - // musl-gcc ships its own self-contained sysroot at - // `/x86_64-linux-musl/{include,lib}` and doesn't link against - // xim:glibc, so this fixup is both unnecessary and harmful for it. - if (pkg.needsGccPostInstallFixup) { - mcpp::toolchain::gcc_post_install_fixup(cfg, payload->root); - } - - // For LLVM/Clang: post-install fixup so the shared libraries and - // clang++.cfg paths point to the payload's actual location instead - // of the xlings install-time paths (which become stale after copy). - if (pkg.ximName == "llvm") { - auto glibcRoot = mcpp::xlings::paths::xim_tool_root(xlEnv, "glibc"); - std::filesystem::path glibcLibDir; - if (std::filesystem::exists(glibcRoot)) { - for (auto& v : std::filesystem::directory_iterator(glibcRoot)) { - auto candidate = v.path() / "lib64"; - if (std::filesystem::exists(candidate / "ld-linux-x86-64.so.2")) { - glibcLibDir = candidate; - break; - } - candidate = v.path() / "lib"; - if (std::filesystem::exists(candidate / "ld-linux-x86-64.so.2")) { - glibcLibDir = candidate; - break; - } - } - } - - // patchelf: rewrite RUNPATH for LLVM runtime shared libraries - // (libc++.so, libunwind.so, etc.) so transitive deps like - // libatomic.so.1 are found at runtime. Without this, - // libc++.so.1 keeps stale xlings-era RUNPATH and cannot locate - // libatomic.so.1 which lives in the same xpkg. - // - // Only walk lib/ dirs — NOT bin/. The clang++ binary has its - // own RUNPATH set by xlings (pointing to zlib, libxml2, etc.) - // that must be preserved. - auto patchelfBin = mcpp::xlings::paths::xim_tool(xlEnv, "patchelf", - mcpp::xlings::pinned::kPatchelfVersion) / "bin" / "patchelf"; - if (!glibcLibDir.empty() && std::filesystem::exists(patchelfBin)) { - auto loader = glibcLibDir / "ld-linux-x86-64.so.2"; - auto llvmTargetLib = payload->root / "lib" / "x86_64-unknown-linux-gnu"; - auto llvmLib = payload->root / "lib"; - std::string rpath = llvmTargetLib.string() - + ":" + llvmLib.string() - + ":" + glibcLibDir.string(); - mcpp::log::verbose("toolchain", std::format( - "llvm fixup: patchelf_walk lib/ rpath='{}'", rpath)); - mcpp::toolchain::patchelf_walk(llvmLib, loader, rpath, patchelfBin); - } - - mcpp::log::verbose("toolchain", "llvm fixup: fixup_clang_cfg"); - mcpp::toolchain::fixup_clang_cfg(payload->root, glibcLibDir); - } + // Post-install fixup (patchelf / specs / cfg regeneration) — ONE + // pipeline shared by every toolchain install path, dispatched and + // made idempotent inside ensure_post_install_fixup. + mcpp::toolchain::ensure_post_install_fixup(cfg, payload->root, pkg); mcpp::ui::status("Installed", std::format("{} → {}", pkg.display_spec(), bin.string())); diff --git a/src/toolchain/post_install.cppm b/src/toolchain/post_install.cppm index 1b07f2d..224e8d9 100644 --- a/src/toolchain/post_install.cppm +++ b/src/toolchain/post_install.cppm @@ -13,8 +13,11 @@ export module mcpp.toolchain.post_install; import std; import mcpp.config; +import mcpp.libs.json; import mcpp.log; import mcpp.platform; +import mcpp.toolchain.linkmodel; +import mcpp.toolchain.registry; import mcpp.ui; import mcpp.xlings; @@ -155,160 +158,102 @@ void fixup_gcc_specs(const std::filesystem::path& gccPkgRoot, } } -// Rewrite clang++.cfg paths after the LLVM payload has been copied to the -// mcpp sandbox. The cfg was authored by xlings at install time and contains -// absolute paths pointing to ~/.xlings/. We rewrite them to point to the -// actual payload location + sibling xpkgs (glibc, linux-headers). +// Regenerate the clang driver cfg files after the LLVM payload landed in the +// sandbox. The cfg xlings authored at install time is a per-machine, +// per-install-path artifact (its content depended on what existed when the +// package was installed); mcpp's builds bypass it entirely +// (--no-default-config), so its only remaining job is to make a HUMAN +// running `clang++` directly get a working, hermetic compiler. We therefore +// regenerate it deterministically from the same link model the builds use, +// instead of line-patching whatever a given install produced: +// C + C++: -B/-L glibc payload, payload dynamic linker + rpath, +// lld / compiler-rt / libunwind +// C++ only: -nostdinc++ -stdlib=libc++ + payload libc++ headers/libs +// On macOS the C library comes from the SDK: --sysroot= + libc++ headers. export void fixup_clang_cfg(const std::filesystem::path& payloadRoot, const std::filesystem::path& glibcLibDir) { - for (auto cfgName : {"clang++.cfg", "clang.cfg"}) { - auto cfgPath = payloadRoot / "bin" / cfgName; - if (!std::filesystem::exists(cfgPath)) continue; + auto binDir = payloadRoot / "bin"; + if (!std::filesystem::exists(binDir)) return; - std::ifstream is(cfgPath); - std::stringstream ss; ss << is.rdbuf(); - std::string content = ss.str(); - is.close(); + // Target triple from the payload layout (lib/), used for the + // loader lookup and the per-target libc++ include/lib dirs. + std::string triple; + std::error_code ec; + for (auto it = std::filesystem::directory_iterator(payloadRoot / "lib", ec); + !ec && it != std::filesystem::directory_iterator{}; it.increment(ec)) { + auto name = it->path().filename().string(); + if (it->is_directory(ec) && name.find("-linux-") != std::string::npos) { + triple = name; + break; + } + } + + std::string common, cxxOnly; + if constexpr (mcpp::platform::is_macos) { + if (auto sdk = mcpp::platform::macos::sdk_path()) + common += "--sysroot=" + sdk->string() + "\n"; + } else { + if (!glibcLibDir.empty()) { + auto loader = resolve_loader(glibcLibDir, triple); + common += "-B" + glibcLibDir.string() + "\n"; + common += "-L" + glibcLibDir.string() + "\n"; + if (!loader.empty()) + common += "-Wl,--dynamic-linker=" + loader.string() + "\n"; + common += "-Wl,--enable-new-dtags,-rpath," + glibcLibDir.string() + "\n"; + } + common += "-fuse-ld=lld\n--rtlib=compiler-rt\n--unwindlib=libunwind\n"; + } - auto llvmRoot = payloadRoot; - auto replace_line_prefix = [&](std::string& s, std::string_view prefix, - const std::string& newValue) { - std::istringstream lines(s); - std::string result, line; - while (std::getline(lines, line)) { - if (line.starts_with(prefix)) { - result += std::string(prefix) + newValue + '\n'; - } else { - result += line + '\n'; - } - } - s = result; - }; - - // Rewrite --sysroot to remove (mcpp provides this explicitly). - // Rewrite -isystem to point to payload's libc++ headers. - // Rewrite -L and -rpath to point to payload's lib dir. - // Rewrite dynamic-linker to use glibc payload's ld-linux. - std::istringstream lines(content); - std::string result, line; - while (std::getline(lines, line)) { - if (line.starts_with("--sysroot=")) { - // Remove — mcpp provides sysroot via payload paths. - continue; - } - if (line.starts_with("-isystem ")) { - auto oldPath = line.substr(9); - if (oldPath.find("include/c++/v1") != std::string::npos) { - auto relative = oldPath.substr(oldPath.find("include/c++/v1")); - result += "-isystem " + (llvmRoot / relative).string() + '\n'; - continue; - } - if (oldPath.find("include/x86_64") != std::string::npos || - oldPath.find("include/aarch64") != std::string::npos) { - // Target-specific libc++ include. - auto includePos = oldPath.find("include/"); - auto relative = oldPath.substr(includePos); - result += "-isystem " + (llvmRoot / relative).string() + '\n'; - continue; - } - } - if (line.starts_with("-L")) { - auto oldPath = line.substr(2); - if (oldPath.find("lib/x86_64") != std::string::npos || - oldPath.find("lib/aarch64") != std::string::npos) { - auto libPos = oldPath.find("lib/"); - auto relative = oldPath.substr(libPos); - result += "-L" + (llvmRoot / relative).string() + '\n'; - continue; - } - } - if (line.starts_with("-Wl,-rpath,")) { - auto oldPath = line.substr(11); - // Rpath for LLVM lib dir - if (oldPath.find("lib/x86_64") != std::string::npos || - oldPath.find("lib/aarch64") != std::string::npos) { - auto libPos = oldPath.find("lib/"); - auto relative = oldPath.substr(libPos); - result += "-Wl,-rpath," + (llvmRoot / relative).string() + '\n'; - continue; - } - // Rpath for subos/glibc — rewrite to glibc payload. - if (!glibcLibDir.empty()) { - auto parentDir = std::filesystem::path(oldPath).parent_path(); - // subos rpath lines like -Wl,-rpath,/lib - if (oldPath.find("subos") != std::string::npos) { - result += "-Wl,-rpath," + glibcLibDir.string() + '\n'; - continue; - } - } - } - if (line.starts_with("-Wl,--dynamic-linker=")) { - // Rewrite to glibc payload's ld-linux. - if (!glibcLibDir.empty()) { - result += "-Wl,--dynamic-linker=" + - (glibcLibDir / "ld-linux-x86-64.so.2").string() + '\n'; - continue; - } - } - if (line.starts_with("-Wl,--enable-new-dtags,-rpath,")) { - if (!glibcLibDir.empty()) { - result += "-Wl,--enable-new-dtags,-rpath," + glibcLibDir.string() + '\n'; - continue; - } - } - if (line.starts_with("-Wl,-rpath-link,")) { - if (!glibcLibDir.empty()) { - result += "-Wl,-rpath-link," + glibcLibDir.string() + '\n'; - continue; - } - } - result += line + '\n'; + auto cxxInclude = payloadRoot / "include" / "c++" / "v1"; + if (std::filesystem::exists(cxxInclude)) { + cxxOnly += "-nostdinc++\n-stdlib=libc++\n"; + cxxOnly += "-isystem " + cxxInclude.string() + "\n"; + } + if (!triple.empty()) { + auto tripleInclude = payloadRoot / "include" / triple / "c++" / "v1"; + if (std::filesystem::exists(tripleInclude)) + cxxOnly += "-isystem " + tripleInclude.string() + "\n"; + auto tripleLib = payloadRoot / "lib" / triple; + if (std::filesystem::exists(tripleLib)) { + cxxOnly += "-L" + tripleLib.string() + "\n"; + cxxOnly += "-Wl,-rpath," + tripleLib.string() + "\n"; } + } - // Remove trailing newline - while (!result.empty() && result.back() == '\n') result.pop_back(); - result += '\n'; + // Regenerate every existing cfg in bin/ (clang.cfg, clang++.cfg, and any + // versioned clang-.cfg xlings created), classified C vs C++ by + // whether the driver name contains "++". + for (auto it = std::filesystem::directory_iterator(binDir, ec); + !ec && it != std::filesystem::directory_iterator{}; it.increment(ec)) { + auto name = it->path().filename().string(); + if (!name.ends_with(".cfg")) continue; + const bool isCxx = name.find("++") != std::string::npos; + std::ofstream os(it->path()); + os << common << (isCxx ? cxxOnly : std::string{}); + } +} - std::ofstream os(cfgPath); - os << result; +// Locate the sandbox glibc payload's lib dir (the newest installed version +// that actually carries a dynamic loader). Shared by the gcc and llvm fixups. +std::filesystem::path find_sandbox_glibc_lib(const mcpp::xlings::Env& xlEnv) { + auto glibcRoot = mcpp::xlings::paths::xim_tool_root(xlEnv, "glibc"); + std::error_code ec; + for (auto it = std::filesystem::directory_iterator(glibcRoot, ec); + !ec && it != std::filesystem::directory_iterator{}; it.increment(ec)) { + if (auto lib = payload_lib_dir_with_loader(it->path()); !lib.empty()) + return lib; } + return {}; } // Post-install fixup for a freshly-installed GNU gcc payload: patchelf // PT_INTERP/RUNPATH for gcc/binutils binaries + linker-specs wiring against -// the sandbox glibc. ONE pipeline shared by `mcpp toolchain install` and the -// first-run auto-install (the latter previously skipped this, leaving a -// fresh-sandbox glibc gcc unable to find the C library: stdlib.h not found). -export void gcc_post_install_fixup(const mcpp::config::GlobalConfig& cfg, - const std::filesystem::path& payloadRoot) { - // Ownership guard: payloads inherited via symlink from another MCPP_HOME - // are not ours to patch — their owner already ran the fixup, and patching - // through the symlink would rewrite the canonical files against OUR - // (possibly ephemeral) paths, bricking the owner's toolchain. - { - std::error_code ec; - auto canonicalRoot = std::filesystem::weakly_canonical(payloadRoot, ec); - auto homeRegistry = std::filesystem::weakly_canonical(cfg.registryDir, ec); - if (!ec && !canonicalRoot.string().starts_with(homeRegistry.string())) { - mcpp::log::verbose("toolchain", std::format( - "skip gcc fixup: payload '{}' resolves outside this home ('{}') — " - "inherited payload, owner is responsible for its fixup", - payloadRoot.string(), canonicalRoot.string())); - return; - } - } +// the sandbox glibc — without it a fresh-sandbox glibc gcc cannot find the +// C library (stdlib.h not found). +void gcc_post_install_fixup(const mcpp::config::GlobalConfig& cfg, + const std::filesystem::path& payloadRoot, + const std::filesystem::path& glibcLibDir) { auto xlEnv = mcpp::config::make_xlings_env(cfg); - auto glibcRoot = mcpp::xlings::paths::xim_tool_root(xlEnv, "glibc"); - std::filesystem::path glibcLibDir; - if (std::filesystem::exists(glibcRoot)) { - for (auto& v : std::filesystem::directory_iterator(glibcRoot)) { - auto candidate = v.path() / "lib64"; - if (std::filesystem::exists(candidate / "ld-linux-x86-64.so.2")) { - glibcLibDir = candidate; - break; - } - } - } auto gccLibDir = payloadRoot / "lib64"; auto patchelfBin = mcpp::xlings::paths::xim_tool(xlEnv, "patchelf", mcpp::xlings::pinned::kPatchelfVersion) / "bin" / "patchelf"; @@ -316,7 +261,7 @@ export void gcc_post_install_fixup(const mcpp::config::GlobalConfig& cfg, if (!glibcLibDir.empty() && std::filesystem::exists(gccLibDir) && std::filesystem::exists(patchelfBin)) { - auto loader = glibcLibDir / "ld-linux-x86-64.so.2"; + auto loader = resolve_loader(glibcLibDir, /*targetTriple=*/{}); auto rpath = std::format("{}:{}", glibcLibDir.string(), gccLibDir.string()); @@ -338,4 +283,105 @@ export void gcc_post_install_fixup(const mcpp::config::GlobalConfig& cfg, } } +// LLVM payload fixup: RUNPATH for the bundled runtime shared libraries +// (libc++.so / libunwind.so need to find siblings like libatomic.so.1 after +// the payload moved) + deterministic cfg regeneration. Only lib/ dirs are +// walked — NOT bin/: the clang++ binary's own RUNPATH (zlib, libxml2, …) was +// set by xlings and must be preserved. +void llvm_post_install_fixup(const mcpp::config::GlobalConfig& cfg, + const std::filesystem::path& payloadRoot, + const std::filesystem::path& glibcLibDir) { + auto xlEnv = mcpp::config::make_xlings_env(cfg); + auto patchelfBin = mcpp::xlings::paths::xim_tool(xlEnv, "patchelf", + mcpp::xlings::pinned::kPatchelfVersion) / "bin" / "patchelf"; + if (!glibcLibDir.empty() && std::filesystem::exists(patchelfBin)) { + auto loader = resolve_loader(glibcLibDir, /*targetTriple=*/{}); + auto llvmLib = payloadRoot / "lib"; + std::string rpath; + std::error_code ec; + for (auto it = std::filesystem::directory_iterator(llvmLib, ec); + !ec && it != std::filesystem::directory_iterator{}; it.increment(ec)) { + if (it->is_directory(ec) + && it->path().filename().string().find("-linux-") != std::string::npos) + rpath += it->path().string() + ":"; + } + rpath += llvmLib.string() + ":" + glibcLibDir.string(); + mcpp::log::verbose("toolchain", std::format( + "llvm fixup: patchelf_walk lib/ rpath='{}'", rpath)); + patchelf_walk(llvmLib, loader, rpath, patchelfBin); + } + mcpp::log::verbose("toolchain", "llvm fixup: fixup_clang_cfg"); + fixup_clang_cfg(payloadRoot, glibcLibDir); +} + +// ── the single fixup pipeline entry ────────────────────────────────────── +// +// Called from the payload-resolution seam shared by ALL toolchain install +// paths (explicit `mcpp toolchain install`, default-toolchain auto-install, +// and manifest `[toolchain]` auto-install). Previously each path remembered +// (or forgot) its own subset of fixups: the manifest path ran none, which is +// how a fresh llvm install kept a stale install-time cfg and unpatched +// runtime libs. Idempotent via a content-fingerprinted marker. +// +// Bump when the fixup logic changes so existing installs re-run it. +constexpr std::string_view kFixupRev = "hermetic-1"; + +export void ensure_post_install_fixup(const mcpp::config::GlobalConfig& cfg, + const std::filesystem::path& payloadRoot, + const XimToolchainPackage& pkg) { + std::string kind; + if (pkg.needsGccPostInstallFixup) kind = "gcc"; + else if (pkg.ximName == "llvm") kind = "llvm"; + else return; + if constexpr (mcpp::platform::is_windows) return; // PE world: no fixups + + // Ownership guard: payloads inherited via symlink from another MCPP_HOME + // are not ours to patch — their owner already ran the fixup, and patching + // through the symlink would rewrite the canonical files against OUR + // (possibly ephemeral) paths, bricking the owner's toolchain. + { + std::error_code ec; + auto canonicalRoot = std::filesystem::weakly_canonical(payloadRoot, ec); + auto homeRegistry = std::filesystem::weakly_canonical(cfg.registryDir, ec); + if (!ec && !canonicalRoot.string().starts_with(homeRegistry.string())) { + mcpp::log::verbose("toolchain", std::format( + "skip {} fixup: payload '{}' resolves outside this home ('{}') — " + "inherited payload, owner is responsible for its fixup", + kind, payloadRoot.string(), canonicalRoot.string())); + return; + } + } + + auto xlEnv = mcpp::config::make_xlings_env(cfg); + std::filesystem::path glibcLibDir; + if constexpr (mcpp::platform::is_linux) + glibcLibDir = find_sandbox_glibc_lib(xlEnv); + + // Content-fingerprinted marker: a marker whose INPUTS drifted (different + // glibc payload, newer fixup logic) re-runs the fixup — "a process once + // exited 0" is not evidence the current inputs were ever applied. + auto markerPath = payloadRoot / ".mcpp-fixup.json"; + nlohmann::json expected; + expected["schema"] = 1; + expected["kind"] = kind; + expected["rev"] = std::string(kFixupRev); + expected["glibcLib"] = glibcLibDir.generic_string(); + { + std::ifstream is(markerPath); + if (is) { + try { + nlohmann::json actual; + is >> actual; + if (actual == expected) return; // fixup already applied + } catch (...) { /* corrupt marker → re-run */ } + } + } + + if (kind == "gcc") gcc_post_install_fixup(cfg, payloadRoot, glibcLibDir); + else llvm_post_install_fixup(cfg, payloadRoot, glibcLibDir); + + std::ofstream os(markerPath); + os << expected.dump(2) << "\n"; +} + } // namespace mcpp::toolchain From e1c6f303de78c4f102dfc4a5e3c78c99a8f03ffc Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 17:46:51 +0800 Subject: [PATCH 04/15] refactor(probe): stop mining the clang cfg for --sysroot (diagnostic only) The cfg-mined sysroot was dead trust: mcpp's fixup pipeline regenerates the cfg without a --sysroot line (the C library comes from the payload link model), so the mined value existed only on never-fixed-up installs and pointed at an environment directory the payload doesn't own. Builds derive everything from the link model; the cfg serves humans running clang++ directly. The parse is kept as a debug log. --- src/toolchain/probe.cppm | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/toolchain/probe.cppm b/src/toolchain/probe.cppm index 2df1b96..4de603b 100644 --- a/src/toolchain/probe.cppm +++ b/src/toolchain/probe.cppm @@ -304,11 +304,21 @@ probe_sysroot(const std::filesystem::path& compilerBin, } } - // 2. Parse the compiler driver config file (Clang .cfg). - if (auto cfg = mcpp::fallback::parse_clang_cfg_sysroot(compilerBin)) - return *cfg; - - // 3. macOS fallback: use xcrun to discover the SDK path. + // 2. macOS fallback: use xcrun to discover the SDK path. + // + // NOTE: mcpp used to also mine the Clang driver cfg for --sysroot here. + // That trust was dead code walking: the cfg is an install-time-generated + // artifact that mcpp's own fixup pipeline now REGENERATES without a + // --sysroot line (the C library comes from the payload link model), so + // the mined value existed only on never-fixed-up installs and pointed at + // an environment directory the payload doesn't own. The cfg is for + // humans running clang++ directly; builds derive everything from the + // link model. Kept as a diagnostic only. + if (auto cfg = mcpp::fallback::parse_clang_cfg_sysroot(compilerBin)) { + mcpp::log::debug("probe", std::format( + "clang cfg declares sysroot '{}' — ignored (payload-first model)", + cfg->string())); + } if (auto sdk = mcpp::fallback::probe_macos_sdk_sysroot()) return *sdk; From 6e4c755ed1ee9e3cb4d2202c63f3cbe4ea0bc63b Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 17:46:51 +0800 Subject: [PATCH 05/15] =?UTF-8?q?feat(build):=20hermetic=20link=20check=20?= =?UTF-8?q?=E2=80=94=20assert=20CRT/loader=20resolve=20inside=20the=20sand?= =?UTF-8?q?box?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dry-run the driver (-###) with the exact ldflags the build will use and assert every CRT object plus the EFFECTIVE dynamic linker (last occurrence wins — the driver emits its built-in default before the -Wl override) resolve under the sandbox's xpkgs registry or the toolchain sysroot. This converts two previously silent failure modes into one actionable build-time diagnostic: on hosts WITH a system toolchain the driver would quietly link the host's CRT (contamination that made CI green a false signal), and on hosts WITHOUT one it passed bare CRT names lld cannot open (issue #195). Sandbox toolchains only — a PATH/system compiler is the user's explicit choice of the host world. Verdict cached per flag-set (.mcpp-hermetic-ok). Escape hatches: [build] allow_host_libs = true or MCPP_ALLOW_HOST_LIBS=1. --- src/build/hermetic.cppm | 203 +++++++++++++++++++++++++++++++++++ src/build/ninja_backend.cppm | 10 ++ src/manifest.cppm | 7 ++ 3 files changed, 220 insertions(+) create mode 100644 src/build/hermetic.cppm diff --git a/src/build/hermetic.cppm b/src/build/hermetic.cppm new file mode 100644 index 0000000..f38ec5f --- /dev/null +++ b/src/build/hermetic.cppm @@ -0,0 +1,203 @@ +// mcpp.build.hermetic — turn the sandbox self-containment promise into an +// executable assertion. +// +// A sandbox toolchain that cannot resolve its CRT startup objects +// (Scrt1.o/crti.o/crtn.o) or dynamic linker inside the sandbox does one of +// two things, both silent until now: on hosts WITH a system toolchain the +// driver falls back to the host's /lib (host contamination — the build goes +// green while linking a C runtime the sandbox never promised), and on hosts +// WITHOUT one it passes bare CRT names that the linker cannot open (issue +// #195). This check dry-runs the driver (`-###`) with the exact link flags +// the build will use and asserts every CRT object + the dynamic linker +// resolve under an allowed sandbox prefix — converting both failure modes +// into one actionable diagnostic at build time. +// +// Scope: Linux, sandbox toolchains only (a compiler outside the xpkgs +// registry — `[toolchain] system`, a PATH compiler — is the user's explicit +// choice of the host world and is skipped). Escape hatches for users who +// genuinely want host linking: `[build] allow_host_libs = true` or +// MCPP_ALLOW_HOST_LIBS=1 (downgrade to a verbose note). + +module; +#include // getenv + +export module mcpp.build.hermetic; + +import std; +import mcpp.log; +import mcpp.platform; +import mcpp.toolchain.fingerprint; +import mcpp.toolchain.model; + +export namespace mcpp::build { + +// Verify the link is hermetic. `ldflagsNinja` is the ninja-escaped ldflags +// string from flags.cppm (un-escaped internally). Returns an error message +// naming the leaked/bare paths, or empty success. `outputDir` caches the +// verdict per flag-set so unchanged builds don't re-spawn the driver. +std::expected verify_hermetic_link( + const mcpp::toolchain::Toolchain& tc, + const std::string& ldflagsNinja, + const std::filesystem::path& outputDir, + bool allowHostLibs); + +} // namespace mcpp::build + +namespace mcpp::build { + +namespace { + +// Reverse flags.cppm's escape_path (space/'$'/':' are prefixed with '$'). +std::string unescape_ninja(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (std::size_t i = 0; i < s.size(); ++i) { + if (s[i] == '$' && i + 1 < s.size()) { out.push_back(s[++i]); continue; } + out.push_back(s[i]); + } + return out; +} + +bool is_crt_object(std::string_view base) { + static constexpr std::array kCrt = { + "Scrt1.o", "crt1.o", "gcrt1.o", "rcrt1.o", "Mcrt1.o", "crti.o", "crtn.o", + }; + for (auto c : kCrt) + if (base == c) return true; + return base.starts_with("crtbegin") || base.starts_with("crtend") + || base.find("clang_rt.crt") != std::string_view::npos; +} + +bool under_any(const std::filesystem::path& p, + const std::vector& prefixes) { + auto s = p.lexically_normal().string(); + for (auto& pre : prefixes) { + if (pre.empty()) continue; + if (s.starts_with(pre.lexically_normal().string())) return true; + } + return false; +} + +// Split a `-###` output line into tokens; the driver quotes each argument +// with double quotes. +std::vector tokenize(std::string_view out) { + std::vector toks; + std::string cur; + bool inQuote = false; + for (char c : out) { + if (c == '"') { + if (inQuote) { toks.push_back(cur); cur.clear(); } + inQuote = !inQuote; + continue; + } + if (inQuote) cur.push_back(c); + else if (!std::isspace(static_cast(c))) cur.push_back(c); + else if (!cur.empty()) { toks.push_back(cur); cur.clear(); } + } + if (!cur.empty()) toks.push_back(cur); + return toks; +} + +} // namespace + +std::expected verify_hermetic_link( + const mcpp::toolchain::Toolchain& tc, + const std::string& ldflagsNinja, + const std::filesystem::path& outputDir, + bool allowHostLibs) +{ + if constexpr (!mcpp::platform::is_linux) return {}; + + // Sandbox toolchains only: the compiler must live under an xpkgs + // registry. A host/system compiler is the user's explicit opt-in to the + // host world. + auto binStr = tc.binaryPath.string(); + auto xpkgsPos = binStr.find("xpkgs"); + if (xpkgsPos == std::string::npos) return {}; + std::vector allowed; + allowed.emplace_back(binStr.substr(0, xpkgsPos + 5)); // the xpkgs root + if (!tc.sysroot.empty()) allowed.push_back(tc.sysroot); + + if (const char* e = std::getenv("MCPP_ALLOW_HOST_LIBS"); e && *e && *e != '0') + allowHostLibs = true; + + auto ldflags = unescape_ninja(ldflagsNinja); + + // Verdict cache: same driver + flags ⇒ same resolution; skip the spawn. + auto marker = outputDir / ".mcpp-hermetic-ok"; + auto key = mcpp::toolchain::hash_string( + tc.binaryPath.string() + "\x1f" + ldflags + + "\x1f" + (allowHostLibs ? "1" : "0")); + { + std::ifstream is(marker); + std::string prev; + if (is && std::getline(is, prev) && prev == key) return {}; + } + + auto cmd = std::format("{} {} -### -x c++ /dev/null -o /dev/null 2>&1", + mcpp::platform::shell::quote(tc.binaryPath.string()), + ldflags); + auto r = mcpp::platform::process::capture(cmd); + if (r.exit_code != 0) { + // The dry-run itself failing is a link-configuration problem the real + // link will also hit; don't duplicate the diagnosis here. + mcpp::log::verbose("hermetic", std::format( + "-### dry-run failed (rc={}) — skipping hermeticity check", r.exit_code)); + return {}; + } + + std::vector leaks; + auto check = [&](std::string_view value, bool bareIsLeak = true) { + std::filesystem::path p{std::string(value)}; + if (!p.is_absolute()) { + if (bareIsLeak) + leaks.push_back(std::format( + "{} (bare name — the linker cannot resolve it)", std::string(value))); + } else if (!under_any(p, allowed)) { + leaks.push_back(std::format("{} (outside the sandbox)", std::string(value))); + } + }; + auto toks = tokenize(r.output); + // The dynamic linker may appear more than once (the driver emits its + // built-in default, then the -Wl override follows); the LAST occurrence + // on the linker command line wins, so only that one is checked. + std::string effectiveLoader; + for (std::size_t i = 0; i < toks.size(); ++i) { + std::string_view t = toks[i]; + if (t == "-dynamic-linker" && i + 1 < toks.size()) { + effectiveLoader = toks[++i]; + continue; + } + if (t.starts_with("--dynamic-linker=")) { + effectiveLoader = std::string(t.substr(17)); + continue; + } + auto base = std::filesystem::path(std::string(t)).filename().string(); + if (is_crt_object(base)) check(t); + } + if (!effectiveLoader.empty()) check(effectiveLoader); + + if (!leaks.empty()) { + std::string list; + for (auto& l : leaks) list += "\n " + l; + auto msg = std::format( + "hermetic link check failed — the sandbox toolchain resolves its C " + "runtime outside the sandbox:{}\n" + " allowed prefixes: {}\n" + " This usually means the glibc payload is missing or the " + "toolchain install is incomplete (try `mcpp toolchain install` again).\n" + " To deliberately link against host libraries set " + "[build] allow_host_libs = true (or MCPP_ALLOW_HOST_LIBS=1).", + list, allowed.front().string()); + if (!allowHostLibs) return std::unexpected(msg); + mcpp::log::verbose("hermetic", "allow_host_libs set — " + msg); + } + + std::error_code ec; + std::filesystem::create_directories(outputDir, ec); + std::ofstream os(marker); + os << key << "\n"; + return {}; +} + +} // namespace mcpp::build diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index f036705..b206bd7 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -21,6 +21,7 @@ import std; import mcpp.build.backend; import mcpp.build.plan; import mcpp.build.flags; +import mcpp.build.hermetic; import mcpp.build.compile_commands; import mcpp.dyndep; import mcpp.toolchain.detect; @@ -722,6 +723,15 @@ std::expected NinjaBackend::build(const BuildPlan& plan return r; } + // Hermetic link check: assert the sandbox toolchain resolves its CRT + // objects + dynamic linker inside the sandbox BEFORE running the build — + // catches both the bare-CRT link failure (#195) and silent host-library + // contamination, cached per flag-set. + if (auto h = verify_hermetic_link(plan.toolchain, flags.ld, plan.outputDir, + plan.manifest.buildConfig.allowHostLibs); !h) { + return std::unexpected(BuildError{h.error(), {}}); + } + // When the toolchain comes from mcpp's private sandbox, use the // sandbox-local ninja absolute path (skip the system xlings ninja // shim which requires per-tool version pin activation). diff --git a/src/manifest.cppm b/src/manifest.cppm index 5823796..968fce8 100644 --- a/src/manifest.cppm +++ b/src/manifest.cppm @@ -135,6 +135,12 @@ struct BuildConfig { std::vector cxxflags; std::vector ldflags; std::string cStandard; + // Escape hatch for the hermetic link check: a sandbox toolchain whose + // CRT/loader resolve OUTSIDE the sandbox is a hard error by default + // (silent host contamination, or issue #195's bare-CRT link failure); + // set true to deliberately link against host libraries. + // MCPP_ALLOW_HOST_LIBS=1 is the per-invocation equivalent. + bool allowHostLibs = false; // macOS minimum supported OS version for produced binaries // (LC_BUILD_VERSION minos), e.g. "14.0". Mirrors the ecosystem // conventions around deployment targets (the MACOSX_DEPLOYMENT_TARGET @@ -1141,6 +1147,7 @@ std::expected parse_string(std::string_view content, // [build] — backend tunables if (auto v = doc->get_bool("build.static_stdlib")) m.buildConfig.staticStdlib = *v; + if (auto v = doc->get_bool("build.allow_host_libs")) m.buildConfig.allowHostLibs = *v; if (auto v = doc->get_string_array("build.cflags")) m.buildConfig.cflags = *v; if (auto v = doc->get_string_array("build.cxxflags")) m.buildConfig.cxxflags = *v; if (auto v = doc->get_string_array("build.ldflags")) m.buildConfig.ldflags = *v; From df7607ac89190586d857d98725da51d75f6efb0b Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 17:49:51 +0800 Subject: [PATCH 06/15] =?UTF-8?q?refactor:=20loader=20paths=20from=20data?= =?UTF-8?q?=20everywhere=20=E2=80=94=20no=20ld-linux-x86-64=20hardcodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixup_gcc_specs detects the baked loader name from the specs content and replaces it with resolve_loader()'s answer (any glibc arch); the specs dir is discovered instead of assuming x86_64-linux-gnu. mcpp pack derives the BundleProject PT_INTERP distro path from the loader soname ldd resolved for the binary being packed (LSB /lib64 vs /lib), instead of a hardcoded x86_64 string. The only remaining literal loader names live in linkmodel.cppm's per-arch triple map. --- src/pack/pack.cppm | 18 ++++++++--- src/toolchain/post_install.cppm | 56 +++++++++++++++++++-------------- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index 57985b8..6323cf5 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -638,15 +638,23 @@ run(const Plan& plan, const mcpp::config::GlobalConfig& cfg) return std::unexpected(Error{r.error()}); // PT_INTERP handling differs by mode: - // BundleProject → repoint to /lib64/ld-linux-x86-64.so.2 - // (LSB-mandated symlink on glibc distros) + // BundleProject → repoint to the target distro's loader + // (LSB layout: /lib64/ on x86_64, + // /lib/ elsewhere), derived from the + // loader soname ldd resolved for THIS binary — + // arch-correct without hardcoding a name. // BundleAll → leave PT_INTERP alone; the wrapper script // ignores it and launches via the bundled // loader directly. if (plan.opts.mode == Mode::BundleProject || plan.opts.mode == Mode::None) { - if (auto r = set_interpreter(bundledBinary, - "/lib64/ld-linux-x86-64.so.2", patchelf); !r) - return std::unexpected(Error{r.error()}); + if (auto soname = find_loader_soname(*deps); !soname.empty()) { + auto distroLoader = + (soname == "ld-linux-x86-64.so.2" ? "/lib64/" : "/lib/") + + soname; + if (auto r = set_interpreter(bundledBinary, distroLoader, + patchelf); !r) + return std::unexpected(Error{r.error()}); + } } } diff --git a/src/toolchain/post_install.cppm b/src/toolchain/post_install.cppm index 224e8d9..d192a13 100644 --- a/src/toolchain/post_install.cppm +++ b/src/toolchain/post_install.cppm @@ -84,19 +84,19 @@ export void patchelf_walk(const std::filesystem::path& dir, // xlings' home, not mcpp's sandbox glibc — binaries would fail to exec. // // Mcpp does a post-install spec rewrite: -// - Dynamically detects the baked-in lib dir from the specs file -// - Replaces the dynamic-linker path with /ld-linux-x86-64.so.2 -// - Replaces the rpath with : +// - Dynamically detects the baked-in loader path from the specs file +// - Replaces it with the sandbox glibc payload's loader +// - Replaces the rpath with : // Idempotent — skips if already pointing at the correct glibc. -// Extract the baked-in lib directory from a gcc specs file by finding -// the dynamic-linker path that ends with `/ld-linux-x86-64.so.2`. -// xim bakes the installing user's XLINGS_HOME into specs at install -// time, so the path varies per machine — we cannot hardcode it. -std::string detect_baked_lib_dir(const std::string& specsContent) { - constexpr std::string_view kLoader = "/ld-linux-x86-64.so.2"; - auto pos = specsContent.find(kLoader); +// Extract the baked-in glibc loader path (".../ld-linux-.so.N") from a +// gcc specs file. xim bakes the installing user's XLINGS_HOME into specs at +// install time, so the DIR varies per machine, and the loader NAME varies +// per arch — detect both instead of hardcoding either. +std::string detect_baked_loader(const std::string& specsContent) { + constexpr std::string_view kLoaderMark = "/ld-linux-"; + auto pos = specsContent.find(kLoaderMark); if (pos == std::string::npos) return ""; - // Walk backwards to find start of the absolute path + // Walk backwards to find start of the absolute path… auto start = pos; while (start > 0 && specsContent[start - 1] != ' ' && specsContent[start - 1] != ':' @@ -104,21 +104,32 @@ std::string detect_baked_lib_dir(const std::string& specsContent) { && specsContent[start - 1] != '\n') { --start; } - auto dir = specsContent.substr(start, pos - start); - // Sanity: must be absolute - if (dir.empty() || dir[0] != '/') return ""; - // Skip if it already points to the target glibc (no fixup needed) - return dir; + // …and forwards to its end. + auto end = pos + kLoaderMark.size(); + while (end < specsContent.size() + && !std::isspace(static_cast(specsContent[end])) + && specsContent[end] != ':' && specsContent[end] != ';') { + ++end; + } + auto loader = specsContent.substr(start, end - start); + if (loader.empty() || loader[0] != '/') return ""; + return loader; } void fixup_gcc_specs(const std::filesystem::path& gccPkgRoot, const std::filesystem::path& glibcLibDir, const std::filesystem::path& gccLibDir) { - auto specsParent = gccPkgRoot / "lib" / "gcc" / "x86_64-linux-gnu"; - if (!std::filesystem::exists(specsParent)) return; + std::filesystem::path specsParent; + std::error_code ec; + for (auto it = std::filesystem::directory_iterator(gccPkgRoot / "lib" / "gcc", ec); + !ec && it != std::filesystem::directory_iterator{}; it.increment(ec)) { + if (it->is_directory(ec)) { specsParent = it->path(); break; } + } + if (specsParent.empty()) return; - auto loaderReplacement = (glibcLibDir / "ld-linux-x86-64.so.2").string(); + auto loaderReplacement = resolve_loader(glibcLibDir, /*targetTriple=*/{}).string(); + if (loaderReplacement.empty()) return; auto rpathReplacement = std::format("{}:{}", glibcLibDir.string(), gccLibDir.string()); @@ -141,13 +152,12 @@ void fixup_gcc_specs(const std::filesystem::path& gccPkgRoot, std::stringstream ss; ss << is.rdbuf(); std::string content = ss.str(); - auto bakedDir = detect_baked_lib_dir(content); - if (bakedDir.empty()) continue; + auto bakedLoader = detect_baked_loader(content); + if (bakedLoader.empty()) continue; + auto bakedDir = std::filesystem::path(bakedLoader).parent_path().string(); // Already pointing at the right place — no fixup needed. if (bakedDir == glibcLibDir.string()) continue; - auto bakedLoader = bakedDir + "/ld-linux-x86-64.so.2"; - // Order matters: replace the full loader file path first so the // shorter dir pattern doesn't eat its prefix. replace_all(content, bakedLoader, loaderReplacement); From 34d3fc95b51600ba14c5609f41e0d137fa61e1bf Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 17:59:00 +0800 Subject: [PATCH 07/15] test(e2e): 86_llvm_hermetic_link + llvm suite unpinned from 20.1.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 86 asserts, via a -### dry-run with the exact ldflags mcpp generated, that every CRT object and the effective dynamic linker resolve inside the sandbox (an xpkgs path) — failing on both regression modes of issue #195: bare CRT names (link failure on hosts without a system toolchain) and host-CRT contamination (silent on hosts with one). The llvm e2e scripts (36-41, 47, 65) source the new _llvm_env.sh instead of hardcoding llvm@20.1.7: MCPP_E2E_LLVM_VERSION overrides, default is the newest installed payload — so new toolchain versions get coverage the day they're installed instead of never. --- tests/e2e/36_llvm_toolchain.sh | 12 +- tests/e2e/37_llvm_import_std.sh | 11 +- tests/e2e/38_llvm_modules.sh | 11 +- tests/e2e/39_llvm_incremental.sh | 11 +- tests/e2e/40_llvm_bmi_cache.sh | 11 +- tests/e2e/41_llvm_std_compat.sh | 11 +- tests/e2e/47_llvm_atomic_link.sh | 15 ++- .../e2e/65_toolchain_runtime_dirs_for_run.sh | 13 +- tests/e2e/86_llvm_hermetic_link.sh | 121 ++++++++++++++++++ tests/e2e/_llvm_env.sh | 25 ++++ 10 files changed, 198 insertions(+), 43 deletions(-) create mode 100755 tests/e2e/86_llvm_hermetic_link.sh create mode 100755 tests/e2e/_llvm_env.sh diff --git a/tests/e2e/36_llvm_toolchain.sh b/tests/e2e/36_llvm_toolchain.sh index bba2d13..13eb230 100755 --- a/tests/e2e/36_llvm_toolchain.sh +++ b/tests/e2e/36_llvm_toolchain.sh @@ -3,17 +3,17 @@ # 36_llvm_toolchain.sh — build a non-module C/C++ package with xlings LLVM. set -e +source "$(dirname "$0")/_llvm_env.sh" + OS="$(uname -s)" # On Windows the clang++ binary has a .exe suffix if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then - LLVM_ROOT="${USERPROFILE}/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7" CLANGPP_BIN="$LLVM_ROOT/bin/clang++.exe" else - LLVM_ROOT="${HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7" CLANGPP_BIN="$LLVM_ROOT/bin/clang++" fi if [[ ! -x "$CLANGPP_BIN" ]]; then - echo "SKIP: xlings llvm@20.1.7 is not installed" + echo "SKIP: xlings llvm@${LLVM_VERSION} is not installed" exit 0 fi @@ -40,7 +40,7 @@ version = "0.1.0" import_std = false [toolchain] -${TC_KEY} = "llvm@20.1.7" +${TC_KEY} = "llvm@${LLVM_VERSION}" EOF cat > src/main.cpp <<'EOF' @@ -61,9 +61,9 @@ int answer(void) { EOF "$MCPP" toolchain list > "$TMP/list.log" 2>&1 -grep -q 'llvm 20.1.7' "$TMP/list.log" || { +grep -q "llvm ${LLVM_VERSION}" "$TMP/list.log" || { cat "$TMP/list.log" - echo "FAIL: toolchain list did not show installed llvm 20.1.7" + echo "FAIL: toolchain list did not show installed llvm ${LLVM_VERSION}" exit 1 } diff --git a/tests/e2e/37_llvm_import_std.sh b/tests/e2e/37_llvm_import_std.sh index 51f5724..efb0431 100755 --- a/tests/e2e/37_llvm_import_std.sh +++ b/tests/e2e/37_llvm_import_std.sh @@ -12,13 +12,14 @@ if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then exit 0 fi -LLVM_ROOT="${HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7" +source "$(dirname "$0")/_llvm_env.sh" + if [[ ! -x "$LLVM_ROOT/bin/clang++" ]]; then - echo "SKIP: xlings llvm@20.1.7 is not installed" + echo "SKIP: xlings llvm@${LLVM_VERSION} is not installed" exit 0 fi if [[ ! -f "$LLVM_ROOT/share/libc++/v1/std.cppm" ]]; then - echo "SKIP: xlings llvm@20.1.7 has no libc++ std.cppm" + echo "SKIP: xlings llvm@${LLVM_VERSION} has no libc++ std.cppm" exit 0 fi @@ -30,13 +31,13 @@ source "$(dirname "$0")/_inherit_toolchain.sh" mkdir -p "$TMP/proj/src" cd "$TMP/proj" -cat > mcpp.toml <<'EOF' +cat > mcpp.toml < src/main.cpp <<'EOF' diff --git a/tests/e2e/38_llvm_modules.sh b/tests/e2e/38_llvm_modules.sh index 2ce8849..f03ee7e 100755 --- a/tests/e2e/38_llvm_modules.sh +++ b/tests/e2e/38_llvm_modules.sh @@ -16,13 +16,14 @@ if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then exit 0 fi -LLVM_ROOT="${HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7" +source "$(dirname "$0")/_llvm_env.sh" + if [[ ! -x "$LLVM_ROOT/bin/clang++" ]]; then - echo "SKIP: xlings llvm@20.1.7 is not installed" + echo "SKIP: xlings llvm@${LLVM_VERSION} is not installed" exit 0 fi if [[ ! -f "$LLVM_ROOT/share/libc++/v1/std.cppm" ]]; then - echo "SKIP: xlings llvm@20.1.7 has no libc++ std.cppm" + echo "SKIP: xlings llvm@${LLVM_VERSION} has no libc++ std.cppm" exit 0 fi @@ -34,13 +35,13 @@ source "$(dirname "$0")/_inherit_toolchain.sh" mkdir -p "$TMP/proj/src" cd "$TMP/proj" -cat > mcpp.toml <<'EOF' +cat > mcpp.toml < mcpp.toml <<'EOF' +cat > mcpp.toml < src/greet.cppm <<'EOF' diff --git a/tests/e2e/40_llvm_bmi_cache.sh b/tests/e2e/40_llvm_bmi_cache.sh index 16ce2f3..41d23eb 100755 --- a/tests/e2e/40_llvm_bmi_cache.sh +++ b/tests/e2e/40_llvm_bmi_cache.sh @@ -12,13 +12,14 @@ if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then exit 0 fi -LLVM_ROOT="${HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7" +source "$(dirname "$0")/_llvm_env.sh" + if [[ ! -x "$LLVM_ROOT/bin/clang++" ]]; then - echo "SKIP: xlings llvm@20.1.7 is not installed" + echo "SKIP: xlings llvm@${LLVM_VERSION} is not installed" exit 0 fi if [[ ! -f "$LLVM_ROOT/share/libc++/v1/std.cppm" ]]; then - echo "SKIP: xlings llvm@20.1.7 has no libc++ std.cppm" + echo "SKIP: xlings llvm@${LLVM_VERSION} has no libc++ std.cppm" exit 0 fi @@ -40,12 +41,12 @@ fi mkdir -p "$TMP/proj/src" cd "$TMP/proj" -cat > mcpp.toml <<'EOF' +cat > mcpp.toml < mcpp.toml <<'EOF' +cat > mcpp.toml < src/main.cpp <<'EOF' diff --git a/tests/e2e/47_llvm_atomic_link.sh b/tests/e2e/47_llvm_atomic_link.sh index dac2f8d..42d794f 100755 --- a/tests/e2e/47_llvm_atomic_link.sh +++ b/tests/e2e/47_llvm_atomic_link.sh @@ -17,17 +17,18 @@ if [[ "$OS" == Darwin* ]]; then exit 0 fi -LLVM_ROOT="${HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7" +source "$(dirname "$0")/_llvm_env.sh" + if [[ ! -x "$LLVM_ROOT/bin/clang++" ]]; then - echo "SKIP: xlings llvm@20.1.7 is not installed" + echo "SKIP: xlings llvm@${LLVM_VERSION} is not installed" exit 0 fi # The fix only emits -latomic when a link-resolvable libatomic is present in -# the toolchain (self-guarding). 20.1.7 bundles it; if a slimmed package does -# not, the genuine-atomic case is out of scope here. +# the toolchain (self-guarding). If a slimmed package does not bundle it, the +# genuine-atomic case is out of scope here. if [[ ! -e "$LLVM_ROOT/lib/x86_64-unknown-linux-gnu/libatomic.so" \ && ! -e "$LLVM_ROOT/lib/x86_64-unknown-linux-gnu/libatomic.a" ]]; then - echo "SKIP: llvm@20.1.7 package ships no link-resolvable libatomic" + echo "SKIP: llvm@${LLVM_VERSION} package ships no link-resolvable libatomic" exit 0 fi @@ -39,13 +40,13 @@ source "$(dirname "$0")/_inherit_toolchain.sh" mkdir -p "$TMP/proj/src" cd "$TMP/proj" -cat > mcpp.toml <<'EOF' +cat > mcpp.toml < mcpp.toml <<'EOF' +cat > mcpp.toml < "$TMP/build.log" 2>&1 || { cat "$TMP/build.log" diff --git a/tests/e2e/86_llvm_hermetic_link.sh b/tests/e2e/86_llvm_hermetic_link.sh new file mode 100755 index 0000000..2a6078b --- /dev/null +++ b/tests/e2e/86_llvm_hermetic_link.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# requires: import-std-libcxx +# 86_llvm_hermetic_link.sh — hermetic-link regression fence for issue #195. +# +# Two failure modes used to be invisible: +# - on hosts WITHOUT a system toolchain, the llvm link passed bare CRT +# names (Scrt1.o/crti.o/crtn.o) that lld cannot open → link failure; +# - on hosts WITH one, the driver silently resolved the HOST's CRT → +# contaminated "green" builds that masked the first mode on CI. +# This test asserts, via a `-###` dry-run with the exact ldflags mcpp +# generated, that every CRT object and the effective dynamic linker resolve +# inside the sandbox (an xpkgs registry path) — on ANY machine, with or +# without a host toolchain. +set -e + +OS="$(uname -s)" +if [[ "$OS" != Linux ]]; then + echo "SKIP: hermetic CRT/loader model is Linux-only" + exit 0 +fi + +source "$(dirname "$0")/_llvm_env.sh" +if [[ ! -x "$LLVM_ROOT/bin/clang++" ]]; then + echo "SKIP: xlings llvm@${LLVM_VERSION} is not installed" + exit 0 +fi +if [[ ! -f "$LLVM_ROOT/share/libc++/v1/std.cppm" ]]; then + echo "SKIP: xlings llvm@${LLVM_VERSION} has no libc++ std.cppm" + exit 0 +fi + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +export MCPP_HOME="$TMP/mcpp-home" +source "$(dirname "$0")/_inherit_toolchain.sh" + +mkdir -p "$TMP/proj/src" +cd "$TMP/proj" + +cat > mcpp.toml < src/main.cpp <<'EOF' +import std; + +int main() { + std::println("hermetic {}", 195); + return 0; +} +EOF + +"$MCPP" build --no-cache > "$TMP/build.log" 2>&1 || { + cat "$TMP/build.log" + echo "FAIL: llvm build failed" + exit 1 +} + +# 1. The link flags must carry the CRT discovery prefix (-B). +ninja_file=$(find target -name build.ninja | head -1) +ldflags=$(grep '^ldflags' "$ninja_file" | sed 's/^ldflags *= *//') +echo "$ldflags" | grep -q ' -B' || { + echo "ldflags: $ldflags" + echo "FAIL: link flags carry no -B (CRT discovery prefix)" + exit 1 +} + +# 2. mcpp's own hermetic check ran and cached a verdict. +[[ -f "$(dirname "$ninja_file")/.mcpp-hermetic-ok" ]] || { + echo "FAIL: build did not record a hermetic-link verdict" + exit 1 +} + +# 3. Independent -### dry-run: CRT objects + effective loader must resolve +# to absolute paths inside an xpkgs registry (never /lib, /usr/lib, or +# bare names). This is the assertion that fails on host contamination +# even when the build itself "succeeds". +dry=$("$LLVM_ROOT/bin/clang++" $(echo "$ldflags" | sed 's/\$\(.\)/\1/g') \ + -### -x c++ /dev/null -o /dev/null 2>&1) + +crt_tokens=$(echo "$dry" | tr ' ' '\n' | tr -d '"' \ + | grep -E '(^|/)(S|g|r|M)?crt[1in]\.o$' | grep -v clang_rt || true) +[[ -n "$crt_tokens" ]] || { + echo "FAIL: -### output contains no CRT objects to check" + exit 1 +} +while IFS= read -r tok; do + case "$tok" in + */xpkgs/*) ;; # sandbox payload — OK + /*) echo "FAIL: CRT resolves outside the sandbox: $tok"; exit 1 ;; + *) echo "FAIL: bare CRT name reached the linker: $tok"; exit 1 ;; + esac +done <<< "$crt_tokens" + +# Effective dynamic linker = LAST occurrence (the driver emits its built-in +# default first; the -Wl override follows and wins). +loader=$(echo "$dry" | tr ' ' '\n' | tr -d '"' \ + | grep -o -- '--dynamic-linker=.*' | tail -1 | cut -d= -f2) +if [[ -z "$loader" ]]; then + loader=$(echo "$dry" | tr ' ' '\n' | tr -d '"' \ + | grep -A1 -- '^-dynamic-linker$' | tail -1) +fi +[[ "$loader" == */xpkgs/* ]] || { + echo "FAIL: effective dynamic linker outside the sandbox: '$loader'" + exit 1 +} + +# 4. And the binary actually runs. +binary=$(find target -type f -path '*/bin/hermetic_hello' | head -1) +out=$("$binary") +[[ "$out" == "hermetic 195" ]] || { + echo "FAIL: wrong runtime output: $out" + exit 1 +} + +echo "OK" diff --git a/tests/e2e/_llvm_env.sh b/tests/e2e/_llvm_env.sh new file mode 100755 index 0000000..5df2b57 --- /dev/null +++ b/tests/e2e/_llvm_env.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# _llvm_env.sh — resolve the LLVM toolchain the llvm e2e tests run against. +# +# Tests used to pin llvm@20.1.7, which meant zero coverage of newer payloads +# (a 22.x-only regression sailed through). Sourcing this sets: +# LLVM_VERSION — $MCPP_E2E_LLVM_VERSION if set, else the newest installed +# LLVM_ROOT — the payload root for that version +# Callers still SKIP when LLVM_ROOT doesn't exist (nothing installed). +# +# Usage: source "$(dirname "$0")/_llvm_env.sh" + +_llvm_base="${HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm" +if [[ ! -d "$_llvm_base" && -n "${USERPROFILE:-}" ]]; then + _llvm_base="${USERPROFILE}/.mcpp/registry/data/xpkgs/xim-x-llvm" +fi + +if [[ -n "${MCPP_E2E_LLVM_VERSION:-}" ]]; then + LLVM_VERSION="$MCPP_E2E_LLVM_VERSION" +else + # Version dirs only (e.g. 20.1.7, 22.1.8) — a payload root may contain + # stray non-version entries. + LLVM_VERSION="$(ls -1 "$_llvm_base" 2>/dev/null | grep -E '^[0-9]+(\.[0-9]+)*$' | sort -V | tail -1)" +fi +LLVM_ROOT="$_llvm_base/${LLVM_VERSION:-none}" +export LLVM_VERSION LLVM_ROOT From a146b51e286daf96c44c73092e6a2146348e100e Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 17:59:00 +0800 Subject: [PATCH 08/15] =?UTF-8?q?ci:=20hermetic=20e2e=20job=20=E2=80=94=20?= =?UTF-8?q?no=20host=20toolchain=20container?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit debian:stable-slim with NO compiler and NO host Scrt1.o: the only environment class that faithfully reproduces issue #195 (standard runners ship libc6-dev, so a sandbox toolchain leaking to the host CRT still links green there). Bootstraps xlings + released mcpp, builds the PR code with the sandbox gcc only, then runs the #195 manifest-llvm reproduction plus the hermetic e2e subset. --- .github/workflows/ci-linux-e2e.yml | 73 ++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index d1d03b6..de672c6 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -117,3 +117,76 @@ jobs: # Warm musl once so fresh-home e2e tests inherit the payload. "$MCPP" toolchain install gcc 15.1.0-musl bash tests/e2e/run_all.sh + + # ────────────────────────────────────────────────────────────────── + # Hermetic (no host toolchain): the ONLY environment class that + # faithfully reproduces issue #195. Standard runners ship gcc + + # libc6-dev, so a sandbox toolchain that leaks to the host's CRT + # still links "green" there; this container has no compiler and no + # host Scrt1.o, so any leak fails loudly. Builds PR code with the + # bootstrap mcpp, then runs the llvm flow end-to-end. + # ────────────────────────────────────────────────────────────────── + hermetic: + name: hermetic e2e (no host toolchain, container) + runs-on: ubuntu-24.04 + container: debian:stable-slim + timeout-minutes: 60 + env: + XLINGS_NON_INTERACTIVE: '1' + steps: + - name: Install base utilities (NO compiler) + run: | + apt-get update -qq + apt-get install -y -qq curl ca-certificates git xz-utils unzip + # The whole point of this job: no host toolchain, no host CRT. + ! command -v gcc + ! command -v cc + test ! -e /usr/lib/x86_64-linux-gnu/Scrt1.o + test ! -e /usr/lib/gcc + + - uses: actions/checkout@v4 + + # Payload cache (downloads only — the container still has no host + # toolchain, which is the property under test). + - name: Cache mcpp sandbox payloads + uses: actions/cache@v4 + with: + path: ~/.mcpp + key: mcpp-hermetic-${{ hashFiles('mcpp.toml') }} + restore-keys: | + mcpp-hermetic- + + - name: Bootstrap xlings + released mcpp + run: | + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v0.4.62 + export PATH="$HOME/.xlings/subos/current/bin:$PATH" + xlings update + xlings install mcpp -y -g + MCPP_BOOT="$HOME/.xlings/subos/current/bin/mcpp" + "$MCPP_BOOT" --version + "$MCPP_BOOT" self config --mirror GLOBAL + echo "MCPP_BOOT=$MCPP_BOOT" >> "$GITHUB_ENV" + + - name: Build PR mcpp from source (sandbox gcc only) + run: | + "$MCPP_BOOT" build + MCPP=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)") + test -x "$MCPP" + "$MCPP" --version + echo "MCPP=$MCPP" >> "$GITHUB_ENV" + + - name: "issue #195 reproduction: manifest llvm toolchain, fresh" + run: | + cd "$(mktemp -d)" + "$MCPP" new hello195 + cd hello195 + printf '\n[toolchain]\nlinux = "llvm@22.1.8"\n' >> mcpp.toml + printf 'import std;\nint main() { std::println("hello {}", 195); return 0; }\n' > src/main.cpp + "$MCPP" run + + - name: Hermetic llvm e2e subset + run: | + export PATH="$HOME/.xlings/subos/current/bin:$PATH" + export MCPP + bash tests/e2e/86_llvm_hermetic_link.sh + bash tests/e2e/37_llvm_import_std.sh From c7441630994ca31fe781d3959f25144c77bef29a Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 17:59:00 +0800 Subject: [PATCH 09/15] chore: 0.0.83 + changelog (hermetic toolchain link model) --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ mcpp.toml | 2 +- src/toolchain/fingerprint.cppm | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c3341e..853439d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,37 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [0.0.83] — 2026-07-07 + +### 修复 + +- **Linux llvm 工具链链接失败 `cannot open Scrt1.o/crti.o/crtn.o`(#195)**:clang-with-cfg + 的 payload 链接路径此前只带 `-L/-rpath/--dynamic-linker`,缺少 CRT 启动对象的发现前缀 + `-B`——driver 查找 `Scrt1.o/crti.o/crtn.o` 只走 `-B` 前缀与 sysroot + 派生路径,不查 `-L`。在装有宿主 libc6-dev 的机器上 driver 会静默兜底宿主 `/lib` 的 CRT + (污染式"假绿"),在没有的机器(如全新 WSL2)上则把裸文件名传给 lld 直接失败。 + +### 新增 / 架构 + +- **工具链链接模型单一化(hermetic toolchain link model)**:新增 `mcpp.toolchain.linkmodel` + 作为「如何对该工具链的 C 库编译/链接」的唯一解析器(payload-first,--sysroot 回退), + `flags` / `stdmod` / `build_program` / cfg 再生全部消费同一模型,消除四份漂移实现; + 动态链接器名按 声明式 payload 元数据 → 按 triple 的 arch 映射 → glob 三级解析,全链 + 不再硬编码 `ld-linux-x86-64.so.2`(aarch64 glibc 的 loader 障碍随之消除)。详见 + `.agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md`。 +- **post-install fixup 归位为统一管线**:`ensure_post_install_fixup` 成为所有工具链安装 + 路径(显式 install / 默认工具链 auto-install / manifest `[toolchain]` auto-install)共享 + 的唯一 fixup 入口,内容指纹 marker 幂等;此前 manifest 路径不跑任何 fixup。clang cfg + 由行级补丁改为从链接模型**确定性再生**(同一 payload 在任何机器/安装路径产出一致 cfg, + 人类直接使用 `clang++` 同样获得 hermetic 的 CRT 发现)。 +- **hermetic 链接校验**:构建前用 `-###` 干跑断言 CRT 对象与生效 dynamic linker 全部解析 + 在沙箱(xpkgs registry)内,越界即报错并指明泄漏路径;逃生阀 + `[build] allow_host_libs = true` / `MCPP_ALLOW_HOST_LIBS=1`。按 flag 集缓存判定。 +- **测试与 CI**:新增 e2e `86_llvm_hermetic_link.sh`(`-###` 前缀断言,双向防「链接失败」 + 与「宿主污染」回归);llvm e2e 解除 20.1.7 硬 pin(`MCPP_E2E_LLVM_VERSION`,默认最新 + 已装 payload);ci-linux-e2e 新增 **无宿主工具链容器 job**(debian:stable-slim,无 gcc / + 无宿主 CRT)——唯一能真实复现 #195 环境类的 CI 形态。 + ## [0.0.71] — 2026-06-29 ### 新增 diff --git a/mcpp.toml b/mcpp.toml index a26aa2d..9d74167 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.82" +version = "0.0.83" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 3645e51..c864490 100644 --- a/src/toolchain/fingerprint.cppm +++ b/src/toolchain/fingerprint.cppm @@ -18,7 +18,7 @@ import mcpp.toolchain.detect; export namespace mcpp::toolchain { -inline constexpr std::string_view MCPP_VERSION = "0.0.82"; +inline constexpr std::string_view MCPP_VERSION = "0.0.83"; struct FingerprintInputs { Toolchain toolchain; From 68958da60437b3c882a798cd1aada438547703ad Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 18:02:56 +0800 Subject: [PATCH 10/15] fix(hermetic): allow the canonical xpkgs registry for symlink-inherited payloads mcpp passes symlink-view paths on the command line, but the clang driver reports its own resource dir (clang_rt.crt*) through the canonical path; with payloads symlink-inherited from another MCPP_HOME (the e2e isolation pattern) that canonical registry must be allowed too. --- src/build/hermetic.cppm | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/build/hermetic.cppm b/src/build/hermetic.cppm index f38ec5f..e192bfc 100644 --- a/src/build/hermetic.cppm +++ b/src/build/hermetic.cppm @@ -116,6 +116,17 @@ std::expected verify_hermetic_link( if (xpkgsPos == std::string::npos) return {}; std::vector allowed; allowed.emplace_back(binStr.substr(0, xpkgsPos + 5)); // the xpkgs root + // Payloads may be symlink-inherited from another MCPP_HOME (the e2e + // isolation pattern): mcpp passes the symlink-view paths on the command + // line, but the driver reports its own resource dir (clang_rt.crt*) + // through the CANONICAL path. Allow that registry too. + { + std::error_code ec; + auto canon = std::filesystem::weakly_canonical(tc.binaryPath, ec).string(); + auto pos = ec ? std::string::npos : canon.find("xpkgs"); + if (pos != std::string::npos) + allowed.emplace_back(canon.substr(0, pos + 5)); + } if (!tc.sysroot.empty()) allowed.push_back(tc.sysroot); if (const char* e = std::getenv("MCPP_ALLOW_HOST_LIBS"); e && *e && *e != '0') From 17ecd649fe16accbbc1dfde416a0981c3ec5226f Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 18:02:56 +0800 Subject: [PATCH 11/15] docs: hermetic toolchain link model design (issue #195) --- ...07-hermetic-toolchain-link-model-design.md | 347 ++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 .agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md diff --git a/.agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md b/.agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md new file mode 100644 index 0000000..74960dc --- /dev/null +++ b/.agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md @@ -0,0 +1,347 @@ +# Hermetic toolchain link model — one-shot cross-repo fix for issue #195 + +Fixes [mcpp-community/mcpp#195] (llvm toolchain link fails with +`ld.lld: cannot open Scrt1.o/crti.o/crtn.o` on Linux) at the architecture +level: one coordinated change set across **mcpp**, **xim-pkgindex**, +**xlings-res** and (optionally) **xlings**, shipped as mcpp **0.0.83**. + +Companion analysis precedents: PR #62 (payload-first sysroot, removed the +subos override), PR #119 (single deployment-target resolver on macOS — the +same "one resolver, many consumers" cure applied here to the Linux C-library +axis), PR #124 (macOS floor + static libc++ default). + +--- + +## 1. Problem statement (what #195 actually is) + +`mcpp` links Clang-with-cfg toolchains with `--no-default-config` and +re-provides all paths itself. On the payload path (glibc xpkg present — the +normal result of installing llvm, whose deps declare `xim:glibc`), the +compile side is complete (`-isystem` payload headers) but the **link side +provides only `-L`/`-rpath`/`--dynamic-linker` and no `-B`** +(`src/build/flags.cppm`, `payload_ld`). The Clang driver locates CRT startup +objects (`Scrt1.o`, `crti.o`, `crtn.o`) through `-B` prefixes and +sysroot-derived paths — **never through `-L`** — so it passes the bare file +names to `lld`, which fails. + +Why it was invisible until now: on hosts that have a system C toolchain +installed (dev machines, CI runners with `libc6-dev`), the driver silently +falls back to the **host's** `/lib/x86_64-linux-gnu/Scrt1.o`. The link +"succeeds" by mixing host CRT + payload glibc + payload loader. On hosts +without one (the reporter's fresh WSL2 Ubuntu), the bug surfaces +immediately. Verified both ways with `clang++ -###`: without `-B` the CRT +resolves to host paths (or bare names); with `-B` all +three CRT objects resolve inside the payload and the binary links and runs. + +## 2. Root architecture defects (why a one-line `-B` is not enough) + +The knowledge "how to compile/link against the payload glibc" currently has +**five divergent implementations across two repos** and no owner: + +| # | Copy | Behavior today | +|---|------|----------------| +| 1 | `mcpp src/build/flags.cppm` (main build) | bypasses cfg (`--no-default-config`), compile side complete, **link side missing `-B`** ← #195 | +| 2 | `mcpp src/toolchain/stdmod.cppm` (std module precompile) | independent re-implementation of the compile side | +| 3 | `mcpp src/build/build_program.cppm` `host_base_flags` (build.mcpp host) | **trusts** the cfg for Clang | +| 4 | `mcpp src/toolchain/post_install.cppm` `fixup_clang_cfg` | **rewrites** the cfg (deletes `--sysroot`, comment promises "mcpp provides sysroot via payload paths" — copy #1 never delivered it on the link side) | +| 5 | `xim-pkgindex pkgs/l/llvm.lua` `__install_linux_cfg` | generates the cfg **from the install-time environment** (subos present → `--sysroot=`; absent → warn *"clang will use system sysroot"* — host fallback by design) | + +Consequences found while tracing #195: + +- **D1 — cfg trust is undefined.** flags bypasses it, `probe_sysroot` + step 2 (`fallback/probe_sysroot.cppm parse_clang_cfg_sysroot`) *mines* it + for `--sysroot`, `build_program` trusts it, `fixup_clang_cfg` rewrites it + and deletes the very line probe mines (so probe's cfg path is dead on any + fixed-up install). Four attitudes toward one file. +- **D2 — cfg is a non-reproducible artifact.** Same xpkg version produces + different cfgs depending on whether a subos existed at install time, and + on *which mcpp code path installed it* (see D3). The #195 reporter's cfg + carried `--sysroot=`; a fixed-up install carries no `--sysroot` and + a payload loader line. +- **D3 — post-install fixups are not part of the pipeline.** Explicit + `mcpp toolchain install` runs gcc + llvm fixups + (`src/toolchain/lifecycle.cppm`); the default-toolchain auto-install runs + only the gcc fixup (`src/build/prepare.cppm`); the **manifest + `[toolchain]` auto-install path runs no fixup at all** — the exact path a + `mcpp.toml` llvm user takes. The gcc side already had this bug once + ("stdlib.h not found", fixed by sharing the fixup with one auto-install + path); llvm re-created it on another path. Structural, not incidental. +- **D4 — hermeticity is a promise, not an assertion.** Nothing at build, + test or packaging time verifies that resolved CRT/libc/loader paths lie + inside the sandbox. Host fallback makes CI green a false signal. +- **D5 — `ld-linux-x86-64.so.2` is hardcoded ~10× in mcpp** + (`flags.cppm`, `pack.cppm` ×3, `lifecycle.cppm` ×3, `post_install.cppm` + ×5) **and again in xim-pkgindex** (`llvm.lua`, `gcc.lua`, + `gcc-specs-config.lua`, …), while `glibc.lua` already declares + `exports.runtime = { loader, abi }` *precisely so consumers don't + hardcode this* — and no consumer reads it. aarch64-glibc is silently + broken across the whole chain. +- **D6 — test blind spot.** llvm e2e (36–41, 47) pin `llvm@20.1.7` (zero + coverage of 22.x) and run on hosts whose system CRT masks the entire + category via D4. +- **D7 — packaging has no admission gate.** Precedent: a slim-repacked + llvm asset shipped without a runtime library it needed, caught only by + users. Same class as #195: install-time artifacts are never smoke-linked. + +## 3. Design + +### 3.1 `ToolchainLinkModel` — one resolver, all consumers (mcpp) + +New module `src/toolchain/linkmodel.cppm`: + +```cpp +export namespace mcpp::toolchain { + +enum class CLibMode { PayloadFirst, Sysroot, HostSDK, SelfContained /*musl*/ }; + +struct ToolchainLinkModel { + CLibMode mode; + std::filesystem::path crtDir; // -B: where Scrt1.o/crti.o/crtn.o live + std::vector libDirs; // -L + -Wl,-rpath + std::filesystem::path loader; // -Wl,--dynamic-linker (empty ⇒ omit) + std::vector systemIncludes; // compile-side -isystem + // canonical flag renderers so every consumer emits IDENTICAL strings: + std::string compile_flags(bool clang) const; // -isystem… (+ --sysroot when mode==Sysroot) + std::string link_flags() const; // -B… -L… -rpath… --dynamic-linker=… +}; + +ToolchainLinkModel resolve_link_model(const Toolchain& tc); + +} // namespace +``` + +Resolution (Linux, glibc world; musl/macOS/windows return their existing +behavior unchanged under `SelfContained`/`HostSDK`): + +1. `payloadPaths` present → `PayloadFirst`: `crtDir = libDirs[0] = + glibcLib`, `systemIncludes = {glibcInclude, linuxInclude}`, + `loader` per §3.2. +2. else usable `tc.sysroot` → `Sysroot`: `--sysroot` on both sides (GCC + include-fixed requirement preserved). +3. else → empty model (current behavior; the hermeticity check in §3.5 + turns silent host fallback into a diagnosed warning). + +Consumers — all four divergent copies converge on the model: + +- `flags.cppm`: `compile_toolchain_flags`/`link_toolchain_flags`/`payload_ld` + are computed **from the model**. This is where the `-B` lands (fixing + #195), as a model field, not a patch. +- `stdmod.cppm`: `sysroot_flag` assembly replaced by + `model.compile_flags()`. (Identical strings also keep the + `std-module.json` `std_build_commands` cache key honest.) +- `build_program.cppm host_base_flags`: Clang branch stops trusting the cfg; + uses the model like everything else. (GCC branch already matches the + model's Sysroot/`-B` shape.) +- `post_install.cppm fixup_clang_cfg`: regenerates the cfg **from the + model** (see §3.3) instead of line-patching xlings' output. + +### 3.2 Loader/ABI from data, not hardcodes (mcpp + pkgindex) + +`resolve_loader(glibcLib, targetTriple)` in `linkmodel.cppm`, in priority +order: + +1. **Declared metadata** — if the payload carries persisted exports + (`/.xpkg-exports.json`, written by xlings at install time once + the optional xlings change in §4.4 lands), use `runtime.loader`. +2. **Triple map** — `x86_64 → ld-linux-x86-64.so.2`, + `aarch64 → ld-linux-aarch64.so.1`, `riscv64 → ld-linux-riscv64-lp64d.so.1`, + musl triples → `ld-musl-.so.1`. +3. **Glob fallback** — first `ld-*.so*` in `glibcLib`. + +All ~10 mcpp hardcode sites (`flags.cppm:367`, `pack.cppm`, +`lifecycle.cppm`, `post_install.cppm`) switch to this resolver. This +resolves the aarch64-glibc loader gap as a by-product — do **not** fix +`flags.cppm:367` as an isolated issue; it is one instance of D5. + +### 3.3 cfg role: humans only; deterministic content (mcpp + pkgindex) + +- **mcpp never reads the cfg again.** Build flags: always + `--no-default-config` + full model (unchanged direction, now complete). + `parse_clang_cfg_sysroot` is removed from the probe chain (it is dead on + fixed-up installs anyway) and demoted to a verbose diagnostic log. +- **The cfg exists so a human running `clang++` directly gets a working, + hermetic compiler.** It is generated deterministically from the model: + `-B`, `-L`, `--dynamic-linker`, rpath, libc++ + `-isystem`s — no `--sysroot=`, no dependence on install-time + environment. +- **`llvm.lua __install_linux_cfg` (xim-pkgindex)** is rewritten to the same + deterministic recipe: resolve the sibling `xim:glibc` payload (guaranteed + by the package's own `deps`), read `exports.runtime.loader` from + `glibc.lua` (finally consuming the mechanism built for this), and + **fail the install** if the payload is missing — delete the + `warn + "clang will use system sysroot"` host-fallback branch. + Also: name the versioned cfg from the actual major + (`clang-.cfg`), not the hardcoded `clang-20.cfg`. +- Compat: an old mcpp (≤0.0.82) against the new cfg behaves no worse than + against today's fixed-up cfg (both lack `--sysroot`); the #195 bug in old + binaries is fixed only by upgrading mcpp, which is expected. + +### 3.4 Fixups become a pipeline stage (mcpp) + +New single entry `toolchain::ensure_post_install_fixup(cfg, payload, pkg)`: + +- Dispatches by package kind (gcc → patchelf + specs; llvm → patchelf(lib) + + cfg regeneration from the model; musl → none). +- **Idempotent via a content-fingerprinted marker** + (`/.mcpp-fixup.json`: schema version + fixup-input hash — glibc + lib dir, loader path, mcpp fixup code version). A marker whose inputs + drifted re-runs the fixup (lesson from the `.mcpp_ok` blind-spot class: + markers must witness content, not just "a process once exited 0"). +- Called from **one** place: the payload-resolution seam all three current + call sites share (after `resolve_xpkg_path` for toolchain packages), so + explicit install, default auto-install and manifest `[toolchain]` + auto-install can never diverge again. The three existing scattered calls + are deleted. +- Ownership guard (inherited/symlinked payloads are not patched) moves into + the entry point and now covers llvm too (today only gcc has it). + +### 3.5 Hermeticity: from promise to assertion (mcpp) + +- **Build-time check** (Linux glibc/musl toolchains): once per toolchain + fingerprint (cached next to the BMI cache, not per link), run the driver + with `-### … -o /dev/null` on a trivial input, extract CRT + loader + paths, and assert every one lies under an allowed prefix (the toolchain + payload root, the glibc payload root, the sandbox registry). Violation ⇒ + hard error naming the leaked host path, with escape hatch + `[build] allow_host_libs = true` (and `MCPP_ALLOW_HOST_LIBS=1`) for users + who genuinely want host linking. +- This converts D4's silent contamination into a first-class diagnostic and + is the regression fence for the whole category, not just #195. + +### 3.6 Tests & CI (mcpp) + +1. **Unit**: `linkmodel` resolution against fabricated payload trees + (payload-first / sysroot / none; x86_64 + aarch64 loader; glob fallback). +2. **e2e `86_llvm_hermetic_link.sh`**: build an `import std` project with + the llvm toolchain, re-run the link command with `-###`, assert + `Scrt1.o/crti.o/crtn.o` + `--dynamic-linker` all resolve inside the + sandbox (`$MCPP_HOME`/registry) — catches both "link fails" and "links + against the host" regressions on any machine. +3. **Parametrize llvm e2e**: 36–41/47 read `MCPP_E2E_LLVM_VERSION` + (default: newest installed payload) instead of the pinned `20.1.7`. +4. **Hermetic CI job**: a container job on a minimal image with **no host + toolchain** (e.g. `debian:stable-slim` without gcc/libc6-dev) that + installs the llvm toolchain and runs the llvm e2e subset + test 86. This + is the only environment class that reproduces #195 faithfully; standard + runners cannot. + +### 3.7 Packaging admission gate (xlings-res) + +The llvm slim-repack workflow gains a release gate: in the same minimal +container, install the candidate asset + `glibc` + `linux-headers`, then +compile, **link and run** an `import std` hello and run the `-###` prefix +assertion. Assets that cannot produce a self-contained binary are not +published (would have caught both the missing-runtime-library precedent and +the #195 environment class at the source). + +## 4. Work breakdown — PRs, order, versions + +All mcpp-side work ships in **one PR** (commit-staged for reviewability); +the other repos land small coordinated PRs. Nothing here changes package +asset contents, so no xpkg version bumps are needed — only mcpp releases. + +### 4.1 PR-1 — mcpp `feat(toolchain): hermetic link model` → **v0.0.83** + +Commit plan inside the single PR: + +1. `feat(toolchain): linkmodel module + loader resolver` (new code + unit + tests; no behavior change yet). +2. `fix(build): link CRT discovery via linkmodel (-B payload glibc)` — + flags/stdmod/build_program/post_install converge on the model; deletes + the four copies. **This commit alone closes #195.** +3. `refactor(toolchain): single post-install fixup pipeline` (marker, + one call site, ownership guard for llvm). +4. `refactor(probe): drop clang-cfg sysroot mining` (diagnostic only). +5. `feat(build): hermeticity assertion + allow_host_libs escape hatch`. +6. `refactor: loader hardcodes → linkmodel (pack/lifecycle/post_install)`. +7. `test(e2e): 86_llvm_hermetic_link + llvm version parametrization`. +8. `ci: hermetic no-host-toolchain container job`. +9. `chore: 0.0.83 + changelog`. + +Merge gate: full e2e matrix (linux/macos/windows self-host) green **plus** +the new hermetic container job green. + +### 4.2 PR-2 — xim-pkgindex `fix(llvm): deterministic hermetic cfg` + +- `pkgs/l/llvm.lua`: rewrite `__install_linux_cfg` per §3.3 (payload-based, + reads `glibc.lua exports.runtime`, fail-fast, versioned cfg name). +- `pkgs/g/glibc.lua`: `exports.runtime` stays the single source of truth; + add per-arch loader entries when aarch64 assets land (tracked, not + blocking). +- Independent of PR-1 (mcpp bypasses the cfg either way); land after PR-1 + so the hermetic CI job exists to validate it end-to-end via a fresh + install in mcpp's smoke path. + +### 4.3 PR-3 — xlings-res `ci(llvm): hermetic admission smoke` (§3.7) + +Workflow-only change in the llvm repack repo; no asset changes. + +### 4.4 PR-4 (optional, non-blocking) — xlings `feat(xim): persist xpkg exports` + +Write declared `exports` (e.g. `runtime.loader`/`abi`) to +`/.xpkg-exports.json` at install time so consumers read +declared metadata instead of conventions. mcpp's resolver treats this as +priority 1 (§3.2) but works without it (triple map + glob), so this PR can +trail without blocking the release train. Ships in the next regular xlings +patch release if taken. + +### 4.5 Release train (0.0.83) + +Follows the automated ecosystem-publish pipeline established for 0.0.82: + +1. Merge PR-1 → tag `v0.0.83` → release workflow builds + publishes + assets on GitHub. +2. Pipeline mirrors the release into `xlings-res/mcpp` (**both** GitHub and + GitCode remotes — CN mirror must not lag, or CN-side installs fetch a + stale binary). +3. Pipeline opens the **xim-pkgindex bump PR** (mcpp 0.0.82 → 0.0.83); + PR-2 (llvm.lua) merges alongside or immediately after it (same repo, + separate PRs — keep the bump PR mechanical). +4. Post-merge verification on a clean environment: `xlings install mcpp` + resolves 0.0.83; then the #195 reproduction — + `mcpp new hello` + `[toolchain] linux = "llvm@22.1.8"` + `mcpp run` — + succeeds in the no-host-toolchain container. +5. Bump the workspace bootstrap pin to 0.0.83 + (`ci: workspace mcpp bootstrap pin -> 0.0.83`) only after step 4 passes. + +## 5. Compatibility & migration + +- **Existing installs**: first build with 0.0.83 hits the fixup pipeline's + marker check (no marker → fixup runs), regenerating the cfg from the + model and patching anything the old scattered paths missed. No user + action; no reinstall. +- **BMI caches**: the flag strings change (new `-B`, canonical model + rendering) ⇒ `std-module.json` metadata mismatch ⇒ std module rebuilds + once per fingerprint. Expected, self-healing. +- **Hermeticity check rollout risk**: environments that intentionally link + host libs (system OpenGL passthrough etc. use dep `[runtime]` + library_dirs, which stay allowed — the assertion covers only CRT/loader + resolution). If an unforeseen legitimate layout trips it, the escape + hatch keeps users unblocked while the allowlist is extended. +- **Windows/macOS**: untouched code paths (`HostSDK`/PE deploy model); + e2e must show zero diffs there. + +## 6. Acceptance criteria + +1. #195 reproduction passes in the hermetic container (fresh env, no host + toolchain, llvm 22.1.8 via manifest `[toolchain]`, `mcpp run` prints). +2. `-###` assertion: CRT + loader resolve inside the sandbox on **every** + Linux llvm e2e, on hosts both with and without a system toolchain. +3. `grep -rn "ld-linux-x86-64" src/` returns only `linkmodel.cppm`'s triple + map (mcpp) / only `glibc.lua exports` (pkgindex). +4. One call site for post-install fixups; all three install paths produce + byte-identical cfg for the same payload. +5. Full existing e2e baseline unchanged on macOS/Windows/musl. +6. Release train completed through the bootstrap-pin bump with the CN + mirror verified. + +## 7. Explicitly out of scope (tracked separately) + +- aarch64 glibc-world enablement beyond the loader resolver (payload assets + for aarch64 glibc/llvm don't exist yet; the resolver removes the code + blocker). +- Identity-first package resolution (`probe_payload_paths` picking the + first glibc version dir without identity checks) — same family as the + existing identity-first refactor plan; the linkmodel takes the payload it + is handed and does not widen the guessing. +- gcc-specs / musl worlds beyond keeping their current behavior green. From b0e275b21b1525a8a561811f8f9c80ddf66bb306 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 18:29:59 +0800 Subject: [PATCH 12/15] fix(fixup): specs-grammar-safe loader detection + macOS keeps cfg-trust semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions the first CI round caught (exactly the environments local verification can't fake): 1. gcc specs corruption (linux): the rewritten detect walked the loader path to 'whitespace/:;' — but specs embed the baked loader inside %-spec conditionals (%{mmusl:...;:/baked/ld-linux-x86-64.so.2}), so the scan swallowed closing braces and the rewrite corrupted the spec grammar ('braced spec body ... is invalid' from every g++ run after). Now: path-character whitelist scan, skip pristine /lib* multilib defaults, unit-tested against the real spec grammar (incl. aarch64 loader names). detect_baked_loader is exported for the tests. Fixup rev bumped (hermetic-2) so payloads stamped by the broken pass re-run the fixup. 2. macOS host/cfg semantics: the cfg-bypass host flags and the -nostdinc++/-stdlib=libc++ cfg regeneration are LINUX semantics; a bare macOS link has no libc++abi handling (that lives in the main build's needs_explicit_libcxx path) and died with undefined __cxa_* / __gxx_personality_v0. build.mcpp host compiles keep trusting the cfg off-Linux, and the macOS cfg keeps its historical shape (--sysroot= + payload libc++ headers only). --- src/build/build_program.cppm | 16 ++++-- src/toolchain/post_install.cppm | 87 +++++++++++++++++++------------- tests/unit/test_post_install.cpp | 60 ++++++++++++++++++++++ 3 files changed, 123 insertions(+), 40 deletions(-) create mode 100644 tests/unit/test_post_install.cpp diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 3fa9153..79353a2 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -14,6 +14,7 @@ export module mcpp.build.build_program; import std; import mcpp.manifest; +import mcpp.platform; import mcpp.platform.process; import mcpp.toolchain.fingerprint; // hash_file / hash_string (FNV-1a, 16 hex) import mcpp.toolchain.linkmodel; // shared C-library / clang-cfg-bypass model @@ -119,12 +120,17 @@ std::vector host_base_flags(const mcpp::toolchain::Toolchain& tc) { std::vector f; const auto lm = mcpp::toolchain::resolve_link_model(tc); - // Clang with a bundled cfg: bypass it (--no-default-config) and provide - // everything explicitly, same as the main build — the cfg is an - // install-time-generated artifact whose content varies per machine and - // install path, so trusting it here while bypassing it in the main build - // meant two different toolchains for the same project. + // Clang with a bundled cfg on LINUX: bypass it (--no-default-config) and + // provide everything explicitly, same as the main build — the cfg is an + // install-time-generated artifact, so trusting it here while bypassing + // it in the main build meant two different toolchains for one project. + // On macOS/Windows keep trusting the cfg: the macOS link additionally + // needs the platform's libc++abi/unwind handling that the main build's + // needs_explicit_libcxx path owns (duplicating it for a host compile + // produced undefined __cxa_*/__gxx_personality_v0), and the fixup + // pipeline regenerates the cfg deterministically anyway. if (mcpp::toolchain::is_clang(tc)) { + if constexpr (!mcpp::platform::is_linux) return f; const auto dm = mcpp::toolchain::resolve_clang_driver(tc); if (dm.hasCfg) { f.push_back("--no-default-config"); diff --git a/src/toolchain/post_install.cppm b/src/toolchain/post_install.cppm index d192a13..015b997 100644 --- a/src/toolchain/post_install.cppm +++ b/src/toolchain/post_install.cppm @@ -92,28 +92,37 @@ export void patchelf_walk(const std::filesystem::path& dir, // gcc specs file. xim bakes the installing user's XLINGS_HOME into specs at // install time, so the DIR varies per machine, and the loader NAME varies // per arch — detect both instead of hardcoding either. -std::string detect_baked_loader(const std::string& specsContent) { +export std::string detect_baked_loader(const std::string& specsContent) { + // Path-character whitelist. Specs embed loader paths inside %-spec + // syntax (`%{mmusl:...;:/baked/dir/ld-linux-x86-64.so.2}`), so scanning + // to "whitespace or :;" is NOT a valid boundary — it would swallow the + // closing braces, and replacing that string corrupts the spec grammar + // ("braced spec body ... is invalid" from every subsequent g++ run). + auto is_path_char = [](char c) { + return std::isalnum(static_cast(c)) + || c == '/' || c == '.' || c == '-' || c == '_' || c == '+'; + }; + + // The baked GNU loader is the ld-linux entry whose directory is NOT a + // standard /lib* location — specs also contain pristine defaults + // (/lib/ld-linux.so.2, /libx32/…) for other multilib branches that must + // never be rewritten. constexpr std::string_view kLoaderMark = "/ld-linux-"; - auto pos = specsContent.find(kLoaderMark); - if (pos == std::string::npos) return ""; - // Walk backwards to find start of the absolute path… - auto start = pos; - while (start > 0 && specsContent[start - 1] != ' ' - && specsContent[start - 1] != ':' - && specsContent[start - 1] != ';' - && specsContent[start - 1] != '\n') { - --start; + for (std::size_t pos = specsContent.find(kLoaderMark); + pos != std::string::npos; + pos = specsContent.find(kLoaderMark, pos + 1)) { + auto start = pos; + while (start > 0 && is_path_char(specsContent[start - 1])) --start; + auto end = pos + 1; + while (end < specsContent.size() && is_path_char(specsContent[end])) ++end; + auto loader = specsContent.substr(start, end - start); + if (loader.empty() || loader[0] != '/') continue; + auto dir = std::filesystem::path(loader).parent_path().string(); + if (dir == "/lib" || dir == "/lib64" || dir == "/lib32" || dir == "/libx32") + continue; // pristine multilib default, not a baked path + return loader; } - // …and forwards to its end. - auto end = pos + kLoaderMark.size(); - while (end < specsContent.size() - && !std::isspace(static_cast(specsContent[end])) - && specsContent[end] != ':' && specsContent[end] != ';') { - ++end; - } - auto loader = specsContent.substr(start, end - start); - if (loader.empty() || loader[0] != '/') return ""; - return loader; + return ""; } void fixup_gcc_specs(const std::filesystem::path& gccPkgRoot, @@ -199,9 +208,18 @@ export void fixup_clang_cfg(const std::filesystem::path& payloadRoot, } std::string common, cxxOnly; + auto cxxInclude = payloadRoot / "include" / "c++" / "v1"; if constexpr (mcpp::platform::is_macos) { + // macOS keeps its historical cfg semantics: the C library and the + // C++ runtime LINK both come from the SDK; only the libc++ HEADERS + // come from the payload. Do NOT add -nostdinc++/-stdlib=libc++ + // here — a bare cfg-driven link has no libc++abi handling (that + // lives in the main build's needs_explicit_libcxx path) and dies + // with undefined __cxa_* / __gxx_personality_v0. if (auto sdk = mcpp::platform::macos::sdk_path()) common += "--sysroot=" + sdk->string() + "\n"; + if (std::filesystem::exists(cxxInclude)) + cxxOnly += "-isystem " + cxxInclude.string() + "\n"; } else { if (!glibcLibDir.empty()) { auto loader = resolve_loader(glibcLibDir, triple); @@ -212,21 +230,20 @@ export void fixup_clang_cfg(const std::filesystem::path& payloadRoot, common += "-Wl,--enable-new-dtags,-rpath," + glibcLibDir.string() + "\n"; } common += "-fuse-ld=lld\n--rtlib=compiler-rt\n--unwindlib=libunwind\n"; - } - auto cxxInclude = payloadRoot / "include" / "c++" / "v1"; - if (std::filesystem::exists(cxxInclude)) { - cxxOnly += "-nostdinc++\n-stdlib=libc++\n"; - cxxOnly += "-isystem " + cxxInclude.string() + "\n"; - } - if (!triple.empty()) { - auto tripleInclude = payloadRoot / "include" / triple / "c++" / "v1"; - if (std::filesystem::exists(tripleInclude)) - cxxOnly += "-isystem " + tripleInclude.string() + "\n"; - auto tripleLib = payloadRoot / "lib" / triple; - if (std::filesystem::exists(tripleLib)) { - cxxOnly += "-L" + tripleLib.string() + "\n"; - cxxOnly += "-Wl,-rpath," + tripleLib.string() + "\n"; + if (std::filesystem::exists(cxxInclude)) { + cxxOnly += "-nostdinc++\n-stdlib=libc++\n"; + cxxOnly += "-isystem " + cxxInclude.string() + "\n"; + } + if (!triple.empty()) { + auto tripleInclude = payloadRoot / "include" / triple / "c++" / "v1"; + if (std::filesystem::exists(tripleInclude)) + cxxOnly += "-isystem " + tripleInclude.string() + "\n"; + auto tripleLib = payloadRoot / "lib" / triple; + if (std::filesystem::exists(tripleLib)) { + cxxOnly += "-L" + tripleLib.string() + "\n"; + cxxOnly += "-Wl,-rpath," + tripleLib.string() + "\n"; + } } } @@ -334,7 +351,7 @@ void llvm_post_install_fixup(const mcpp::config::GlobalConfig& cfg, // runtime libs. Idempotent via a content-fingerprinted marker. // // Bump when the fixup logic changes so existing installs re-run it. -constexpr std::string_view kFixupRev = "hermetic-1"; +constexpr std::string_view kFixupRev = "hermetic-2"; export void ensure_post_install_fixup(const mcpp::config::GlobalConfig& cfg, const std::filesystem::path& payloadRoot, diff --git a/tests/unit/test_post_install.cpp b/tests/unit/test_post_install.cpp new file mode 100644 index 0000000..34b5e8f --- /dev/null +++ b/tests/unit/test_post_install.cpp @@ -0,0 +1,60 @@ +#include + +import std; +import mcpp.toolchain.post_install; + +// detect_baked_loader parses gcc SPECS GRAMMAR, not plain text. The baked +// loader path is embedded inside %-spec conditionals, e.g. +// %{mmusl:/lib/ld-musl-x86_64.so.1;:/baked/dir/ld-linux-x86-64.so.2} +// A scanner that treats "whitespace or :;" as the boundary swallows the +// closing braces; replacing that string then corrupts the spec grammar and +// EVERY subsequent g++ invocation dies with "braced spec body ... is +// invalid" (observed on CI). These tests pin the exact grammar shape. + +namespace { + +using mcpp::toolchain::detect_baked_loader; + +// Realistic *link_spec fragment as xim bakes it (64-bit branch rewritten to +// the installing user's home; other multilib branches pristine). +const std::string kBakedSpecs = + "*link:\n" + "%{m16|m32|mx32:;:-m elf_x86_64} %{shared:-shared} %{!shared: %{!static: " + "%{m16|m32:-dynamic-linker %{muclibc:/lib/ld-uClibc.so.0;:%{mbionic:/system/bin/linker;:" + "%{mmusl:/lib/ld-musl-i386.so.1;:/lib/ld-linux.so.2}}}} " + "%{m16|m32|mx32:;:-dynamic-linker %{muclibc:/lib/ld64-uClibc.so.0;:%{mbionic:/system/bin/linker64;:" + "%{mmusl:/lib/ld-musl-x86_64.so.1;:/opt/other-home/.xlings/data/xpkgs/xim-x-glibc/2.39/lib64/ld-linux-x86-64.so.2}}}}} " + "%{static:-static}}\n"; + +TEST(DetectBakedLoader, ExtractsExactPathWithoutSpecBraces) { + auto got = detect_baked_loader(kBakedSpecs); + EXPECT_EQ(got, + "/opt/other-home/.xlings/data/xpkgs/xim-x-glibc/2.39/lib64/ld-linux-x86-64.so.2"); + // The regression: any brace in the result corrupts the specs on rewrite. + EXPECT_EQ(got.find('}'), std::string::npos); + EXPECT_EQ(got.find('{'), std::string::npos); +} + +TEST(DetectBakedLoader, IgnoresPristineMultilibDefaults) { + // An unbaked spec (all-standard /lib*/ paths) must not be rewritten. + const std::string pristine = + "%{mmusl:/lib/ld-musl-x86_64.so.1;:/lib64/ld-linux-x86-64.so.2} " + "%{m16|m32:-dynamic-linker /lib/ld-linux.so.2} " + "%{mx32:-dynamic-linker /libx32/ld-linux-x32.so.2}"; + EXPECT_EQ(detect_baked_loader(pristine), ""); +} + +TEST(DetectBakedLoader, Aarch64LoaderNameDetected) { + const std::string specs = + "-dynamic-linker %{mmusl:/lib/ld-musl-aarch64.so.1;:" + "/srv/build/.xlings/xpkgs/xim-x-glibc/2.39/lib/ld-linux-aarch64.so.1}"; + EXPECT_EQ(detect_baked_loader(specs), + "/srv/build/.xlings/xpkgs/xim-x-glibc/2.39/lib/ld-linux-aarch64.so.1"); +} + +TEST(DetectBakedLoader, EmptyWhenNoGnuLoaderPresent) { + EXPECT_EQ(detect_baked_loader("no loaders here"), ""); + EXPECT_EQ(detect_baked_loader("%{mmusl:/lib/ld-musl-x86_64.so.1}"), ""); +} + +} // namespace From 24d138ed3193ed8406c9af81268c4755ff782664 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 19:01:42 +0800 Subject: [PATCH 13/15] =?UTF-8?q?ci:=20TEMP-DEBUG=20=E2=80=94=20capture=20?= =?UTF-8?q?core=20backtrace=20for=20the=20exit-time=20segfault=20(#196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-linux.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index 9ea2d4a..e51b5ff 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -114,7 +114,27 @@ jobs: # Use freshly-built mcpp for test (it has --mirror support) MCPP_FRESH=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)") "$MCPP_FRESH" self config --mirror GLOBAL + # TEMP-DEBUG(#196): exit-time segfault reproduces only on CI — + # capture a core + backtrace, then remove this block. + ulimit -c unlimited + sudo sysctl -w kernel.core_pattern="$PWD/core.%p" || true + set +e "$MCPP_FRESH" test + rc=$? + set -e + if [[ $rc -ne 0 ]]; then + sudo apt-get install -y -qq gdb >/dev/null 2>&1 || true + core=$(ls -t core.* 2>/dev/null | head -1) + if [[ -n "$core" ]]; then + gdb -batch -ex 'bt full' -ex 'info sharedlibrary' -ex 'thread apply all bt' \ + "$MCPP_FRESH" "$core" 2>&1 | head -150 + else + echo "no core file found" + fi + ldd "$MCPP_FRESH" || true + readelf -d "$MCPP_FRESH" | head -20 || true + exit $rc + fi # NOTE: the e2e suite (tests/e2e/run_all.sh) moved to ci-linux-e2e.yml # so it runs in parallel with this job. The toolchain matrix below no From 26e0d44bbb8528fc877347b88553ae5ed4d58a0d Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 19:33:26 +0800 Subject: [PATCH 14/15] =?UTF-8?q?fix(fixup):=20patchelf=20a=20copy=20+=20a?= =?UTF-8?q?tomic=20rename=20=E2=80=94=20never=20rewrite=20live=20mappings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixup pipeline now runs on every toolchain install path, which means the process executing it can itself be linked against the very libraries being patched (a self-hosted mcpp loads the sandbox glibc/libgcc_s). In-place patchelf rewrites the backing file of those live mappings and corrupts the running process — reproduced deterministically on fresh- sandbox CI as an exit-time SIGSEGV in _dl_fini jumping to an unrelocated address (0x45a0), while a control run of main on the same fresh sandbox passed (the old code only ever patched from the statically-linked bootstrap mcpp, so the hazard was structural but unexposed). patchelf now operates on a copy and atomically rename()s it into place: the patched content gets a fresh inode, live processes keep the old one. --- src/toolchain/post_install.cppm | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/toolchain/post_install.cppm b/src/toolchain/post_install.cppm index 015b997..22986a0 100644 --- a/src/toolchain/post_install.cppm +++ b/src/toolchain/post_install.cppm @@ -60,21 +60,46 @@ export void patchelf_walk(const std::filesystem::path& dir, mcpp::platform::shell::quote(path.string())); auto probeResult = mcpp::platform::process::capture(probe); bool hasInterp = (probeResult.exit_code == 0 && !probeResult.output.empty()); + + // Patch a COPY and atomically rename it into place. The payload can + // contain libraries the CURRENT process has mmapped (a self-hosted + // mcpp links the sandbox glibc/libgcc_s, and since the fixup + // pipeline runs on every install path, the patching process may BE + // such a consumer). In-place patchelf rewrites the backing file of + // those live mappings and corrupts the running process — observed + // on CI as an exit-time SIGSEGV in _dl_fini jumping to an + // unrelocated address. rename() gives the patched content a fresh + // inode while live processes keep the old one. + auto tmp = path; + tmp += ".mcpp-patch.tmp"; + { + std::error_code cec; + std::filesystem::copy_file( + path, tmp, std::filesystem::copy_options::overwrite_existing, cec); + if (cec) continue; + std::filesystem::permissions( + tmp, std::filesystem::status(path, cec).permissions(), + std::filesystem::perm_options::replace, cec); + } + bool patched = true; if (hasInterp) { - (void)mcpp::platform::process::run_silent(std::format( + patched = (mcpp::platform::process::run_silent(std::format( "{} --set-interpreter {} {} 2>/dev/null", mcpp::platform::shell::quote(patchelfBin.string()), mcpp::platform::shell::quote(loader.string()), - mcpp::platform::shell::quote(path.string()))); + mcpp::platform::shell::quote(tmp.string()))) == 0) && patched; } // Always set RUNPATH (works on .so too — they need to find deps). if (!rpath.empty()) { - (void)mcpp::platform::process::run_silent(std::format( + patched = (mcpp::platform::process::run_silent(std::format( "{} --set-rpath {} {} 2>/dev/null", mcpp::platform::shell::quote(patchelfBin.string()), mcpp::platform::shell::quote(rpath), - mcpp::platform::shell::quote(path.string()))); + mcpp::platform::shell::quote(tmp.string()))) == 0) && patched; } + std::error_code rec; + if (patched) std::filesystem::rename(tmp, path, rec); + if (!patched || rec) std::filesystem::remove(tmp, rec); } } From 7bc5ff9255f607a28d7be4ae0eeeaec61bb0b9ae Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Tue, 7 Jul 2026 19:50:54 +0800 Subject: [PATCH 15/15] =?UTF-8?q?Revert=20"ci:=20TEMP-DEBUG=20=E2=80=94=20?= =?UTF-8?q?capture=20core=20backtrace=20for=20the=20exit-time=20segfault?= =?UTF-8?q?=20(#196)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 24d138ed3193ed8406c9af81268c4755ff782664. --- .github/workflows/ci-linux.yml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index e51b5ff..9ea2d4a 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -114,27 +114,7 @@ jobs: # Use freshly-built mcpp for test (it has --mirror support) MCPP_FRESH=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)") "$MCPP_FRESH" self config --mirror GLOBAL - # TEMP-DEBUG(#196): exit-time segfault reproduces only on CI — - # capture a core + backtrace, then remove this block. - ulimit -c unlimited - sudo sysctl -w kernel.core_pattern="$PWD/core.%p" || true - set +e "$MCPP_FRESH" test - rc=$? - set -e - if [[ $rc -ne 0 ]]; then - sudo apt-get install -y -qq gdb >/dev/null 2>&1 || true - core=$(ls -t core.* 2>/dev/null | head -1) - if [[ -n "$core" ]]; then - gdb -batch -ex 'bt full' -ex 'info sharedlibrary' -ex 'thread apply all bt' \ - "$MCPP_FRESH" "$core" 2>&1 | head -150 - else - echo "no core file found" - fi - ldd "$MCPP_FRESH" || true - readelf -d "$MCPP_FRESH" | head -20 || true - exit $rc - fi # NOTE: the e2e suite (tests/e2e/run_all.sh) moved to ci-linux-e2e.yml # so it runs in parallel with this job. The toolchain matrix below no