@@ -46,43 +46,118 @@ fn parse_icd_api_version(content: &str) -> Option<VulkanVersion> {
4646 } )
4747}
4848
49- /// Whether the Vulkan loader is installed. Presence of the shared object is
50- /// the signal; nothing is opened or linked.
49+ /// Whether the Vulkan loader is installed under any of `dirs`. Presence of the
50+ /// shared object is the signal; nothing is opened or linked.
5151///
52- /// Unconditional, like the `ROCm` and `oneAPI` probes: the paths this checks
53- /// are Linux-specific and simply do not exist on other platforms, so
54- /// `.exists()` is `false` there without needing a `cfg`.
55- fn loader_present ( ) -> bool {
56- LIB_DIRS
57- . iter ( )
58- . flat_map ( |dir| LOADER_SONAMES . iter ( ) . map ( move |so| Path :: new ( dir) . join ( so) ) )
52+ /// Only the sonames a program is linked against count: a bare
53+ /// `libvulkan.so.1.3.280` with no `libvulkan.so.1` beside it is a file the
54+ /// loader could not be found by, so it is not an install.
55+ ///
56+ /// Takes its directories rather than reading `LIB_DIRS` directly, the way the
57+ /// `ROCm` probe takes an install root, so the search is testable against a
58+ /// fixture instead of whatever the host happens to have.
59+ fn loader_in < P : AsRef < Path > > ( dirs : & [ P ] ) -> bool {
60+ dirs. iter ( )
61+ . flat_map ( |dir| LOADER_SONAMES . iter ( ) . map ( move |so| dir. as_ref ( ) . join ( so) ) )
5962 . any ( |path| path. exists ( ) )
6063}
6164
62- /// The highest API version any installed ICD advertises.
65+ /// The highest API version any manifest under `dirs` advertises.
6366///
6467/// Highest rather than lowest: a host with both a software rasterizer and a
6568/// real driver can run what the real driver supports, and the consumer is
6669/// choosing one build for the machine.
67- pub ( crate ) fn host ( ) -> Option < VulkanHost > {
68- if ! loader_present ( ) {
69- return None ;
70- }
71- let api_version = ICD_DIRS
72- . iter ( )
70+ ///
71+ /// Unreadable directories, non-`.json` files, and manifests that do not parse
72+ /// are skipped rather than failing the probe — a stray file in `icd.d` must
73+ /// not hide a working driver's manifest.
74+ fn highest_api_version < P : AsRef < Path > > ( dirs : & [ P ] ) -> Option < VulkanVersion > {
75+ dirs . iter ( )
7376 . filter_map ( |dir| std:: fs:: read_dir ( dir) . ok ( ) )
7477 . flatten ( )
7578 . flatten ( )
7679 . filter ( |entry| entry. path ( ) . extension ( ) . is_some_and ( |e| e == "json" ) )
7780 . filter_map ( |entry| std:: fs:: read_to_string ( entry. path ( ) ) . ok ( ) )
7881 . filter_map ( |content| parse_icd_api_version ( & content) )
79- . max ( ) ?;
82+ . max ( )
83+ }
84+
85+ /// The runtime installed under `lib_dirs`/`icd_dirs`, if both halves are there.
86+ ///
87+ /// Both are required: the loader alone cannot name a version, and manifests
88+ /// alone describe drivers nothing can dispatch to.
89+ fn host_in < L : AsRef < Path > , I : AsRef < Path > > ( lib_dirs : & [ L ] , icd_dirs : & [ I ] ) -> Option < VulkanHost > {
90+ if !loader_in ( lib_dirs) {
91+ return None ;
92+ }
93+ let api_version = highest_api_version ( icd_dirs) ?;
8094 Some ( VulkanHost { api_version } )
8195}
8296
97+ /// Probe the conventional locations.
98+ ///
99+ /// Unconditional, like the `ROCm` and `oneAPI` probes: the paths this checks
100+ /// are Linux-specific and simply do not exist on other platforms, so the
101+ /// lookups come back empty there without needing a `cfg`.
102+ pub ( crate ) fn host ( ) -> Option < VulkanHost > {
103+ host_in ( & LIB_DIRS , & ICD_DIRS )
104+ }
105+
83106#[ cfg( test) ]
84107mod tests {
85108 use super :: * ;
109+ use std:: path:: PathBuf ;
110+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
111+
112+ /// A real `radeon_icd.x86_64.json`, trimmed to the fields read here.
113+ const RADEON : & str = r#"{"file_format_version":"1.0.0",
114+ "ICD":{"library_path":"/usr/lib/libvulkan_radeon.so",
115+ "api_version":"1.3.280"}}"# ;
116+
117+ /// Mesa's software rasterizer, which ships by default on many distros and
118+ /// is an ICD like any other — the case the `README` warns consumers about.
119+ const LAVAPIPE : & str = r#"{"file_format_version":"1.0.1",
120+ "ICD":{"library_path":"/usr/lib/libvulkan_lvp.so",
121+ "api_version":"1.3.255"}}"# ;
122+
123+ /// A scratch directory that deletes itself on drop.
124+ ///
125+ /// Written here rather than pulled in as a dev-dependency: this probe is a
126+ /// filesystem read, so testing the search means giving it real directories,
127+ /// and that needs nothing beyond `std`.
128+ struct TempTree ( PathBuf ) ;
129+
130+ impl TempTree {
131+ /// A fresh empty directory. The pid plus a counter keeps concurrent
132+ /// tests — and concurrent `cargo test` runs — off each other's fixtures.
133+ fn new ( label : & str ) -> Self {
134+ static COUNTER : AtomicUsize = AtomicUsize :: new ( 0 ) ;
135+ let nth = COUNTER . fetch_add ( 1 , Ordering :: Relaxed ) ;
136+ let path = std:: env:: temp_dir ( ) . join ( format ! (
137+ "gpu-probe-vulkan-{}-{label}-{nth}" ,
138+ std:: process:: id( )
139+ ) ) ;
140+ let _ = std:: fs:: remove_dir_all ( & path) ;
141+ std:: fs:: create_dir_all ( & path) . expect ( "scratch directory is creatable" ) ;
142+ Self ( path)
143+ }
144+
145+ /// Drop a file into the tree; chainable, so a fixture reads as a listing.
146+ fn with ( & self , name : & str , content : & str ) -> & Self {
147+ std:: fs:: write ( self . 0 . join ( name) , content) . expect ( "fixture file is writable" ) ;
148+ self
149+ }
150+
151+ fn path ( & self ) -> & Path {
152+ & self . 0
153+ }
154+ }
155+
156+ impl Drop for TempTree {
157+ fn drop ( & mut self ) {
158+ let _ = std:: fs:: remove_dir_all ( & self . 0 ) ;
159+ }
160+ }
86161
87162 #[ test]
88163 fn parses_an_icd_manifest_api_version ( ) {
@@ -93,13 +168,55 @@ mod tests {
93168 parse_icd_api_version( icd) ,
94169 Some ( VulkanVersion :: new( 1 , 3 , 280 ) )
95170 ) ;
171+ assert_eq ! (
172+ parse_icd_api_version( LAVAPIPE ) ,
173+ Some ( VulkanVersion :: new( 1 , 3 , 255 ) )
174+ ) ;
175+ // NVIDIA's manifest carries extra top-level keys; unknown fields are
176+ // ignored rather than rejected.
177+ let nvidia = r#"{"file_format_version":"1.0.1",
178+ "ICD":{"library_path":"libGLX_nvidia.so.0",
179+ "api_version":"1.3.277",
180+ "is_portability_driver":false}}"# ;
181+ assert_eq ! (
182+ parse_icd_api_version( nvidia) ,
183+ Some ( VulkanVersion :: new( 1 , 3 , 277 ) )
184+ ) ;
96185 }
97186
98187 #[ test]
99188 fn rejects_an_icd_manifest_without_an_api_version ( ) {
100189 let icd = r#"{"ICD":{"library_path":"libvulkan_radeon.so"}}"# ;
101190 assert_eq ! ( parse_icd_api_version( icd) , None ) ;
102191 assert_eq ! ( parse_icd_api_version( "not json" ) , None ) ;
192+ assert_eq ! ( parse_icd_api_version( "" ) , None ) ;
193+ assert_eq ! ( parse_icd_api_version( "{}" ) , None ) ;
194+ // The version must be under `ICD`, and must be a string: a JSON number
195+ // would parse as 1.3 and silently lose the patch.
196+ assert_eq ! ( parse_icd_api_version( r#"{"api_version":"1.3.280"}"# ) , None ) ;
197+ assert_eq ! (
198+ parse_icd_api_version( r#"{"ICD":{"api_version":1.3}}"# ) ,
199+ None
200+ ) ;
201+ assert_eq ! ( parse_icd_api_version( r#"{"ICD":"1.3.280"}"# ) , None ) ;
202+ assert_eq ! (
203+ parse_icd_api_version( r#"{"ICD":[{"api_version":"1.3.0"}]}"# ) ,
204+ None
205+ ) ;
206+ }
207+
208+ #[ test]
209+ fn rejects_manifests_whose_version_is_not_a_version ( ) {
210+ let with = |version : & str | {
211+ parse_icd_api_version ( & format ! ( r#"{{"ICD":{{"api_version":"{version}"}}}}"# ) )
212+ } ;
213+ // A two-part version is a legal shape for the shared parser; the spec
214+ // writes all three, but a truncated one still names an API level.
215+ assert_eq ! ( with( "1.2" ) , Some ( VulkanVersion :: new( 1 , 2 , 0 ) ) ) ;
216+ assert_eq ! ( with( "1" ) , None , "a bare major is not a version" ) ;
217+ assert_eq ! ( with( "" ) , None ) ;
218+ assert_eq ! ( with( "one.three.zero" ) , None ) ;
219+ assert_eq ! ( with( "1.3.x" ) , None ) ;
103220 }
104221
105222 #[ test]
@@ -108,6 +225,147 @@ mod tests {
108225 assert ! ( VulkanVersion :: new( 2 , 0 , 0 ) > VulkanVersion :: new( 1 , 9 , 9 ) ) ;
109226 }
110227
228+ #[ test]
229+ fn loader_is_found_under_either_soname ( ) {
230+ let versioned = TempTree :: new ( "loader-soname-1" ) ;
231+ versioned. with ( "libvulkan.so.1" , "" ) ;
232+ assert ! ( loader_in( & [ versioned. path( ) ] ) ) ;
233+
234+ // The development symlink alone is enough; the set is unordered.
235+ let unversioned = TempTree :: new ( "loader-soname-dev" ) ;
236+ unversioned. with ( "libvulkan.so" , "" ) ;
237+ assert ! ( loader_in( & [ unversioned. path( ) ] ) ) ;
238+
239+ // Any one directory in the list satisfies the search.
240+ let empty = TempTree :: new ( "loader-empty-first" ) ;
241+ assert ! ( loader_in( & [ empty. path( ) , versioned. path( ) ] ) ) ;
242+ }
243+
244+ #[ test]
245+ fn a_host_without_the_loader_reports_no_install ( ) {
246+ let empty = TempTree :: new ( "loader-absent" ) ;
247+ assert ! ( !loader_in( & [ empty. path( ) ] ) ) ;
248+ assert ! ( !loader_in( & [ Path :: new( "/nonexistent-vulkan-libdir" ) ] ) ) ;
249+ assert ! ( !loader_in:: <& Path >( & [ ] ) ) ;
250+
251+ // The real file with no soname symlink beside it: nothing can dlopen
252+ // `libvulkan.so.1` here, so this is not an installed loader.
253+ let unlinked = TempTree :: new ( "loader-unlinked" ) ;
254+ unlinked. with ( "libvulkan.so.1.3.280" , "" ) ;
255+ assert ! ( !loader_in( & [ unlinked. path( ) ] ) ) ;
256+ }
257+
258+ #[ cfg( unix) ]
259+ #[ test]
260+ fn a_dangling_loader_symlink_is_not_an_install ( ) {
261+ // A package removed without its symlink leaves the name behind. The
262+ // check follows the link, so the broken one does not count.
263+ let tree = TempTree :: new ( "loader-dangling" ) ;
264+ std:: os:: unix:: fs:: symlink ( "libvulkan.so.1.3.280" , tree. path ( ) . join ( "libvulkan.so.1" ) )
265+ . expect ( "symlink is creatable" ) ;
266+ assert ! ( !loader_in( & [ tree. path( ) ] ) ) ;
267+
268+ tree. with ( "libvulkan.so.1.3.280" , "" ) ;
269+ assert ! ( loader_in( & [ tree. path( ) ] ) , "the same link now resolves" ) ;
270+ }
271+
272+ #[ test]
273+ fn highest_api_version_wins_across_manifests ( ) {
274+ // A machine with the software rasterizer installed alongside a real
275+ // driver: the real driver's level is the one a build can target.
276+ let icd = TempTree :: new ( "icd-highest" ) ;
277+ icd. with ( "lvp_icd.x86_64.json" , LAVAPIPE )
278+ . with ( "radeon_icd.x86_64.json" , RADEON ) ;
279+ assert_eq ! (
280+ highest_api_version( & [ icd. path( ) ] ) ,
281+ Some ( VulkanVersion :: new( 1 , 3 , 280 ) )
282+ ) ;
283+
284+ // The two search directories are a union, not a preference order, so
285+ // the highest wins whichever side it sits on.
286+ let local = TempTree :: new ( "icd-local" ) ;
287+ local. with ( "lvp_icd.x86_64.json" , LAVAPIPE ) ;
288+ let shared = TempTree :: new ( "icd-shared" ) ;
289+ shared. with ( "radeon_icd.x86_64.json" , RADEON ) ;
290+ assert_eq ! (
291+ highest_api_version( & [ local. path( ) , shared. path( ) ] ) ,
292+ Some ( VulkanVersion :: new( 1 , 3 , 280 ) )
293+ ) ;
294+ assert_eq ! (
295+ highest_api_version( & [ shared. path( ) , local. path( ) ] ) ,
296+ Some ( VulkanVersion :: new( 1 , 3 , 280 ) ) ,
297+ "directory order must not change the answer" ,
298+ ) ;
299+ }
300+
301+ #[ test]
302+ fn only_json_manifests_are_read ( ) {
303+ // `icd.d` collects editor backups and disabled drivers; the loader
304+ // reads `.json` and so does this.
305+ let icd = TempTree :: new ( "icd-extensions" ) ;
306+ icd. with ( "lvp_icd.x86_64.json" , LAVAPIPE )
307+ . with ( "radeon_icd.x86_64.json.disabled" , RADEON )
308+ . with ( "radeon_icd.x86_64.json.bak" , RADEON )
309+ . with ( "notes.txt" , RADEON )
310+ . with ( "README" , RADEON ) ;
311+ assert_eq ! (
312+ highest_api_version( & [ icd. path( ) ] ) ,
313+ Some ( VulkanVersion :: new( 1 , 3 , 255 ) ) ,
314+ "only the .json manifest counts, so lavapipe's version stands" ,
315+ ) ;
316+ }
317+
318+ #[ test]
319+ fn a_malformed_manifest_does_not_hide_a_good_one ( ) {
320+ let icd = TempTree :: new ( "icd-malformed" ) ;
321+ icd. with ( "broken_icd.json" , "{ not json" )
322+ . with ( "empty_icd.json" , "" )
323+ . with ( "versionless_icd.json" , r#"{"ICD":{"library_path":"x.so"}}"# )
324+ . with ( "radeon_icd.x86_64.json" , RADEON ) ;
325+ assert_eq ! (
326+ highest_api_version( & [ icd. path( ) ] ) ,
327+ Some ( VulkanVersion :: new( 1 , 3 , 280 ) )
328+ ) ;
329+ }
330+
331+ #[ test]
332+ fn missing_or_empty_icd_directories_report_nothing ( ) {
333+ let empty = TempTree :: new ( "icd-empty" ) ;
334+ assert_eq ! ( highest_api_version( & [ empty. path( ) ] ) , None ) ;
335+ assert_eq ! (
336+ highest_api_version( & [ Path :: new( "/nonexistent-vulkan-icd-dir" ) ] ) ,
337+ None ,
338+ "an absent directory is skipped, not an error" ,
339+ ) ;
340+ assert_eq ! ( highest_api_version:: <& Path >( & [ ] ) , None ) ;
341+ }
342+
343+ #[ test]
344+ fn both_halves_are_required ( ) {
345+ let lib = TempTree :: new ( "host-lib" ) ;
346+ lib. with ( "libvulkan.so.1" , "" ) ;
347+ let icd = TempTree :: new ( "host-icd" ) ;
348+ icd. with ( "radeon_icd.x86_64.json" , RADEON ) ;
349+ let empty = TempTree :: new ( "host-empty" ) ;
350+
351+ assert_eq ! (
352+ host_in( & [ lib. path( ) ] , & [ icd. path( ) ] ) ,
353+ Some ( VulkanHost {
354+ api_version: VulkanVersion :: new( 1 , 3 , 280 )
355+ } )
356+ ) ;
357+ assert_eq ! (
358+ host_in( & [ empty. path( ) ] , & [ icd. path( ) ] ) ,
359+ None ,
360+ "manifests describe drivers nothing can dispatch to without a loader" ,
361+ ) ;
362+ assert_eq ! (
363+ host_in( & [ lib. path( ) ] , & [ empty. path( ) ] ) ,
364+ None ,
365+ "a loader with no ICD advertises no version to report" ,
366+ ) ;
367+ }
368+
111369 #[ test]
112370 fn host_lookup_never_panics ( ) {
113371 // Environment-dependent: asserts invariants, not the presence of a driver.
0 commit comments