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/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" <>& 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"}};