From 1043e1853aa94ed0eaf1d7b9e4e3a7d1295308e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 18:27:42 +0000 Subject: [PATCH] fix: tell the confined semantic provider where its libraries are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rust-analyzer under confinement died with status 127 before its first byte of LSP: /module/rust-analyzer: error while loading shared libraries: librustc_driver-.so: cannot open shared object file It was read as the seccomp filter for a day, and it is not: no SIGSYS, nothing in ausearch, and cargo running fine beside it under the same profile. The cause is RUNPATH. Every binary rustup installs carries `RUNPATH: [$ORIGIN/../lib]`, and $ORIGIN is the directory the *loader* finds the binary in — not the one it was installed in. `foreign::establish` mounts a foreign program's own directory at `/module`, so a rust-analyzer living in /bin is executed as `/module/rust-analyzer` and looks for librustc_driver in `/lib`. Cargo never meets it: `cargo` needs no librustc_driver, and the rustc it starts is started at its own absolute path, where $ORIGIN still means what it was linked to mean. So the directory RUNPATH meant is named outright, derived from where the binary really is — no hash, no version, no toolchain name — and only when it exists. Not a widening: it is inside the toolchain `readable()` already grants read-only, a grant keeps its absolute path inside the root filesystem, and an LD_LIBRARY_PATH entry naming something nobody granted names something that is not there. Reproduced physically without a confinement, by hardlinking this container's rustc into another directory so its $ORIGIN is wrong: status 127 with that exact message, and `rustc --version` with LD_LIBRARY_PATH set to /lib. The regression test asserts the property the change controls — what environment reaches whoever starts the process — through a spawner that records and refuses. The confinement itself cannot be built here, and a test that claimed otherwise would be claiming to have proven the thing that was broken. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013VYCpUvjbzH1vPoCh4L4fh --- crates/thalyx-rust/src/analyzer.rs | 26 +++- crates/thalyx-rust/src/toolchain.rs | 44 +++++++ ...nalyzer_is_told_where_its_libraries_are.rs | 111 ++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 crates/thalyx-rust/tests/a_confined_analyzer_is_told_where_its_libraries_are.rs diff --git a/crates/thalyx-rust/src/analyzer.rs b/crates/thalyx-rust/src/analyzer.rs index 3e53737..9662476 100644 --- a/crates/thalyx-rust/src/analyzer.rs +++ b/crates/thalyx-rust/src/analyzer.rs @@ -426,6 +426,30 @@ impl Analyzer { environment: &[(String, String)], spawner: &dyn Spawn, ) -> Result { + // The loader path the binary's own `RUNPATH` cannot reach from where it + // is executed. See [`crate::toolchain::loader_path`]: confined, this + // server runs as `/module/rust-analyzer`, so its `$ORIGIN/../lib` is + // `/lib`, `librustc_driver-.so` is not there, and the process + // exits 127 before its first byte of LSP — with no `SIGSYS` and nothing + // in `ausearch`, which reads exactly like the filter killing it. + // + // Added here, where the binary's real directory is still known, rather + // than by each spawner: two spawners assembling it is two answers to + // where a toolchain keeps its libraries. A caller that named the + // variable itself is left alone — an explicit value is somebody's + // decision and this is a default. + let mut environment = environment.to_vec(); + if !environment + .iter() + .any(|(name, _)| name == crate::toolchain::LOADER_PATH_VARIABLE) + && let Some(lib) = crate::toolchain::loader_path(binary) + { + environment.push(( + crate::toolchain::LOADER_PATH_VARIABLE.to_string(), + lib.display().to_string(), + )); + } + let Started { mut child, release, @@ -436,7 +460,7 @@ impl Analyzer { root, build_into, readable, - environment, + environment: &environment, })?; let stdin = child.stdin.take().ok_or_else(|| { diff --git a/crates/thalyx-rust/src/toolchain.rs b/crates/thalyx-rust/src/toolchain.rs index 35d7b25..185d712 100644 --- a/crates/thalyx-rust/src/toolchain.rs +++ b/crates/thalyx-rust/src/toolchain.rs @@ -332,6 +332,50 @@ pub fn environment() -> Vec<(&'static str, PathBuf)> { environment } +/// The environment variable that names where the loader looks first. +pub const LOADER_PATH_VARIABLE: &str = "LD_LIBRARY_PATH"; + +/// The directory a toolchain binary's own `RUNPATH` means, resolved from where +/// the binary really is rather than from where it is executed. +/// +/// ## The failure this exists to stop +/// +/// Every binary rustup installs carries `RUNPATH: [$ORIGIN/../lib]`, and +/// `librustc_driver-.so` — which `rust-analyzer` cannot start without — +/// is what it is there to find. **`$ORIGIN` is the directory the loader finds +/// the binary in**, and inside a confinement that is not where it was +/// installed: `foreign::establish` mounts the program's own directory at +/// `/module`, so a `rust-analyzer` living in `/bin` is executed as +/// `/module/rust-analyzer`, `$ORIGIN/../lib` becomes `/lib`, and the process +/// dies before its first byte of LSP saying +/// +/// ```text +/// error while loading shared libraries: librustc_driver-.so: +/// cannot open shared object file: No such file or directory +/// ``` +/// +/// That is status 127 with no `SIGSYS` and nothing in `ausearch` — a death +/// that looks exactly like the seccomp filter killing the process and is not +/// the filter at all. Cargo does not meet it because `cargo` needs no +/// `librustc_driver`, and the `rustc` it starts is started at its own absolute +/// path, where `$ORIGIN` still means what it was linked to mean. +/// +/// ## And why naming it is not a widening +/// +/// The directory is inside the toolchain [`readable`] already grants +/// read-only, a grant keeps its absolute path inside the root filesystem, and +/// a `LD_LIBRARY_PATH` entry naming something nobody granted names something +/// that is not there. Nothing new is reachable; the loader is told the one +/// place its own `RUNPATH` meant. +/// +/// Derived from the binary and never spelled: no hash, no version, no +/// toolchain name. `None` when there is no such directory, so a binary laid +/// out some other way is left to its own `RUNPATH`. +pub fn loader_path(binary: &Path) -> Option { + let lib = binary.parent()?.parent()?.join("lib"); + lib.is_dir().then_some(lib) +} + /// Everything a confined toolchain run must be able to read. /// /// The registry and the toolchain, and nothing else. Named here rather than at diff --git a/crates/thalyx-rust/tests/a_confined_analyzer_is_told_where_its_libraries_are.rs b/crates/thalyx-rust/tests/a_confined_analyzer_is_told_where_its_libraries_are.rs new file mode 100644 index 0000000..3245dfe --- /dev/null +++ b/crates/thalyx-rust/tests/a_confined_analyzer_is_told_where_its_libraries_are.rs @@ -0,0 +1,111 @@ +//! The semantic provider is started knowing where its own libraries are. +//! +//! Written on 2026-08-30 from physical evidence on Fedora. Under confinement +//! rust-analyzer died like this, and it was read as the seccomp filter for a +//! day: +//! +//! ```text +//! the process exited with status 127 +//! /module/rust-analyzer: error while loading shared libraries: +//! librustc_driver-.so: cannot open shared object file +//! ``` +//! +//! No `SIGSYS`, nothing in `ausearch`, and cargo running fine beside it. The +//! cause is `RUNPATH: [$ORIGIN/../lib]`, which every binary rustup installs +//! carries: `$ORIGIN` is the directory the **loader** finds the binary in, and +//! `foreign::establish` mounts the program's own directory at `/module`, so a +//! server installed in `/bin` looks for its libraries in `/lib`. +//! +//! What is asserted here is the property the fix controls: the environment +//! handed to whoever starts the process names the directory that `RUNPATH` +//! meant, derived from where the binary really is. The confinement itself +//! cannot be built in this container, and a test that claimed otherwise would +//! be claiming to have proven the thing that was broken. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use thalyx_rust::analyzer::{Analyzer, Launching, Spawn, Started}; + +/// A spawner that starts nothing and remembers what it was asked with. +/// +/// It refuses rather than standing a process up: the question is what reaches +/// a spawner, and a stand-in that spoke LSP would put a conversation between +/// the assertion and the thing being asserted. +#[derive(Default)] +struct Remembers(Arc>>); + +impl Spawn for Remembers { + fn start(&self, asked: Launching<'_>) -> thalyx_rust::Result { + *self.0.lock().expect("the record") = asked.environment.to_vec(); + Err(thalyx_rust::RustError::NoAnalyzer( + "this spawner exists to be asked, not to start anything".to_string(), + )) + } +} + +/// A toolchain's shape on disk — `bin` beside `lib` — without a toolchain. +/// +/// The layout is the whole of what the answer is derived from, so a fake that +/// has it models the property under test. Nothing here is executed. +fn toolchain_shaped(with_lib: bool) -> (tempfile::TempDir, PathBuf) { + let directory = tempfile::tempdir().expect("a temp dir"); + let bin = directory.path().join("bin"); + std::fs::create_dir_all(&bin).expect("a bin directory"); + if with_lib { + std::fs::create_dir_all(directory.path().join("lib")).expect("a lib directory"); + } + let binary = bin.join("rust-analyzer"); + (directory, binary) +} + +/// What the spawner was handed, for a server started from `binary`. +fn environment_for(binary: &Path, named: &[(String, String)]) -> Vec<(String, String)> { + let remembers = Remembers::default(); + let seen = Arc::clone(&remembers.0); + let _ = Analyzer::start(Path::new("."), binary, None, &[], named, &remembers); + seen.lock().expect("the record").clone() +} + +#[test] +fn the_directory_the_runpath_meant_is_named_outright() { + let (toolchain, binary) = toolchain_shaped(true); + let handed = environment_for(&binary, &[]); + let lib = toolchain.path().join("lib").display().to_string(); + assert!( + handed.contains(&("LD_LIBRARY_PATH".to_string(), lib.clone())), + "a server whose `RUNPATH` is `$ORIGIN/../lib` must be told that \ + directory by name, because inside the confinement `$ORIGIN` is \ + `/module`. Expected {lib}, and the spawner was handed: {handed:?}" + ); +} + +#[test] +fn a_binary_with_no_such_directory_is_left_to_its_own_runpath() { + // A guess would be worse than nothing here: naming a directory that is not + // there puts a path in front of the loader that no grant covers, and turns + // "this layout is unusual" into a second failure to read. + let (_toolchain, binary) = toolchain_shaped(false); + let handed = environment_for(&binary, &[]); + assert!( + !handed.iter().any(|(name, _)| name == "LD_LIBRARY_PATH"), + "nothing should be named when there is no such directory, and the \ + spawner was handed: {handed:?}" + ); +} + +#[test] +fn a_caller_that_named_it_is_not_overruled() { + let (_toolchain, binary) = toolchain_shaped(true); + let named = vec![("LD_LIBRARY_PATH".to_string(), "/somewhere/said".to_string())]; + let handed = environment_for(&binary, &named); + assert_eq!( + handed + .iter() + .filter(|(name, _)| name == "LD_LIBRARY_PATH") + .collect::>(), + vec![&named[0]], + "an explicit value is somebody's decision and this is a default; \ + the spawner was handed: {handed:?}" + ); +}