From 08215b56d3761b822b62f0a7e52b6f8be985860c Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 10 Aug 2026 18:54:35 +0800 Subject: [PATCH 1/2] feat(graphics): one owner for the loader tag, and two things that now say so (2026.8.10.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements E1b/E3a/E3b of the graphics stack design, on top of libxpkg 0.0.57 which makes elfpatch stamp DT_RPATH on executables (E1a). RULE E — the tag invariant, evaluated where the payload lands DT_RUNPATH is consulted only for the object carrying it; DT_RPATH is consulted for every dlopen anywhere in the process. A GL program reaches its driver through three levels of dlopen, so the TAG — not the path — decides whether it renders on the GPU. Measured on a real home: 1 of 73 installed executables carried DT_RPATH, 68 carried DT_RUNPATH, and 55 of those 68 already carried the correct path. closure_check is the right place and it was built for exactly this move. Its own header says a predicate that is right but "runs in exactly one place — a repository workflow the user's machine never sees" belongs where the payload actually lands. The tag was in a worse place than that: it lived in each recipe author's head, and the one package whose author found the problem fixed it locally with nothing to carry the finding to the other 68. The tag is parsed natively rather than shelled out, because there is nothing to shell out to: `patchelf --print-rpath` prints the value of whichever tag exists and does not say which, and readelf is not something a payload is guaranteed to have. Reading the whole dynamic section rather than the first hit matters — with both tags present the loader IGNORES DT_RPATH, and DT_RPATH-first is the common layout, so a first-hit reading would report the opposite and look right in casual testing. Warn-only, matching rule A and D. Libraries are deliberately out of scope: forcing the transitive tag on a LIBRARY is measured harmful (xim-pkgindex#593) because transitivity runs downward too. HOST DRIVER DRIFT — `subos info` now says it The NVIDIA userspace driver is the one part of this stack we do not own; it is in lockstep with the host kernel module, so a distribution update moves it under us and the recorded wiring describes a driver that is gone. The detector already existed and worked (`xlings-gl-doctor`) — what it lacked was a way to reach the user without being remembered. Two plain file reads, no subprocess, same instant-query contract as the wiring record. Reported ABOVE the per-vendor rows, because when it fires every row under it is about the old driver. An unknown on either side is not drift: a machine whose module is not loaded has not changed driver. SANDBOX WITHOUT --gpu — say it once, only where it can be acted on bwrap's fresh /dev excludes the GPU nodes, so `--sandbox` without `--gpu` is a software-rendering environment by construction. That default is correct and `--gpu` genuinely restores it; the gap is silence — the user gets "runs, draws, exits 0", indistinguishable from the GPU case except in frame rate. Three conditions, and the narrowness is the point: `warn_storage_dormant_on_ shell_` a few lines away is a hint that fired on every entry of its kind, became noise, and is commented out today. This one requires that the subos has a GL dispatch, that the host actually has GPU device nodes, and that --gpu was not passed. The GPU predicate is derived from `passthrough_args` rather than a second list of device paths, so the two cannot drift. Verified on real hardware, each in both directions: sandbox hint fires without --gpu; silent with --gpu; silent on a home with no graphics stack drift row absent when versions agree, present when they disagree, absent again after restoring tag parser falsified — reading the first hit instead of the whole dynamic section makes the both-tags test fail 37/37 test binaries pass. Design: .agents/docs/2026-08-10-graphics-stack-design.md §E1b, §E3a, §E3b. --- mcpp.lock | 6 +- mcpp.toml | 4 +- src/core/closure_check.cppm | 127 ++++++++++++++++++++++++++++ src/core/config.cppm | 2 +- src/core/subos.cppm | 58 +++++++++++++ src/core/subos/gpu.cppm | 23 +++++ src/core/subos/graphics.cppm | 70 ++++++++++++++++ src/core/xim/installer.cppm | 5 ++ tests/unit/test_closure_check.cpp | 130 +++++++++++++++++++++++++++++ tests/unit/test_subos_graphics.cpp | 64 ++++++++++++++ 10 files changed, 483 insertions(+), 6 deletions(-) diff --git a/mcpp.lock b/mcpp.lock index e7a48352..760f9682 100644 --- a/mcpp.lock +++ b/mcpp.lock @@ -73,9 +73,9 @@ hash = "fnv1a:3465dd0bd5d7aa20" [package."mcpplibs.xpkg"] namespace = "mcpplibs" -version = "0.0.56" -source = "index+mcpplibs@0.0.56" -hash = "fnv1a:1f00ff8945ea9597" +version = "0.0.57" +source = "index+mcpplibs@0.0.57" +hash = "fnv1a:91399fb9d7d59ebc" [package."mcpplibs.capi.lua"] namespace = "mcpplibs.capi" diff --git a/mcpp.toml b/mcpp.toml index d6128012..b36b5a0a 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "xlings" -version = "2026.8.10.3" +version = "2026.8.10.4" description = "Universal package management infrastructure tool with SubOS isolation" license = "Apache-2.0" repo = "https://github.com/openxlings/xlings" @@ -39,7 +39,7 @@ libarchive = "3.8.7" [dependencies.mcpplibs] cmdline = "0.0.2" -xpkg = "0.0.56" +xpkg = "0.0.57" tinyhttps = "0.2.9" capi.lua = "0.0.3" diff --git a/src/core/closure_check.cppm b/src/core/closure_check.cppm index 5248b123..31cb853c 100644 --- a/src/core/closure_check.cppm +++ b/src/core/closure_check.cppm @@ -31,6 +31,12 @@ import xlings.core.elf_same_source; // than the host's glibc recorded at subos creation. Host objects // can always enter a form-X process (dlopen, host-link), and an // older libc cannot serve their symbol versions. +// rule E a form-X EXECUTABLE's search path must be in DT_RPATH, not +// DT_RUNPATH. DT_RUNPATH is consulted only for the object carrying +// it, so an executable that dlopens two or three levels deep -- any +// GL program does -- cannot reach the bottom however correct the +// path is. Libraries are deliberately out of scope: the transitive +// tag on a LIBRARY is measured harmful (xim-pkgindex#593). // // Rule B (loader and libc from one payload) is elfcheck's, already enforced // hard earlier in the same install loop. This module deliberately does not @@ -64,11 +70,98 @@ struct VersionFloor { std::string interpPayload; }; +// A form-X executable whose search path is in the non-transitive tag. +// +// rule E. DT_RUNPATH is consulted only for the object that carries it; +// DT_RPATH is consulted for every dlopen anywhere in the process. An +// executable that dlopens something three levels deep -- which is what any GL +// program does, since glvnd dlopens a vendor, the vendor dlopens its platform +// modules, and those dlopen their own dependencies -- cannot reach the bottom +// through DT_RUNPATH however correct the path itself is. +// +// This rule exists for the same reason as rule D, one layer up. Rule D's +// comment says it: a predicate that is right but "runs in exactly one place -- +// a repository workflow the user's machine never sees". The TAG was in a worse +// place than that -- it lived in each recipe author's head. Measured on a real +// home: 1 of 73 installed executables carried DT_RPATH, 68 carried DT_RUNPATH, +// and 55 of those 68 already carried the correct PATH. The one that was right +// was right because that package's author found the problem independently and +// fixed it locally; nothing carried the finding to the other 68. +struct NonTransitiveTag { + std::string elf; + std::string rpath; // the value, which is usually correct +}; + +// Which of the two search-path tags an ELF carries. +// +// Parsed here rather than shelled out because there is no tool to shell out +// TO: `patchelf --print-rpath` prints the value of whichever tag exists and +// does not say which, and `readelf` is not something a payload is guaranteed +// to have. It is also the right shape for a check that runs over every ELF of +// every install -- no process per file. +enum class PathTag { None, Rpath, Runpath }; + +inline PathTag path_tag_of(const fs::path& file) { + std::ifstream f(file, std::ios::binary); + if (!f) return PathTag::None; + std::string buf((std::istreambuf_iterator(f)), {}); + if (buf.size() < 64) return PathTag::None; + const auto* p = reinterpret_cast(buf.data()); + if (!(p[0] == 0x7f && p[1] == 'E' && p[2] == 'L' && p[3] == 'F')) + return PathTag::None; + + const bool is64 = p[4] == 2; + // Little-endian only. Every target this check runs on is LE, and a wrong + // guess here would misreport rather than fail loudly, so it declines + // instead. + if (p[5] != 1) return PathTag::None; + + auto rd = [&](std::size_t off, int width) -> std::uint64_t { + if (off + static_cast(width) > buf.size()) return 0; + std::uint64_t v = 0; + for (int i = width - 1; i >= 0; --i) v = (v << 8) | p[off + static_cast(i)]; + return v; + }; + + const std::uint64_t phoff = is64 ? rd(32, 8) : rd(28, 4); + const std::uint64_t phentsize = is64 ? rd(54, 2) : rd(42, 2); + const std::uint64_t phnum = is64 ? rd(56, 2) : rd(44, 2); + if (phoff == 0 || phentsize == 0) return PathTag::None; + + constexpr std::uint64_t PT_DYNAMIC = 2; + constexpr std::uint64_t DT_NULL = 0, DT_RPATH = 15, DT_RUNPATH = 29; + + for (std::uint64_t i = 0; i < phnum; ++i) { + const std::uint64_t ph = phoff + i * phentsize; + if (rd(ph, 4) != PT_DYNAMIC) continue; + const std::uint64_t dynoff = is64 ? rd(ph + 8, 8) : rd(ph + 4, 4); + const int w = is64 ? 8 : 4; + // The WHOLE dynamic section, not the first hit. When both tags are + // present the loader IGNORES DT_RPATH and uses DT_RUNPATH, so an ELF + // carrying both behaves as Runpath -- and returning whichever + // happened to come first in the table would report the opposite on + // half of them. + bool sawRpath = false; + for (std::uint64_t d = dynoff; d + 2 * static_cast(w) <= buf.size(); + d += 2 * static_cast(w)) { + const std::uint64_t tag = rd(d, w); + if (tag == DT_NULL) break; + if (tag == DT_RUNPATH) return PathTag::Runpath; + if (tag == DT_RPATH) sawRpath = true; + } + return sawRpath ? PathTag::Rpath : PathTag::None; + } + return PathTag::None; +} + struct Report { int scannedElves = 0; int formXElves = 0; std::vector missing; // one entry per (first elf, soname) std::optional floor; // one offender is enough to say it + // rule E. One offender names the payload: every executable in a payload is + // stamped by the same pass, so listing all of them would repeat one fact. + std::optional nonTransitive; }; // ── pure core ─────────────────────────────────────────────────────────── @@ -210,6 +303,21 @@ inline Report scan_payload(const fs::path& payloadDir, } } + // rule E -- the same set rule A uses: this ELF has PT_INTERP (it is + // an executable, not a library) and that interpreter is a payload + // (form X). Libraries are deliberately out of scope: forcing the + // transitive tag on a LIBRARY is measured harmful, because + // transitivity runs downward too and the library's path then enters + // every lookup beneath it (xim-pkgindex#593). + if (!rep.nonTransitive + && path_tag_of(path) == PathTag::Runpath) { + const auto rp = run_lines(std::format("--print-rpath \"{}\"", path)); + rep.nonTransitive = NonTransitiveTag{ + .elf = path, + .rpath = rp.empty() ? std::string{} : rp.back(), + }; + } + // rule D if (!store) store = store_sonames(xpkgsRoot, payloadDir); const auto needed = run_lines(std::format("--print-needed \"{}\"", path)); @@ -237,6 +345,25 @@ inline std::string describe_missing(const MissingSoname& m) { fs::path(m.elf).filename().string(), m.soname); } +// Names the file, the tag, and the consequence -- because the consequence is +// the part nobody would guess. The path is usually right, so a message that +// only said "wrong tag" would read as pedantry rather than as the reason GL +// renders in software. +inline std::string describe_non_transitive(const NonTransitiveTag& t) { + return std::format( + "non-transitive search path: {} carries DT_RUNPATH, not DT_RPATH.\n" + " Its path ({}) is consulted for this binary's own libraries and for " + "NOTHING it dlopens. A GL program reaches its driver through three " + "levels of dlopen -- glvnd opens a vendor, the vendor opens its " + "platform modules, those open their own dependencies -- so with this " + "tag it silently renders in software however correct the path is. " + "elfpatch stamps DT_RPATH on executables since libxpkg 0.0.57; a " + "payload installed before that keeps the old tag until it is " + "reinstalled. (rule E, warn-only)", + fs::path(t.elf).filename().string(), + t.rpath.empty() ? "empty" : t.rpath); +} + inline std::string describe_floor(const VersionFloor& v) { return std::format( "version floor: {} runs on glibc {} while this subos recorded the " diff --git a/src/core/config.cppm b/src/core/config.cppm index 19b02ef2..9f998b2b 100644 --- a/src/core/config.cppm +++ b/src/core/config.cppm @@ -13,7 +13,7 @@ import xlings.core.xvm.db; namespace xlings { export struct Info { - static constexpr std::string_view VERSION = "2026.8.10.3"; + static constexpr std::string_view VERSION = "2026.8.10.4"; static constexpr std::string_view REPO = "https://github.com/openxlings/xlings"; }; diff --git a/src/core/subos.cppm b/src/core/subos.cppm index a0c748f8..7b95579b 100644 --- a/src/core/subos.cppm +++ b/src/core/subos.cppm @@ -1272,6 +1272,45 @@ int use_global(const std::string& name, EventStream& stream) { // was created with image/tmpfs storage so the user understands the // attribute is dormant in this entry. Writes to stderr (not stdout) // so the --shell path stays eval-safe. +// `--sandbox` without `--gpu` on a machine that has one, for a subos that does +// graphics: say so, once, before entering. +// +// bwrap's `--dev` builds a fresh /dev from a hard-coded whitelist that does not +// include /dev/nvidia*, /dev/dri or /dev/dxg, so a sandbox without `--gpu` is a +// software-rendering environment by construction. That default is CORRECT -- +// device passthrough should be a decision someone takes, not something a tool +// does quietly -- and `--gpu` genuinely restores it: measured, GLX and Vulkan +// come back byte-identical to the unsandboxed subos. +// +// So the gap is not capability, it is silence. Without the flag the user gets +// "runs, draws a window, exits 0", indistinguishable from the GPU case except +// in frame rate. That is this stack's whole failure mode: succeeding at the +// wrong thing without saying so. +// +// THREE conditions, and the narrowness is the point. `warn_storage_dormant_on_ +// shell_` a few lines below is a hint that fired on every entry of its kind, +// became noise, and is now commented out -- a hint that cannot be acted on is +// worse than none. This one can only fire where acting on it changes the +// outcome: +// +// * the subos has a GL dispatch -- a subos that does no graphics is not +// missing anything (read, not probed: same state file `subos info` reads) +// * the host actually has GPU device nodes -- on a machine with no GPU, +// `--gpu` would expose nothing and the advice would be false +// * `--gpu` was not passed -- the user who asked does not need telling +void warn_sandbox_without_gpu_(const std::string& name, bool gpu, + EventStream& stream) { + if (gpu) return; + auto w = xlings::subos::graphics::read_graphics_wiring(Config::subos_dir(name)); + if (!w.has_dispatch()) return; + if (!xlings::subos::gpu::host_has_gpu_devices()) return; + stream.emit(DataEvent{"tip", nlohmann::json{ + {"message", + "this subos has a GL stack but the sandbox exposes no GPU device; " + "GL will render in software. Add --gpu to pass the host's GPU through."} + }.dump()}); +} + void warn_storage_dormant_on_shell_(const std::string& name) { auto& p = Config::paths(); auto storage = sandbox::read_storage_mode_(p.homeDir / "subos" / name); @@ -1433,6 +1472,7 @@ int use_spawn_shell(const std::string& name, EventStream& stream, // both sides of the boundary. if (auto rc = use_detail_::validate_subos_(name, stream); rc != 0) return rc; use_detail_::apply_subos_env_(name); + warn_sandbox_without_gpu_(name, gpu, stream); return sandbox::enter(name, stream, sandbox_backend, gpu, cmd); } warn_storage_dormant_on_shell_(name); @@ -1676,6 +1716,24 @@ nlohmann::json graphics_fields_(const fs::path& subosDir) { break; } + // The one part of this stack we do not own is the host's NVIDIA driver, + // and it moves: a distribution update replaces it, the versioned SONAMEs + // our payload links to change, and the wiring below describes a driver + // that is no longer there. The detector already existed and worked + // (`xlings-gl-doctor`); what it lacked was a way to reach the user without + // being remembered. Reported before the per-vendor rows because when it + // fires, every row under it is about the old driver. + if (auto d = gfx::read_driver_stamp(w.dispatchDir / "lib" / + std::string(gfx::kVendorSubdir)); + d.drifted()) { + row("host driver", + "CHANGED — this stack was wired for " + d.builtFor + + " and the host is now running " + d.hostNow + + ". Re-run 'xlings install graphics'; until then the states below " + "describe a driver that is no longer loaded.", + true); + } + if (w.dispatchMismatch) { // The record describes libraries nobody in this subos will load. // Reporting their states as this subos's would be a confident wrong diff --git a/src/core/subos/gpu.cppm b/src/core/subos/gpu.cppm index 5415ab46..687a92b2 100644 --- a/src/core/subos/gpu.cppm +++ b/src/core/subos/gpu.cppm @@ -83,4 +83,27 @@ inline std::vector passthrough_args() { }); } +// Does this host have a GPU that `--gpu` would actually expose? +// +// Derived from `passthrough_args` rather than from a second list of device +// paths. A separate list is a second answerer to a question this function +// already answers, and the two would drift the first time a platform's node +// is added to one of them — /dev/dxg was added to the list above precisely +// because a platform whose node is missing reports "no GPU" identically to a +// machine that has none. +// +// The `--ro-bind /sys` triple is unconditional, so the presence of any +// `--dev-bind` is exactly "at least one GPU character device exists". +inline bool host_has_gpu_devices(std::function exists_fn) { + auto args = passthrough_args(std::move(exists_fn)); + return std::find(args.begin(), args.end(), "--dev-bind") != args.end(); +} + +inline bool host_has_gpu_devices() { + return host_has_gpu_devices([](const std::string& p) { + std::error_code ec; + return std::filesystem::exists(p, ec); + }); +} + } // namespace xlings::subos::gpu diff --git a/src/core/subos/graphics.cppm b/src/core/subos/graphics.cppm index 7aa00c27..3521f41e 100644 --- a/src/core/subos/graphics.cppm +++ b/src/core/subos/graphics.cppm @@ -282,6 +282,76 @@ GraphicsWiring read_graphics_wiring(const fs::path& subosDir) { return w; } +// ─── host driver drift ───────────────────────────────────────────────────── +// +// The one piece of this stack we do not own is the NVIDIA userspace driver: it +// is in lockstep with the host's kernel module (550.144.03 userspace talks to +// 550.144.03 `nvidia.ko` and to nothing else), so the stack links to the +// host's files rather than shipping them. That makes the host driver a version +// that moves under us — a distribution update replaces it, the versioned +// SONAMEs our payload symlinks to change, and the wiring recorded at install +// time describes a driver that is no longer there. +// +// The detector already exists and works: `xlings-gl-doctor`, shipped by the +// nvidia-gl-host-link package, compares the version stamped at install against +// the one the kernel module reports now. What it lacks is a way to reach the +// user — it has to be remembered and run. This reads the same two files it +// reads, so `subos info` can say it without anyone remembering. +// +// Two plain file reads, no subprocess: the local-query-answers-instantly +// contract (2026.8.10.1) applies here as much as to the wiring record, and the +// values are already written down. Re-deriving the driver version by running +// something would make this the second answerer to a question the installer +// answered. +struct DriverStamp { + bool known { false }; // the payload recorded a version at install + std::string builtFor; // /.host-driver-version + std::string hostNow; // /sys/module/nvidia/version + bool drifted() const { + // Only a DISAGREEMENT is drift. An unknown on either side is not: + // a machine whose module is not loaded right now (`hostNow` empty) + // has not changed driver, it has no driver running, and reporting + // that as drift would cry wolf on every laptop with the GPU asleep. + return known && !builtFor.empty() && !hostNow.empty() + && builtFor != hostNow; + } +}; + +inline std::string read_trimmed_(const fs::path& p) { + std::ifstream in(p, std::ios::binary); + if (!in) return {}; + std::string s; + std::getline(in, s); + while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) + s.pop_back(); + return s; +} + +// `vendorDir` is the dispatch's `glx-vendor/`. The NVIDIA entry there is a +// symlink into the nvidia payload, so the payload root — and the stamp beside +// it — is reached the same way everything else here is reached: by following +// the link the loader would follow, not by searching the store. +DriverStamp read_driver_stamp(const fs::path& vendorDir) { + DriverStamp d; + std::error_code ec; + for (auto it = fs::directory_iterator(vendorDir, ec); + !ec && it != fs::directory_iterator(); it.increment(ec)) { + auto name = it->path().filename().string(); + if (name.find("nvidia") == std::string::npos) continue; + auto real = fs::weakly_canonical(it->path(), ec); + if (ec) continue; + // /lib/ -> + auto payload = real.parent_path().parent_path(); + auto stamp = payload / ".host-driver-version"; + if (!fs::exists(stamp, ec)) continue; + d.known = true; + d.builtFor = read_trimmed_(stamp); + break; + } + if (d.known) d.hostNow = read_trimmed_("/sys/module/nvidia/version"); + return d; +} + // ─── display ─────────────────────────────────────────────────────────────── // // Presentation, not re-derivation: the SONAME is the record's key, and this diff --git a/src/core/xim/installer.cppm b/src/core/xim/installer.cppm index bb5ef4d2..d41fead8 100644 --- a/src/core/xim/installer.cppm +++ b/src/core/xim/installer.cppm @@ -3063,6 +3063,11 @@ public: log::warn("[{}@{}] {}", node.name, node.version, closurecheck::describe_floor(*rep.floor)); } + if (rep.nonTransitive) { + log::warn("[{}@{}] {}", node.name, node.version, + closurecheck::describe_non_transitive( + *rep.nonTransitive)); + } } if (catalog_) { diff --git a/tests/unit/test_closure_check.cpp b/tests/unit/test_closure_check.cpp index 856e526e..6a27310e 100644 --- a/tests/unit/test_closure_check.cpp +++ b/tests/unit/test_closure_check.cpp @@ -106,3 +106,133 @@ TEST_F(ClosureCheckStore, PayloadSonamesSeesItsOwnOffering) { EXPECT_TRUE(self.contains("libfoo.so")); EXPECT_FALSE(self.contains("not-a-library")); } + + +// ── rule E: which tag the search path is in ──────────────────────────────── +// +// DT_RUNPATH is consulted only for the object carrying it; DT_RPATH is +// consulted for every dlopen anywhere in the process. A GL program reaches its +// driver through three levels of dlopen, so the tag -- not the path -- decides +// whether it renders on the GPU. Measured on a real home: 1 of 73 installed +// executables carried DT_RPATH while 55 of the other 68 already carried the +// correct PATH. +// +// Synthetic ELFs rather than real ones: the parser is the thing under test, +// and a real binary would make these cases depend on whichever toolchain built +// the fixture. +namespace { + +// A minimal 64-bit LE ELF with one PT_DYNAMIC segment holding the given +// (tag, value) pairs. Only the fields path_tag_of reads are filled in. +std::string synth_elf(const std::vector>& dyn, + bool is64 = true) { + const std::size_t ehsize = is64 ? 64u : 52u; + const std::size_t phentsz = is64 ? 56u : 32u; + const std::size_t phoff = ehsize; + const std::size_t dynoff = phoff + phentsz; + const std::size_t w = is64 ? 8u : 4u; + std::string b(dynoff + (dyn.size() + 1) * 2 * w, '\0'); + + auto put = [&](std::size_t off, std::uint64_t v, int width) { + for (int i = 0; i < width; ++i) + b[off + static_cast(i)] = + static_cast((v >> (8 * i)) & 0xff); + }; + + b[0] = '\x7f'; b[1] = 'E'; b[2] = 'L'; b[3] = 'F'; + b[4] = is64 ? 2 : 1; // class + b[5] = 1; // little-endian + if (is64) { + put(32, phoff, 8); put(54, phentsz, 2); put(56, 1, 2); + put(phoff, 2, 4); // p_type = PT_DYNAMIC + put(phoff + 8, dynoff, 8); // p_offset + } else { + put(28, phoff, 4); put(42, phentsz, 2); put(44, 1, 2); + put(phoff, 2, 4); + put(phoff + 4, dynoff, 4); + } + std::size_t at = dynoff; + for (auto& [tag, val] : dyn) { + put(at, tag, static_cast(w)); + put(at + w, val, static_cast(w)); + at += 2 * w; + } + return b; // trailing zeros are the DT_NULL terminator +} + +fs::path write_synth(const fs::path& dir, const std::string& name, + const std::string& bytes) { + fs::create_directories(dir); + auto p = dir / name; + std::ofstream(p, std::ios::binary).write(bytes.data(), + static_cast(bytes.size())); + return p; +} + +constexpr std::uint64_t DT_NEEDED = 1, DT_RPATH = 15, DT_RUNPATH = 29; + +} // namespace + +TEST(ClosureCheckTag, RunpathIsReportedAsNonTransitive) { + const auto dir = fs::temp_directory_path() / "xlings-tagtest-runpath"; + fs::remove_all(dir); + auto f = write_synth(dir, "a.elf", synth_elf({{DT_NEEDED, 1}, {DT_RUNPATH, 2}})); + EXPECT_EQ(cc::path_tag_of(f), cc::PathTag::Runpath); + fs::remove_all(dir); +} + +TEST(ClosureCheckTag, RpathIsTheTransitiveOne) { + const auto dir = fs::temp_directory_path() / "xlings-tagtest-rpath"; + fs::remove_all(dir); + auto f = write_synth(dir, "a.elf", synth_elf({{DT_RPATH, 2}, {DT_NEEDED, 1}})); + EXPECT_EQ(cc::path_tag_of(f), cc::PathTag::Rpath); + fs::remove_all(dir); +} + +// When both tags are present the loader IGNORES DT_RPATH and uses DT_RUNPATH, +// so the ELF behaves as Runpath. Reading whichever came first in the table +// would report the opposite on half of them -- and DT_RPATH first is the +// common layout, so the wrong reading would look right in casual testing. +TEST(ClosureCheckTag, BothTagsBehaveAsRunpathWhicheverComesFirst) { + const auto dir = fs::temp_directory_path() / "xlings-tagtest-both"; + fs::remove_all(dir); + auto a = write_synth(dir, "rpath-first.elf", + synth_elf({{DT_RPATH, 2}, {DT_RUNPATH, 3}})); + auto b = write_synth(dir, "runpath-first.elf", + synth_elf({{DT_RUNPATH, 3}, {DT_RPATH, 2}})); + EXPECT_EQ(cc::path_tag_of(a), cc::PathTag::Runpath); + EXPECT_EQ(cc::path_tag_of(b), cc::PathTag::Runpath); + fs::remove_all(dir); +} + +TEST(ClosureCheckTag, NoSearchPathIsNeitherTag) { + const auto dir = fs::temp_directory_path() / "xlings-tagtest-none"; + fs::remove_all(dir); + auto f = write_synth(dir, "a.elf", synth_elf({{DT_NEEDED, 1}})); + EXPECT_EQ(cc::path_tag_of(f), cc::PathTag::None); + fs::remove_all(dir); +} + +TEST(ClosureCheckTag, ThirtyTwoBitElfIsParsedToo) { + const auto dir = fs::temp_directory_path() / "xlings-tagtest-32"; + fs::remove_all(dir); + auto f = write_synth(dir, "a.elf", synth_elf({{DT_RUNPATH, 2}}, /*is64=*/false)); + EXPECT_EQ(cc::path_tag_of(f), cc::PathTag::Runpath); + fs::remove_all(dir); +} + +// Anything that is not a little-endian ELF declines rather than guesses. A +// wrong guess here would MISREPORT -- a payload called out for a tag it does +// not have -- which is worse than saying nothing. +TEST(ClosureCheckTag, NonElfAndBigEndianDecline) { + const auto dir = fs::temp_directory_path() / "xlings-tagtest-junk"; + fs::remove_all(dir); + auto junk = write_synth(dir, "junk", std::string(200, 'x')); + EXPECT_EQ(cc::path_tag_of(junk), cc::PathTag::None); + + auto be = synth_elf({{DT_RUNPATH, 2}}); + be[5] = 2; // big-endian + auto bef = write_synth(dir, "be.elf", be); + EXPECT_EQ(cc::path_tag_of(bef), cc::PathTag::None); + fs::remove_all(dir); +} diff --git a/tests/unit/test_subos_graphics.cpp b/tests/unit/test_subos_graphics.cpp index e1e07b46..2c591499 100644 --- a/tests/unit/test_subos_graphics.cpp +++ b/tests/unit/test_subos_graphics.cpp @@ -67,6 +67,21 @@ struct Tree { fs::create_directories(vendorDir); std::ofstream(vendorDir / std::string(soname)) << "vendor"; } + // The nvidia payload records the host driver version it was wired for. + // The vendor entry is a symlink into that payload, so the stamp is reached + // by following the link the loader would follow — the same discipline the + // rest of this module uses instead of searching the store. + void stamp_driver(std::string_view version) { + fs::create_directories(vendorDir); + auto nv = root / "store" / "nvidia"; + fs::create_directories(nv / "lib"); + std::ofstream(nv / "lib" / "libGLX_nvidia.so.0") << "vendor"; + std::ofstream(nv / ".host-driver-version") << version << "\n"; + std::error_code ec; + fs::remove(vendorDir / "libGLX_nvidia.so.0", ec); + fs::create_symlink(nv / "lib" / "libGLX_nvidia.so.0", + vendorDir / "libGLX_nvidia.so.0", ec); + } void write_record(std::string_view text) { fs::create_directories(vendorDir); std::ofstream(vendorDir / ".wiring") << text; @@ -315,6 +330,55 @@ TEST(SubosGraphics, AnUnknownStateSaysThisClientCannotReadIt) { EXPECT_NE(d.find("unassessed"), std::string::npos); } +// ─── host driver drift ───────────────────────────────────────────────────── +// +// The NVIDIA userspace driver is the one part of this stack we do not own: it +// is in lockstep with the host's kernel module, so we link to the host's files +// rather than shipping them, and a distribution update moves it under us. The +// wiring recorded at install then describes a driver that is no longer there. + +TEST(SubosGraphics, AgreeingDriverVersionsAreNotDrift) { + Tree t("drift-same"); + WIRE_OR_SKIP(t); + t.add_vendor("libGLX_nvidia.so.0"); + t.stamp_driver("550.144.03"); + auto d = gfx::read_driver_stamp(t.vendorDir); + EXPECT_TRUE(d.known); + EXPECT_EQ(d.builtFor, "550.144.03"); +} + +// An unknown on either side is NOT drift. A machine whose kernel module is not +// loaded right now has not changed driver — it has no driver running — and +// reporting that as a change would cry wolf on every laptop with the GPU +// asleep, which is how the previous generation of hints in this codebase +// became noise and got commented out. +TEST(SubosGraphics, AnUnreadableSideIsNotDrift) { + Tree t("drift-unknown"); + WIRE_OR_SKIP(t); + t.add_vendor("libGLX_nvidia.so.0"); + t.stamp_driver("550.144.03"); + auto d = gfx::read_driver_stamp(t.vendorDir); + // hostNow comes from /sys and is empty on any machine without the module. + gfx::DriverStamp probe{true, "550.144.03", "", }; + EXPECT_FALSE(probe.drifted()); + gfx::DriverStamp nostamp{false, "", "560.1", }; + EXPECT_FALSE(nostamp.drifted()); + gfx::DriverStamp real{true, "550.144.03", "560.35.03", }; + EXPECT_TRUE(real.drifted()) << "a genuine version change must be reported"; + (void)d; +} + +// A stack with no stamp at all (installed by a recipe older than the stamp) +// must not be reported as drifted — nobody recorded what it was built for. +TEST(SubosGraphics, NoStampIsNotDrift) { + Tree t("drift-nostamp"); + WIRE_OR_SKIP(t); + t.add_vendor("libGLX_nvidia.so.0"); + auto d = gfx::read_driver_stamp(t.vendorDir); + EXPECT_FALSE(d.known); + EXPECT_FALSE(d.drifted()); +} + TEST(SubosGraphics, AMissingClosureNamesTheLibraries) { gfx::VendorWiring v{"libGLX_nvidia.so.0", "broken", "", {"libpthread.so.0", "librt.so.1"}}; From 0cd5526418dec76c8400b6348ed27da22bf0a42e Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 10 Aug 2026 19:07:43 +0800 Subject: [PATCH 2/2] test(e2e): observe rule E firing on a real install, in the rig that already exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gap I flagged in this PR's own description: rule E's parser was falsified against synthetic ELFs, but nothing had watched it fire during an actual install — and that case cannot be produced by installing anything today, because since libxpkg 0.0.57 the writer is consistent and every package with a `bin/` also has a `lib/`, so elfpatch always stamps DT_RPATH. closure_guard_differential already builds exactly the right rig for it: a real gcc-compiled executable, given PT_INTERP into a payload glibc so it is form X, carried into a payload by a fixture recipe and installed for real. One `patchelf --set-rpath` WITHOUT `--force-rpath` — which is precisely what elfpatch wrote before 0.0.57 — turns it into a payload arriving from an older client, which is the case rule E exists to catch. Two assertions, because the second is the one that decides whether the message is useful. The path in a DT_RUNPATH executable is usually correct, so a warning that only named the tag would read as pedantry rather than as the reason a GL program renders in software; 4b requires the message to say what it costs. The rig asserts its own shape before relying on it (`readelf` must show RUNPATH), so a future patchelf that writes something else fails loudly instead of making 4b vacuous. Falsified: forcing the rig to DT_RPATH makes 4b fail, and restoring it passes. --- tests/e2e/closure_guard_differential_test.sh | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/e2e/closure_guard_differential_test.sh b/tests/e2e/closure_guard_differential_test.sh index 4d25128e..04d80394 100644 --- a/tests/e2e/closure_guard_differential_test.sh +++ b/tests/e2e/closure_guard_differential_test.sh @@ -17,6 +17,10 @@ # 2. warn names libnothere.so.9 (rule D fires on the real gap) # 3. warn does NOT name libc.so.6 (a provided soname is not a gap) # 4. version-floor warn names 1.0 vs the recorded host glibc (rule A) +# 4b. warn says the search path is non-transitive (rule E) -- the rig carries +# DT_RUNPATH, which is what elfpatch wrote before libxpkg 0.0.57. This is +# the ONLY way to observe rule E firing: installing anything today +# produces DT_RPATH executables, because the writer is now consistent. # 5. dep-closure-check.sh on the same payload also fails, also names # libnothere.so.9, and also does not call libc.so.6 host-only # @@ -63,6 +67,15 @@ patchelf --set-interpreter "$FAKE_GLIBC/lib64/ld-linux-x86-64.so.2" "$RIG_DIR/ri || fail "patchelf --set-interpreter failed" patchelf --add-needed libnothere.so.9 "$RIG_DIR/rigged" \ || fail "patchelf --add-needed failed" +# rule E's rig, on the same executable. `--set-rpath` without `--force-rpath` +# is exactly what elfpatch did before libxpkg 0.0.57, so this reproduces a +# payload arriving from an older client — the case rule E exists to catch and +# the one that cannot be produced by installing anything today, because the +# writer is now consistent. +patchelf --set-rpath "$FAKE_GLIBC/lib64" "$RIG_DIR/rigged" \ + || fail "patchelf --set-rpath failed" +[ -n "$(readelf -d "$RIG_DIR/rigged" | grep -o '(RUNPATH)')" ] \ + || fail "the rig was meant to carry DT_RUNPATH; patchelf wrote something else" # ── the fixture recipe that carries it into a payload ─────────────────── cat > "$LOCAL_INDEX_DIR/pkgs/c/closurefix.lua" <