Skip to content

Commit 6e4c755

Browse files
committed
feat(build): hermetic link check — assert CRT/loader resolve inside the sandbox
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.
1 parent e1c6f30 commit 6e4c755

3 files changed

Lines changed: 220 additions & 0 deletions

File tree

src/build/hermetic.cppm

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// mcpp.build.hermetic — turn the sandbox self-containment promise into an
2+
// executable assertion.
3+
//
4+
// A sandbox toolchain that cannot resolve its CRT startup objects
5+
// (Scrt1.o/crti.o/crtn.o) or dynamic linker inside the sandbox does one of
6+
// two things, both silent until now: on hosts WITH a system toolchain the
7+
// driver falls back to the host's /lib (host contamination — the build goes
8+
// green while linking a C runtime the sandbox never promised), and on hosts
9+
// WITHOUT one it passes bare CRT names that the linker cannot open (issue
10+
// #195). This check dry-runs the driver (`-###`) with the exact link flags
11+
// the build will use and asserts every CRT object + the dynamic linker
12+
// resolve under an allowed sandbox prefix — converting both failure modes
13+
// into one actionable diagnostic at build time.
14+
//
15+
// Scope: Linux, sandbox toolchains only (a compiler outside the xpkgs
16+
// registry — `[toolchain] system`, a PATH compiler — is the user's explicit
17+
// choice of the host world and is skipped). Escape hatches for users who
18+
// genuinely want host linking: `[build] allow_host_libs = true` or
19+
// MCPP_ALLOW_HOST_LIBS=1 (downgrade to a verbose note).
20+
21+
module;
22+
#include <cstdlib> // getenv
23+
24+
export module mcpp.build.hermetic;
25+
26+
import std;
27+
import mcpp.log;
28+
import mcpp.platform;
29+
import mcpp.toolchain.fingerprint;
30+
import mcpp.toolchain.model;
31+
32+
export namespace mcpp::build {
33+
34+
// Verify the link is hermetic. `ldflagsNinja` is the ninja-escaped ldflags
35+
// string from flags.cppm (un-escaped internally). Returns an error message
36+
// naming the leaked/bare paths, or empty success. `outputDir` caches the
37+
// verdict per flag-set so unchanged builds don't re-spawn the driver.
38+
std::expected<void, std::string> verify_hermetic_link(
39+
const mcpp::toolchain::Toolchain& tc,
40+
const std::string& ldflagsNinja,
41+
const std::filesystem::path& outputDir,
42+
bool allowHostLibs);
43+
44+
} // namespace mcpp::build
45+
46+
namespace mcpp::build {
47+
48+
namespace {
49+
50+
// Reverse flags.cppm's escape_path (space/'$'/':' are prefixed with '$').
51+
std::string unescape_ninja(const std::string& s) {
52+
std::string out;
53+
out.reserve(s.size());
54+
for (std::size_t i = 0; i < s.size(); ++i) {
55+
if (s[i] == '$' && i + 1 < s.size()) { out.push_back(s[++i]); continue; }
56+
out.push_back(s[i]);
57+
}
58+
return out;
59+
}
60+
61+
bool is_crt_object(std::string_view base) {
62+
static constexpr std::array<std::string_view, 7> kCrt = {
63+
"Scrt1.o", "crt1.o", "gcrt1.o", "rcrt1.o", "Mcrt1.o", "crti.o", "crtn.o",
64+
};
65+
for (auto c : kCrt)
66+
if (base == c) return true;
67+
return base.starts_with("crtbegin") || base.starts_with("crtend")
68+
|| base.find("clang_rt.crt") != std::string_view::npos;
69+
}
70+
71+
bool under_any(const std::filesystem::path& p,
72+
const std::vector<std::filesystem::path>& prefixes) {
73+
auto s = p.lexically_normal().string();
74+
for (auto& pre : prefixes) {
75+
if (pre.empty()) continue;
76+
if (s.starts_with(pre.lexically_normal().string())) return true;
77+
}
78+
return false;
79+
}
80+
81+
// Split a `-###` output line into tokens; the driver quotes each argument
82+
// with double quotes.
83+
std::vector<std::string> tokenize(std::string_view out) {
84+
std::vector<std::string> toks;
85+
std::string cur;
86+
bool inQuote = false;
87+
for (char c : out) {
88+
if (c == '"') {
89+
if (inQuote) { toks.push_back(cur); cur.clear(); }
90+
inQuote = !inQuote;
91+
continue;
92+
}
93+
if (inQuote) cur.push_back(c);
94+
else if (!std::isspace(static_cast<unsigned char>(c))) cur.push_back(c);
95+
else if (!cur.empty()) { toks.push_back(cur); cur.clear(); }
96+
}
97+
if (!cur.empty()) toks.push_back(cur);
98+
return toks;
99+
}
100+
101+
} // namespace
102+
103+
std::expected<void, std::string> verify_hermetic_link(
104+
const mcpp::toolchain::Toolchain& tc,
105+
const std::string& ldflagsNinja,
106+
const std::filesystem::path& outputDir,
107+
bool allowHostLibs)
108+
{
109+
if constexpr (!mcpp::platform::is_linux) return {};
110+
111+
// Sandbox toolchains only: the compiler must live under an xpkgs
112+
// registry. A host/system compiler is the user's explicit opt-in to the
113+
// host world.
114+
auto binStr = tc.binaryPath.string();
115+
auto xpkgsPos = binStr.find("xpkgs");
116+
if (xpkgsPos == std::string::npos) return {};
117+
std::vector<std::filesystem::path> allowed;
118+
allowed.emplace_back(binStr.substr(0, xpkgsPos + 5)); // the xpkgs root
119+
if (!tc.sysroot.empty()) allowed.push_back(tc.sysroot);
120+
121+
if (const char* e = std::getenv("MCPP_ALLOW_HOST_LIBS"); e && *e && *e != '0')
122+
allowHostLibs = true;
123+
124+
auto ldflags = unescape_ninja(ldflagsNinja);
125+
126+
// Verdict cache: same driver + flags ⇒ same resolution; skip the spawn.
127+
auto marker = outputDir / ".mcpp-hermetic-ok";
128+
auto key = mcpp::toolchain::hash_string(
129+
tc.binaryPath.string() + "\x1f" + ldflags
130+
+ "\x1f" + (allowHostLibs ? "1" : "0"));
131+
{
132+
std::ifstream is(marker);
133+
std::string prev;
134+
if (is && std::getline(is, prev) && prev == key) return {};
135+
}
136+
137+
auto cmd = std::format("{} {} -### -x c++ /dev/null -o /dev/null 2>&1",
138+
mcpp::platform::shell::quote(tc.binaryPath.string()),
139+
ldflags);
140+
auto r = mcpp::platform::process::capture(cmd);
141+
if (r.exit_code != 0) {
142+
// The dry-run itself failing is a link-configuration problem the real
143+
// link will also hit; don't duplicate the diagnosis here.
144+
mcpp::log::verbose("hermetic", std::format(
145+
"-### dry-run failed (rc={}) — skipping hermeticity check", r.exit_code));
146+
return {};
147+
}
148+
149+
std::vector<std::string> leaks;
150+
auto check = [&](std::string_view value, bool bareIsLeak = true) {
151+
std::filesystem::path p{std::string(value)};
152+
if (!p.is_absolute()) {
153+
if (bareIsLeak)
154+
leaks.push_back(std::format(
155+
"{} (bare name — the linker cannot resolve it)", std::string(value)));
156+
} else if (!under_any(p, allowed)) {
157+
leaks.push_back(std::format("{} (outside the sandbox)", std::string(value)));
158+
}
159+
};
160+
auto toks = tokenize(r.output);
161+
// The dynamic linker may appear more than once (the driver emits its
162+
// built-in default, then the -Wl override follows); the LAST occurrence
163+
// on the linker command line wins, so only that one is checked.
164+
std::string effectiveLoader;
165+
for (std::size_t i = 0; i < toks.size(); ++i) {
166+
std::string_view t = toks[i];
167+
if (t == "-dynamic-linker" && i + 1 < toks.size()) {
168+
effectiveLoader = toks[++i];
169+
continue;
170+
}
171+
if (t.starts_with("--dynamic-linker=")) {
172+
effectiveLoader = std::string(t.substr(17));
173+
continue;
174+
}
175+
auto base = std::filesystem::path(std::string(t)).filename().string();
176+
if (is_crt_object(base)) check(t);
177+
}
178+
if (!effectiveLoader.empty()) check(effectiveLoader);
179+
180+
if (!leaks.empty()) {
181+
std::string list;
182+
for (auto& l : leaks) list += "\n " + l;
183+
auto msg = std::format(
184+
"hermetic link check failed — the sandbox toolchain resolves its C "
185+
"runtime outside the sandbox:{}\n"
186+
" allowed prefixes: {}\n"
187+
" This usually means the glibc payload is missing or the "
188+
"toolchain install is incomplete (try `mcpp toolchain install` again).\n"
189+
" To deliberately link against host libraries set "
190+
"[build] allow_host_libs = true (or MCPP_ALLOW_HOST_LIBS=1).",
191+
list, allowed.front().string());
192+
if (!allowHostLibs) return std::unexpected(msg);
193+
mcpp::log::verbose("hermetic", "allow_host_libs set — " + msg);
194+
}
195+
196+
std::error_code ec;
197+
std::filesystem::create_directories(outputDir, ec);
198+
std::ofstream os(marker);
199+
os << key << "\n";
200+
return {};
201+
}
202+
203+
} // namespace mcpp::build

src/build/ninja_backend.cppm

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import std;
2121
import mcpp.build.backend;
2222
import mcpp.build.plan;
2323
import mcpp.build.flags;
24+
import mcpp.build.hermetic;
2425
import mcpp.build.compile_commands;
2526
import mcpp.dyndep;
2627
import mcpp.toolchain.detect;
@@ -722,6 +723,15 @@ std::expected<BuildResult, BuildError> NinjaBackend::build(const BuildPlan& plan
722723
return r;
723724
}
724725

726+
// Hermetic link check: assert the sandbox toolchain resolves its CRT
727+
// objects + dynamic linker inside the sandbox BEFORE running the build —
728+
// catches both the bare-CRT link failure (#195) and silent host-library
729+
// contamination, cached per flag-set.
730+
if (auto h = verify_hermetic_link(plan.toolchain, flags.ld, plan.outputDir,
731+
plan.manifest.buildConfig.allowHostLibs); !h) {
732+
return std::unexpected(BuildError{h.error(), {}});
733+
}
734+
725735
// When the toolchain comes from mcpp's private sandbox, use the
726736
// sandbox-local ninja absolute path (skip the system xlings ninja
727737
// shim which requires per-tool version pin activation).

src/manifest.cppm

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ struct BuildConfig {
135135
std::vector<std::string> cxxflags;
136136
std::vector<std::string> ldflags;
137137
std::string cStandard;
138+
// Escape hatch for the hermetic link check: a sandbox toolchain whose
139+
// CRT/loader resolve OUTSIDE the sandbox is a hard error by default
140+
// (silent host contamination, or issue #195's bare-CRT link failure);
141+
// set true to deliberately link against host libraries.
142+
// MCPP_ALLOW_HOST_LIBS=1 is the per-invocation equivalent.
143+
bool allowHostLibs = false;
138144
// macOS minimum supported OS version for produced binaries
139145
// (LC_BUILD_VERSION minos), e.g. "14.0". Mirrors the ecosystem
140146
// conventions around deployment targets (the MACOSX_DEPLOYMENT_TARGET
@@ -1141,6 +1147,7 @@ std::expected<Manifest, ManifestError> parse_string(std::string_view content,
11411147

11421148
// [build] — backend tunables
11431149
if (auto v = doc->get_bool("build.static_stdlib")) m.buildConfig.staticStdlib = *v;
1150+
if (auto v = doc->get_bool("build.allow_host_libs")) m.buildConfig.allowHostLibs = *v;
11441151
if (auto v = doc->get_string_array("build.cflags")) m.buildConfig.cflags = *v;
11451152
if (auto v = doc->get_string_array("build.cxxflags")) m.buildConfig.cxxflags = *v;
11461153
if (auto v = doc->get_string_array("build.ldflags")) m.buildConfig.ldflags = *v;

0 commit comments

Comments
 (0)