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:?}" + ); +}