Coverage Report

Created: 2026-08-20 05:46

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
src/vulkan.rs
Line
Count
Source
1
// SPDX-License-Identifier: Apache-2.0
2
//! Vulkan runtime detection.
3
//!
4
//! Vulkan advertises itself through two filesystem facts that need no linking:
5
//! the loader (`libvulkan.so.1`) and the ICD manifests each installed driver
6
//! drops under `/usr/share/vulkan/icd.d`. Reading those is enough to answer
7
//! "can this host run a Vulkan build, and to which API version", which is all
8
//! a consumer selecting a prebuilt artifact needs.
9
//!
10
//! What a manifest yields is that driver's *advertised* version, and `host()`
11
//! reports the highest across them. That is neither the loader's instance
12
//! version — the number `vulkaninfo` prints, usually the higher of the two —
13
//! nor any single device's `apiVersion`; both of those require creating an
14
//! instance and calling into the loader. Staying on the filesystem is the
15
//! trade this module makes, and the reason a caller must not read the result
16
//! as a per-GPU capability.
17
//!
18
//! Unlike CUDA and `ROCm` there is no architecture to report: SPIR-V is
19
//! portable and the driver compiles it at load time, so a Vulkan build that
20
//! runs anywhere runs everywhere the loader does.
21
22
use crate::{VulkanHost, VulkanVersion};
23
use std::path::Path;
24
25
/// Where the loader looks for driver manifests. Every manifest under both
26
/// directories is read; `host()` takes the highest `api_version` across the
27
/// union, so this order does not affect which version wins.
28
const ICD_DIRS: [&str; 2] = ["/usr/local/share/vulkan/icd.d", "/usr/share/vulkan/icd.d"];
29
30
/// Loader sonames to probe. Checked with `.any()`, so this is an unordered
31
/// set, not a preference list.
32
const LOADER_SONAMES: [&str; 2] = ["libvulkan.so.1", "libvulkan.so"];
33
34
/// Directories the loader is normally installed into.
35
const LIB_DIRS: [&str; 4] = [
36
    "/usr/lib/x86_64-linux-gnu",
37
    "/usr/lib64",
38
    "/usr/lib",
39
    "/usr/local/lib",
40
];
41
42
/// Pull `ICD.api_version` out of a driver manifest.
43
///
44
/// The manifest is small, fixed-shape JSON, so this reads the one field it
45
/// needs rather than modelling the whole document.
46
28
fn parse_icd_api_version(content: &str) -> Option<VulkanVersion> {
47
28
    let 
value24
:
serde_json::Value24
= serde_json::from_str(content).ok()
?4
;
48
24
    let 
text17
= value.get("ICD")
?2
.
get22
("api_version")
?4
.
as_str18
()
?1
;
49
17
    let (
major13
,
minor13
,
patch13
) = crate::parse_dotted_version(text)
?4
;
50
13
    Some(VulkanVersion {
51
13
        major,
52
13
        minor,
53
13
        patch,
54
13
    })
55
28
}
56
57
/// Whether the Vulkan loader is installed under any of `dirs`. Presence of the
58
/// shared object is the signal; nothing is opened or linked.
59
///
60
/// Only the sonames a program is linked against count: a bare
61
/// `libvulkan.so.1.3.280` with no `libvulkan.so.1` beside it is a file the
62
/// loader could not be found by, so it is not an install.
63
///
64
/// Takes its directories rather than reading `LIB_DIRS` directly, the way the
65
/// `ROCm` probe takes an install root, so the search is testable against a
66
/// fixture instead of whatever the host happens to have.
67
228
fn loader_in<P: AsRef<Path>>(dirs: &[P]) -> bool {
68
228
    dirs.iter()
69
235
        .
flat_map228
(|dir|
LOADER_SONAMES228
.
iter228
().
map228
(move |so| dir.as_ref().join(so)))
70
235
        .
any228
(|path| path.exists())
71
228
}
72
73
/// The highest API version any manifest under `dirs` advertises.
74
///
75
/// Highest rather than lowest: a host with both a software rasterizer and a
76
/// real driver can run what the real driver supports, and the consumer is
77
/// choosing one build for the machine.
78
///
79
/// Unreadable directories, non-`.json` files, and manifests that do not parse
80
/// are skipped rather than failing the probe — a stray file in `icd.d` must
81
/// not hide a working driver's manifest.
82
226
fn highest_api_version<P: AsRef<Path>>(dirs: &[P]) -> Option<VulkanVersion> {
83
226
    dirs.iter()
84
443
        .
filter_map226
(|dir| std::fs::read_dir(dir).ok())
85
226
        .flatten()
86
226
        .flatten()
87
226
        .filter(|entry| 
entry.path().extension()16
.
is_some_and16
(|e|
e15
==
"json"15
))
88
226
        .filter_map(|entry| 
std::fs::read_to_string12
(
entry12
.
path12
()).
ok12
())
89
226
        .filter_map(|content| 
parse_icd_api_version12
(
&content12
))
90
226
        .max()
91
226
}
92
93
/// The runtime installed under `lib_dirs`/`icd_dirs`, if both halves are there.
94
///
95
/// Both are required: the loader alone cannot name a version, and manifests
96
/// alone describe drivers nothing can dispatch to.
97
219
fn host_in<L: AsRef<Path>, I: AsRef<Path>>(lib_dirs: &[L], icd_dirs: &[I]) -> Option<VulkanHost> {
98
219
    if !loader_in(lib_dirs) {
99
1
        return None;
100
218
    }
101
218
    let 
api_version1
= highest_api_version(icd_dirs)
?217
;
102
1
    Some(VulkanHost { api_version })
103
219
}
104
105
/// Probe the conventional locations.
106
///
107
/// Unconditional, like the `ROCm` and `oneAPI` probes: the paths this checks
108
/// are Linux-specific and simply do not exist on other platforms, so the
109
/// lookups come back empty there without needing a `cfg`.
110
216
pub(crate) fn host() -> Option<VulkanHost> {
111
216
    host_in(&LIB_DIRS, &ICD_DIRS)
112
216
}
113
114
#[cfg(test)]
115
mod tests {
116
    use super::*;
117
    use std::path::PathBuf;
118
    use std::sync::atomic::{AtomicUsize, Ordering};
119
120
    /// A real `radeon_icd.x86_64.json`, trimmed to the fields read here.
121
    const RADEON: &str = r#"{"file_format_version":"1.0.0",
122
                             "ICD":{"library_path":"/usr/lib/libvulkan_radeon.so",
123
                                    "api_version":"1.3.280"}}"#;
124
125
    /// Mesa's software rasterizer, which ships by default on many distros and
126
    /// is an ICD like any other — the case the `README` warns consumers about.
127
    const LAVAPIPE: &str = r#"{"file_format_version":"1.0.1",
128
                               "ICD":{"library_path":"/usr/lib/libvulkan_lvp.so",
129
                                      "api_version":"1.3.255"}}"#;
130
131
    /// A scratch directory that deletes itself on drop.
132
    ///
133
    /// Written here rather than pulled in as a dev-dependency: this probe is a
134
    /// filesystem read, so testing the search means giving it real directories,
135
    /// and that needs nothing beyond `std`.
136
    struct TempTree(PathBuf);
137
138
    impl TempTree {
139
        /// A fresh empty directory. The pid plus a counter keeps concurrent
140
        /// tests — and concurrent `cargo test` runs — off each other's fixtures.
141
15
        fn new(label: &str) -> Self {
142
            static COUNTER: AtomicUsize = AtomicUsize::new(0);
143
15
            let nth = COUNTER.fetch_add(1, Ordering::Relaxed);
144
15
            let path = std::env::temp_dir().join(format!(
145
15
                "gpu-probe-vulkan-{}-{label}-{nth}",
146
15
                std::process::id()
147
15
            ));
148
15
            let _ = std::fs::remove_dir_all(&path);
149
15
            std::fs::create_dir_all(&path).expect("scratch directory is creatable");
150
15
            Self(path)
151
15
        }
152
153
        /// Drop a file into the tree; chainable, so a fixture reads as a listing.
154
19
        fn with(&self, name: &str, content: &str) -> &Self {
155
19
            std::fs::write(self.0.join(name), content).expect("fixture file is writable");
156
19
            self
157
19
        }
158
159
23
        fn path(&self) -> &Path {
160
23
            &self.0
161
23
        }
162
    }
163
164
    impl Drop for TempTree {
165
15
        fn drop(&mut self) {
166
15
            let _ = std::fs::remove_dir_all(&self.0);
167
15
        }
168
    }
169
170
    #[test]
171
1
    fn parses_an_icd_manifest_api_version() {
172
1
        let icd = r#"{"file_format_version":"1.0.0",
173
1
                      "ICD":{"library_path":"libvulkan_radeon.so",
174
1
                             "api_version":"1.3.280"}}"#;
175
1
        assert_eq!(
176
1
            parse_icd_api_version(icd),
177
1
            Some(VulkanVersion::new(1, 3, 280))
178
        );
179
1
        assert_eq!(
180
1
            parse_icd_api_version(LAVAPIPE),
181
1
            Some(VulkanVersion::new(1, 3, 255))
182
        );
183
        // NVIDIA's manifest carries extra top-level keys; unknown fields are
184
        // ignored rather than rejected.
185
1
        let nvidia = r#"{"file_format_version":"1.0.1",
186
1
                         "ICD":{"library_path":"libGLX_nvidia.so.0",
187
1
                                "api_version":"1.3.277",
188
1
                                "is_portability_driver":false}}"#;
189
1
        assert_eq!(
190
1
            parse_icd_api_version(nvidia),
191
1
            Some(VulkanVersion::new(1, 3, 277))
192
        );
193
1
    }
194
195
    #[test]
196
1
    fn rejects_an_icd_manifest_without_an_api_version() {
197
1
        let icd = r#"{"ICD":{"library_path":"libvulkan_radeon.so"}}"#;
198
1
        assert_eq!(parse_icd_api_version(icd), None);
199
1
        assert_eq!(parse_icd_api_version("not json"), None);
200
1
        assert_eq!(parse_icd_api_version(""), None);
201
1
        assert_eq!(parse_icd_api_version("{}"), None);
202
        // The version must be under `ICD`, and must be a string: a JSON number
203
        // would parse as 1.3 and silently lose the patch.
204
1
        assert_eq!(parse_icd_api_version(r#"{"api_version":"1.3.280"}"#), None);
205
1
        assert_eq!(
206
1
            parse_icd_api_version(r#"{"ICD":{"api_version":1.3}}"#),
207
            None
208
        );
209
1
        assert_eq!(parse_icd_api_version(r#"{"ICD":"1.3.280"}"#), None);
210
1
        assert_eq!(
211
1
            parse_icd_api_version(r#"{"ICD":[{"api_version":"1.3.0"}]}"#),
212
            None
213
        );
214
1
    }
215
216
    #[test]
217
1
    fn rejects_manifests_whose_version_is_not_a_version() {
218
5
        let 
with1
= |version: &str| {
219
5
            parse_icd_api_version(&format!(r#"{{"ICD":{{"api_version":"{version}"}}}}"#))
220
5
        };
221
        // A two-part version is a legal shape for the shared parser; the spec
222
        // writes all three, but a truncated one still names an API level.
223
1
        assert_eq!(with("1.2"), Some(VulkanVersion::new(1, 2, 0)));
224
1
        assert_eq!(with("1"), None, "a bare major is not a version");
225
1
        assert_eq!(with(""), None);
226
1
        assert_eq!(with("one.three.zero"), None);
227
1
        assert_eq!(with("1.3.x"), None);
228
1
    }
229
230
    #[test]
231
1
    fn versions_order_major_first() {
232
1
        assert!(VulkanVersion::new(1, 3, 0) > VulkanVersion::new(1, 2, 300));
233
1
        assert!(VulkanVersion::new(2, 0, 0) > VulkanVersion::new(1, 9, 9));
234
1
    }
235
236
    #[test]
237
1
    fn loader_is_found_under_either_soname() {
238
1
        let versioned = TempTree::new("loader-soname-1");
239
1
        versioned.with("libvulkan.so.1", "");
240
1
        assert!(loader_in(&[versioned.path()]));
241
242
        // The development symlink alone is enough; the set is unordered.
243
1
        let unversioned = TempTree::new("loader-soname-dev");
244
1
        unversioned.with("libvulkan.so", "");
245
1
        assert!(loader_in(&[unversioned.path()]));
246
247
        // Any one directory in the list satisfies the search.
248
1
        let empty = TempTree::new("loader-empty-first");
249
1
        assert!(loader_in(&[empty.path(), versioned.path()]));
250
1
    }
251
252
    #[test]
253
1
    fn a_host_without_the_loader_reports_no_install() {
254
1
        let empty = TempTree::new("loader-absent");
255
1
        assert!(!loader_in(&[empty.path()]));
256
1
        assert!(!loader_in(&[Path::new("/nonexistent-vulkan-libdir")]));
257
1
        assert!(!loader_in::<&Path>(&[]));
258
259
        // The real file with no soname symlink beside it: nothing can dlopen
260
        // `libvulkan.so.1` here, so this is not an installed loader.
261
1
        let unlinked = TempTree::new("loader-unlinked");
262
1
        unlinked.with("libvulkan.so.1.3.280", "");
263
1
        assert!(!loader_in(&[unlinked.path()]));
264
1
    }
265
266
    #[cfg(unix)]
267
    #[test]
268
1
    fn a_dangling_loader_symlink_is_not_an_install() {
269
        // A package removed without its symlink leaves the name behind. The
270
        // check follows the link, so the broken one does not count.
271
1
        let tree = TempTree::new("loader-dangling");
272
1
        std::os::unix::fs::symlink("libvulkan.so.1.3.280", tree.path().join("libvulkan.so.1"))
273
1
            .expect("symlink is creatable");
274
1
        assert!(!loader_in(&[tree.path()]));
275
276
1
        tree.with("libvulkan.so.1.3.280", "");
277
1
        assert!(loader_in(&[tree.path()]), "the same link now resolves");
278
1
    }
279
280
    #[test]
281
1
    fn highest_api_version_wins_across_manifests() {
282
        // A machine with the software rasterizer installed alongside a real
283
        // driver: the real driver's level is the one a build can target.
284
1
        let icd = TempTree::new("icd-highest");
285
1
        icd.with("lvp_icd.x86_64.json", LAVAPIPE)
286
1
            .with("radeon_icd.x86_64.json", RADEON);
287
1
        assert_eq!(
288
1
            highest_api_version(&[icd.path()]),
289
1
            Some(VulkanVersion::new(1, 3, 280))
290
        );
291
292
        // The two search directories are a union, not a preference order, so
293
        // the highest wins whichever side it sits on.
294
1
        let local = TempTree::new("icd-local");
295
1
        local.with("lvp_icd.x86_64.json", LAVAPIPE);
296
1
        let shared = TempTree::new("icd-shared");
297
1
        shared.with("radeon_icd.x86_64.json", RADEON);
298
1
        assert_eq!(
299
1
            highest_api_version(&[local.path(), shared.path()]),
300
1
            Some(VulkanVersion::new(1, 3, 280))
301
        );
302
1
        assert_eq!(
303
1
            highest_api_version(&[shared.path(), local.path()]),
304
1
            Some(VulkanVersion::new(1, 3, 280)),
305
            "directory order must not change the answer",
306
        );
307
1
    }
308
309
    #[test]
310
1
    fn only_json_manifests_are_read() {
311
        // `icd.d` collects editor backups and disabled drivers; the loader
312
        // reads `.json` and so does this.
313
1
        let icd = TempTree::new("icd-extensions");
314
1
        icd.with("lvp_icd.x86_64.json", LAVAPIPE)
315
1
            .with("radeon_icd.x86_64.json.disabled", RADEON)
316
1
            .with("radeon_icd.x86_64.json.bak", RADEON)
317
1
            .with("notes.txt", RADEON)
318
1
            .with("README", RADEON);
319
1
        assert_eq!(
320
1
            highest_api_version(&[icd.path()]),
321
1
            Some(VulkanVersion::new(1, 3, 255)),
322
            "only the .json manifest counts, so lavapipe's version stands",
323
        );
324
1
    }
325
326
    #[test]
327
1
    fn a_malformed_manifest_does_not_hide_a_good_one() {
328
1
        let icd = TempTree::new("icd-malformed");
329
1
        icd.with("broken_icd.json", "{ not json")
330
1
            .with("empty_icd.json", "")
331
1
            .with("versionless_icd.json", r#"{"ICD":{"library_path":"x.so"}}"#)
332
1
            .with("radeon_icd.x86_64.json", RADEON);
333
1
        assert_eq!(
334
1
            highest_api_version(&[icd.path()]),
335
1
            Some(VulkanVersion::new(1, 3, 280))
336
        );
337
1
    }
338
339
    #[test]
340
1
    fn missing_or_empty_icd_directories_report_nothing() {
341
1
        let empty = TempTree::new("icd-empty");
342
1
        assert_eq!(highest_api_version(&[empty.path()]), None);
343
1
        assert_eq!(
344
1
            highest_api_version(&[Path::new("/nonexistent-vulkan-icd-dir")]),
345
            None,
346
            "an absent directory is skipped, not an error",
347
        );
348
1
        assert_eq!(highest_api_version::<&Path>(&[]), None);
349
1
    }
350
351
    #[test]
352
1
    fn both_halves_are_required() {
353
1
        let lib = TempTree::new("host-lib");
354
1
        lib.with("libvulkan.so.1", "");
355
1
        let icd = TempTree::new("host-icd");
356
1
        icd.with("radeon_icd.x86_64.json", RADEON);
357
1
        let empty = TempTree::new("host-empty");
358
359
1
        assert_eq!(
360
1
            host_in(&[lib.path()], &[icd.path()]),
361
1
            Some(VulkanHost {
362
1
                api_version: VulkanVersion::new(1, 3, 280)
363
1
            })
364
        );
365
1
        assert_eq!(
366
1
            host_in(&[empty.path()], &[icd.path()]),
367
            None,
368
            "manifests describe drivers nothing can dispatch to without a loader",
369
        );
370
1
        assert_eq!(
371
1
            host_in(&[lib.path()], &[empty.path()]),
372
            None,
373
            "a loader with no ICD advertises no version to report",
374
        );
375
1
    }
376
377
    #[test]
378
1
    fn host_lookup_never_panics() {
379
        // Environment-dependent: asserts invariants, not the presence of a driver.
380
1
        if let Some(
v0
) = host() {
381
0
            assert!(v.api_version.major > 0);
382
1
        }
383
1
    }
384
}