Skip to main content

onelf_format/
drivers.rs

1//! Host directories that supply GPU and system libraries.
2//!
3//! A bundled loader has its compiled-in search paths scrubbed, so anything
4//! the host must still provide (libcuda, libvulkan, libGL, libva) has to be
5//! named explicitly. The packed runtime and `onelf run` both need the same
6//! list, and previously kept two copies in sync by comment.
7
8use std::path::Path;
9
10/// Host driver and system library directories for `arch`, in descending
11/// priority, filtered to those that exist.
12///
13/// `arch` takes the `std::env::consts::ARCH` spelling of the architecture
14/// the package targets. Multiarch directory names are derived from it, so a
15/// package is not handed x86_64 paths on aarch64.
16///
17/// The well-known directories come first, then any further directory the
18/// host's `ld.so.cache` names. See [`cache_dirs`] for why the cache has to be
19/// read here rather than left to the loader.
20pub fn host_driver_paths(arch: &str) -> Vec<String> {
21    // NixOS exposes every GPU userspace driver under /run/opengl-driver,
22    // populated from the active `hardware.graphics` closure. Elsewhere they
23    // sit alongside the rest of the system's libraries.
24    let mut dirs: Vec<&str> = vec!["/run/opengl-driver/lib", "/run/opengl-driver-32/lib"];
25    dirs.extend(multiarch_dirs(arch));
26    dirs.extend(["/usr/lib64", "/usr/lib", "/lib64", "/lib"]);
27
28    let mut seen: Vec<String> = Vec::new();
29    for d in dirs {
30        if Path::new(d).is_dir() && !seen.iter().any(|s| s == d) {
31            seen.push(d.to_string());
32        }
33    }
34    // Appended last: these are a completion of the list above, and must not
35    // displace the driver closure that has to be searched first.
36    for d in cache_dirs() {
37        if !seen.contains(&d) {
38            seen.push(d);
39        }
40    }
41    seen
42}
43
44/// Directories holding a library named by the host's `/etc/ld.so.cache`,
45/// in the order the cache lists them, filtered to those that exist.
46///
47/// The bundled loader cannot consult the cache itself: `bundle-libs` blanks
48/// the `/etc/` string inside it and the runtime passes `--inhibit-cache`,
49/// both so a host library cannot shadow a bundled one. That leaves the fixed
50/// list above as the only route to a host library, and it is a guess about
51/// where a distribution puts things.
52///
53/// Where the guess fails, a host GPU driver loads and its own dependencies do
54/// not. Gentoo slots LLVM under `/usr/lib/llvm/<n>/lib64`, so Mesa's RADV
55/// driver resolves but the `libLLVM.so.<n>` behind it does not, and Vulkan
56/// goes silently missing. Reading the cache here keeps the decision about
57/// what the host may supply on onelf's side, while letting the answer come
58/// from the host's own index instead of a hardcoded list.
59fn cache_dirs() -> Vec<String> {
60    let Ok(data) = std::fs::read("/etc/ld.so.cache") else {
61        return Vec::new();
62    };
63
64    let mut dirs: Vec<String> = Vec::new();
65    for entry in cache_entries(&data) {
66        let Some((dir, _)) = entry.rsplit_once('/') else {
67            continue;
68        };
69        if !dir.is_empty() && !dirs.iter().any(|d| d == dir) {
70            dirs.push(dir.to_string());
71        }
72    }
73    // One stat per distinct directory rather than one per cache entry: a
74    // cache routinely lists a couple of thousand libraries across a handful
75    // of directories.
76    dirs.retain(|d| Path::new(d).is_dir());
77    dirs
78}
79
80const CACHE_MAGIC_OLD: &[u8] = b"ld.so-1.7.0";
81const CACHE_MAGIC_NEW: &[u8] = b"glibc-ld.so.cache1.1";
82
83/// Library paths recorded in a glibc loader cache image.
84///
85/// Two layouts exist. `ldconfig` in its compatibility mode writes the old
86/// header, its entries, and then a complete new-format cache after them;
87/// otherwise it writes the new format alone, which is what current
88/// distributions ship. The new format is preferred wherever it appears, since
89/// the old one cannot express hwcap and is only kept for compatibility.
90fn cache_entries(data: &[u8]) -> Vec<&str> {
91    if data.starts_with(CACHE_MAGIC_NEW) {
92        return new_entries(data, 0);
93    }
94    if data.starts_with(CACHE_MAGIC_OLD) {
95        // struct cache_file: char magic[11], then `unsigned int nlibs` at the
96        // next 4-byte boundary, then 12-byte entries.
97        let Some(nlibs) = read_u32(data, 12) else {
98            return Vec::new();
99        };
100        let Some(entries_len) = (nlibs as usize).checked_mul(12) else {
101            return Vec::new();
102        };
103        let Some(end) = entries_len.checked_add(16) else {
104            return Vec::new();
105        };
106        // The new-format header follows, aligned to its 8-byte alignment.
107        let aligned = end.next_multiple_of(8);
108        if data.len() > aligned && data[aligned..].starts_with(CACHE_MAGIC_NEW) {
109            return new_entries(data, aligned);
110        }
111        return old_entries(data, nlibs as usize, end);
112    }
113    Vec::new()
114}
115
116/// Entries of a new-format cache whose header starts at `base`.
117fn new_entries(data: &[u8], base: usize) -> Vec<&str> {
118    // Header: magic+version (20), nlibs (4), len_strings (4), flags (1),
119    // padding (3), extension_offset (4), unused (12). Entries follow at 48
120    // and are 24 bytes each; string offsets are relative to `base`.
121    let Some(nlibs) = read_u32(data, base + 20) else {
122        return Vec::new();
123    };
124    let mut out = Vec::new();
125    for i in 0..nlibs as usize {
126        let Some(entry) = base.checked_add(48).and_then(|s| s.checked_add(i * 24)) else {
127            break;
128        };
129        let Some(value) = read_u32(data, entry + 8) else {
130            break;
131        };
132        if let Some(s) = read_str(data, base + value as usize) {
133            out.push(s);
134        }
135    }
136    out
137}
138
139/// Entries of an old-format cache with `nlibs` entries and a string table
140/// beginning at `strings`.
141fn old_entries(data: &[u8], nlibs: usize, strings: usize) -> Vec<&str> {
142    let mut out = Vec::new();
143    for i in 0..nlibs {
144        let entry = 16 + i * 12;
145        let Some(value) = read_u32(data, entry + 8) else {
146            break;
147        };
148        if let Some(s) = read_str(data, strings + value as usize) {
149            out.push(s);
150        }
151    }
152    out
153}
154
155fn read_u32(data: &[u8], at: usize) -> Option<u32> {
156    let bytes = data.get(at..at.checked_add(4)?)?;
157    Some(u32::from_le_bytes(bytes.try_into().ok()?))
158}
159
160/// The NUL-terminated string at `at`, if it is in bounds, terminated, and
161/// an absolute path. Anything else is a cache we do not understand, and a
162/// relative path would resolve against the app's working directory.
163fn read_str(data: &[u8], at: usize) -> Option<&str> {
164    let rest = data.get(at..)?;
165    let end = rest.iter().position(|&b| b == 0)?;
166    let s = std::str::from_utf8(&rest[..end]).ok()?;
167    s.starts_with('/').then_some(s)
168}
169
170/// Debian-style multiarch library directories for `arch`, or nothing when the
171/// architecture has no well-known tuple.
172fn multiarch_dirs(arch: &str) -> &'static [&'static str] {
173    match arch {
174        "x86_64" => &["/usr/lib/x86_64-linux-gnu", "/lib/x86_64-linux-gnu"],
175        "aarch64" => &["/usr/lib/aarch64-linux-gnu", "/lib/aarch64-linux-gnu"],
176        "x86" => &["/usr/lib/i386-linux-gnu", "/lib/i386-linux-gnu"],
177        "arm" => &["/usr/lib/arm-linux-gnueabihf", "/lib/arm-linux-gnueabihf"],
178        _ => &[],
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn multiarch_follows_the_architecture() {
188        assert_eq!(multiarch_dirs("x86_64")[0], "/usr/lib/x86_64-linux-gnu");
189        assert_eq!(multiarch_dirs("aarch64")[0], "/usr/lib/aarch64-linux-gnu");
190        assert_eq!(multiarch_dirs("x86")[0], "/usr/lib/i386-linux-gnu");
191        assert!(multiarch_dirs("riscv64").is_empty());
192    }
193
194    #[test]
195    fn results_exist_and_are_unique() {
196        let dirs = host_driver_paths(std::env::consts::ARCH);
197        for d in &dirs {
198            assert!(std::path::Path::new(d).is_dir(), "{d} must exist");
199        }
200        let mut sorted = dirs.clone();
201        sorted.sort();
202        sorted.dedup();
203        assert_eq!(sorted.len(), dirs.len(), "no directory is listed twice");
204    }
205
206    /// A new-format cache image naming `paths`.
207    fn new_cache(paths: &[&str]) -> Vec<u8> {
208        let mut out = Vec::from(CACHE_MAGIC_NEW);
209        out.extend_from_slice(&(paths.len() as u32).to_le_bytes());
210        out.extend_from_slice(&0u32.to_le_bytes()); // len_strings
211        out.push(0); // flags
212        out.extend_from_slice(&[0; 3]); // padding
213        out.extend_from_slice(&0u32.to_le_bytes()); // extension_offset
214        out.extend_from_slice(&[0; 12]); // unused
215        assert_eq!(out.len(), 48, "entries must start at 48");
216
217        let base = out.len();
218        let strings_at = base + paths.len() * 24;
219        let (offsets, strings) = string_table(paths, strings_at);
220        for off in offsets {
221            out.extend_from_slice(&0i32.to_le_bytes()); // flags
222            out.extend_from_slice(&0u32.to_le_bytes()); // key
223            out.extend_from_slice(&(off as u32).to_le_bytes()); // value
224            out.extend_from_slice(&0u32.to_le_bytes()); // osversion
225            out.extend_from_slice(&0u64.to_le_bytes()); // hwcap
226        }
227        out.extend_from_slice(&strings);
228        out
229    }
230
231    /// An old-format cache image naming `paths`, optionally followed by a
232    /// new-format cache naming `also`, the way glibc before 2.32 wrote it.
233    ///
234    /// The two formats share one string table. glibc bases old-format offsets
235    /// at `&libs[nlibs]`, which is exactly where the new header is aligned to,
236    /// so when both are present both formats count from the same place.
237    fn old_cache(paths: &[&str], also: Option<&[&str]>) -> Vec<u8> {
238        let mut out = Vec::from(CACHE_MAGIC_OLD);
239        out.push(0); // pad to the 4-byte boundary nlibs sits on
240        out.extend_from_slice(&(paths.len() as u32).to_le_bytes());
241        assert_eq!(out.len(), 16, "entries must start at 16");
242
243        let entries_end = 16 + paths.len() * 12;
244        let Some(also) = also else {
245            let (offsets, strings) = string_table(paths, 0);
246            for off in offsets {
247                out.extend_from_slice(&0i32.to_le_bytes()); // flags
248                out.extend_from_slice(&0u32.to_le_bytes()); // key
249                out.extend_from_slice(&(off as u32).to_le_bytes()); // value
250            }
251            assert_eq!(out.len(), entries_end);
252            out.extend_from_slice(&strings);
253            return out;
254        };
255
256        let new_base = entries_end.next_multiple_of(8);
257        let strings_at = new_base + 48 + also.len() * 24;
258        // Both tables count from new_base, so lay the strings out once and
259        // hand each format the slice of offsets that belongs to it.
260        let all: Vec<&str> = paths.iter().chain(also.iter()).copied().collect();
261        let (offsets, strings) = string_table(&all, strings_at - new_base);
262
263        for off in &offsets[..paths.len()] {
264            out.extend_from_slice(&0i32.to_le_bytes());
265            out.extend_from_slice(&0u32.to_le_bytes());
266            out.extend_from_slice(&(*off as u32).to_le_bytes());
267        }
268        out.resize(new_base, 0);
269
270        out.extend_from_slice(CACHE_MAGIC_NEW);
271        out.extend_from_slice(&(also.len() as u32).to_le_bytes());
272        out.extend_from_slice(&0u32.to_le_bytes()); // len_strings
273        out.push(0); // flags
274        out.extend_from_slice(&[0; 3]); // padding
275        out.extend_from_slice(&0u32.to_le_bytes()); // extension_offset
276        out.extend_from_slice(&[0; 12]); // unused
277        for off in &offsets[paths.len()..] {
278            out.extend_from_slice(&0i32.to_le_bytes()); // flags
279            out.extend_from_slice(&0u32.to_le_bytes()); // key
280            out.extend_from_slice(&(*off as u32).to_le_bytes()); // value
281            out.extend_from_slice(&0u32.to_le_bytes()); // osversion
282            out.extend_from_slice(&0u64.to_le_bytes()); // hwcap
283        }
284        assert_eq!(out.len(), strings_at);
285        out.extend_from_slice(&strings);
286        out
287    }
288
289    fn string_table(paths: &[&str], base: usize) -> (Vec<usize>, Vec<u8>) {
290        let mut offsets = Vec::new();
291        let mut strings: Vec<u8> = Vec::new();
292        for p in paths {
293            offsets.push(base + strings.len());
294            strings.extend_from_slice(p.as_bytes());
295            strings.push(0);
296        }
297        (offsets, strings)
298    }
299
300    #[test]
301    fn reads_a_new_format_cache() {
302        let img = new_cache(&[
303            "/usr/lib64/libc.so.6",
304            "/usr/lib/llvm/22/lib64/libLLVM.so.22.1",
305        ]);
306        assert_eq!(
307            cache_entries(&img),
308            [
309                "/usr/lib64/libc.so.6",
310                "/usr/lib/llvm/22/lib64/libLLVM.so.22.1"
311            ]
312        );
313    }
314
315    #[test]
316    fn prefers_the_new_cache_appended_after_an_old_one() {
317        // glibc before 2.32 writes both. The old section is compatibility
318        // padding; reading it instead would miss anything hwcap-tagged.
319        let img = old_cache(&["/lib/old.so.1"], Some(&["/usr/lib64/new.so.2"]));
320        assert_eq!(cache_entries(&img), ["/usr/lib64/new.so.2"]);
321    }
322
323    #[test]
324    fn reads_an_old_format_cache_with_nothing_appended() {
325        let img = old_cache(&["/lib/libz.so.1", "/usr/lib/libm.so.6"], None);
326        assert_eq!(
327            cache_entries(&img),
328            ["/lib/libz.so.1", "/usr/lib/libm.so.6"]
329        );
330    }
331
332    #[test]
333    fn a_damaged_cache_yields_nothing_rather_than_panicking() {
334        assert!(cache_entries(b"").is_empty());
335        assert!(cache_entries(b"not a cache at all").is_empty());
336        // Truncated part-way through the entry table, and a count that would
337        // run far past the end of the image.
338        let img = new_cache(&["/usr/lib64/libc.so.6"]);
339        for cut in 0..img.len() {
340            let _ = cache_entries(&img[..cut]);
341        }
342        let mut lying = new_cache(&["/usr/lib64/libc.so.6"]);
343        lying[20..24].copy_from_slice(&u32::MAX.to_le_bytes());
344        assert!(cache_entries(&lying).len() < 2);
345    }
346
347    #[test]
348    fn relative_and_unterminated_entries_are_skipped() {
349        // A path resolved against the app's working directory would be a way
350        // in for anything that can chdir the process.
351        let img = new_cache(&["not/absolute", "/usr/lib64/fine.so"]);
352        assert_eq!(cache_entries(&img), ["/usr/lib64/fine.so"]);
353
354        let mut unterminated = new_cache(&["/usr/lib64/fine.so"]);
355        let last = unterminated.len() - 1;
356        unterminated[last] = b'x'; // clobber the NUL
357        assert!(cache_entries(&unterminated).is_empty());
358    }
359
360    #[test]
361    fn cache_directories_come_after_the_well_known_ones() {
362        // The driver closure and the distribution directories are ordered
363        // deliberately; the cache completes that list without reordering it.
364        let dirs = host_driver_paths(std::env::consts::ARCH);
365        let from_cache = cache_dirs();
366        let Some(first_cache_only) = dirs
367            .iter()
368            .position(|d| from_cache.contains(d) && !well_known(d))
369        else {
370            return; // this host's cache adds nothing
371        };
372        let last_well_known = dirs.iter().rposition(|d| well_known(d)).unwrap_or(0);
373        assert!(
374            last_well_known < first_cache_only,
375            "cache directory ordered before a well-known one in {dirs:?}"
376        );
377    }
378
379    fn well_known(dir: &str) -> bool {
380        matches!(
381            dir,
382            "/run/opengl-driver/lib"
383                | "/run/opengl-driver-32/lib"
384                | "/usr/lib64"
385                | "/usr/lib"
386                | "/lib64"
387                | "/lib"
388        ) || multiarch_dirs(std::env::consts::ARCH).contains(&dir)
389    }
390
391    #[test]
392    fn driver_paths_outrank_distribution_paths() {
393        // A host that has both must search the driver closure first, or a
394        // stale system libGL shadows the one the GPU stack expects.
395        let all = ["/run/opengl-driver/lib", "/usr/lib"];
396        let present: Vec<&str> = all
397            .into_iter()
398            .filter(|d| std::path::Path::new(d).is_dir())
399            .collect();
400        if present.len() == 2 {
401            let dirs = host_driver_paths(std::env::consts::ARCH);
402            let driver = dirs.iter().position(|d| d == "/run/opengl-driver/lib");
403            let usrlib = dirs.iter().position(|d| d == "/usr/lib");
404            assert!(driver < usrlib);
405        }
406    }
407}