Skip to main content

elfpak_core/resolver/
mod.rs

1//! The dynamic linker resolver.
2//!
3//! This models the glibc loader's search algorithm rather than looking for
4//! matching filenames. The target binary is never executed and `ldd` is never
5//! called.
6
7pub mod cache;
8pub mod search;
9pub mod tokens;
10
11use crate::{
12    elf::{Architecture, ElfMetadata, ObjectType},
13    error::{Error, Result},
14    graph::{DependencyGraph, DependencyReason, Node, NodeId, NodeKind},
15    hash::DigestCache,
16    paths::{logical_parent, normalize_absolute},
17    source::{ElfCache, EntryKind, Resolved, SourceRoot},
18};
19pub use cache::LdCache;
20use std::path::{Path, PathBuf};
21pub use tokens::TokenContext;
22
23/// A single `DT_NEEDED` lookup, with all loader state it depends on.
24#[derive(Debug, Clone)]
25pub struct LibraryRequest {
26    pub soname: String,
27    /// Logical path of the object that needs the library.
28    pub requester: PathBuf,
29    /// Expanded `DT_RPATH` lists of the requester and its loaders, nearest
30    /// first. Entries are expanded when their owning object is visited: an
31    /// inherited `$ORIGIN` refers to that owner, not the current requester.
32    pub rpath_chain: Vec<Vec<PathBuf>>,
33    /// `DT_RUNPATH` of the requester (never inherited).
34    pub runpath: Vec<String>,
35    pub nodeflib: bool,
36    pub architecture: Architecture,
37}
38
39#[derive(Debug, Clone)]
40pub struct ResolvedLibrary {
41    pub resolved: Resolved,
42    pub metadata: ElfMetadata,
43}
44
45/// Where a lookup succeeded. Only some of these survive into the bundle:
46/// `--library-path` is a hint to `elfpak` and the packaged application never
47/// sees it.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum SearchOrigin {
50    /// `DT_RPATH`/`DT_RUNPATH` of the requesting object, or an absolute soname.
51    ObjectPath,
52    /// `--library-path`, the `LD_LIBRARY_PATH` equivalent.
53    LibraryPath,
54    /// `/etc/ld.so.cache`.
55    Cache,
56    /// A directory the loader searches without being told to.
57    DefaultDirectory,
58    /// A directory that only `/etc/ld.so.conf` named.
59    ConfiguredDirectory,
60}
61
62/// A library the loader inside the bundle would not find on its own.
63///
64/// `elfpak` never runs `ldconfig`, so a bundle carries no `ld.so.cache`; a
65/// library that was only reachable through the build host's cache, its
66/// `ld.so.conf` or `--library-path` keeps its original path but nothing points
67/// the loader at it any more.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ResolutionNote {
70    pub soname: String,
71    pub directory: PathBuf,
72    pub origin: SearchOrigin,
73}
74
75impl SearchOrigin {
76    pub fn as_str(&self) -> &'static str {
77        match self {
78            SearchOrigin::ObjectPath => "DT_RPATH/DT_RUNPATH",
79            SearchOrigin::LibraryPath => "--library-path",
80            SearchOrigin::Cache => "/etc/ld.so.cache",
81            SearchOrigin::DefaultDirectory => "a default directory",
82            SearchOrigin::ConfiguredDirectory => "/etc/ld.so.conf",
83        }
84    }
85
86    /// Whether the packaged application can still find a library that was
87    /// located this way.
88    fn survives_packaging(&self) -> bool {
89        matches!(
90            self,
91            SearchOrigin::ObjectPath | SearchOrigin::DefaultDirectory
92        )
93    }
94}
95
96/// Loader-specific resolution, kept behind a trait so the implementation can be
97/// replaced (or wrapped for tracing) without touching the planner.
98pub trait DynamicLinkerResolver {
99    fn resolve(&mut self, request: &LibraryRequest) -> Result<ResolvedLibrary>;
100}
101
102/// Upper bound on the directories one lookup may probe.
103///
104/// A request consults the object's own search paths, `--library-path`, the
105/// cache and the default directories; hundreds would mean a pathological
106/// `DT_RPATH`, and an unbounded list would mean an unbounded lookup.
107const SEARCH_DIRECTORIES_MAX: usize = 256;
108
109#[derive(Debug)]
110pub struct Resolver {
111    root: SourceRoot,
112    /// Explicit search paths, equivalent to `LD_LIBRARY_PATH`.
113    library_paths: Vec<PathBuf>,
114    /// Paths configured through `/etc/ld.so.conf`.
115    conf_paths: Vec<PathBuf>,
116    cache: Option<LdCache>,
117    elf: ElfCache,
118    digests: DigestCache,
119    notes: Vec<ResolutionNote>,
120}
121
122impl Resolver {
123    pub fn new(root: SourceRoot) -> Resolver {
124        let cache = root
125            .resolve(Path::new("/etc/ld.so.cache"))
126            .ok()
127            .flatten()
128            .filter(|r| r.kind == EntryKind::File)
129            .and_then(|r| LdCache::load(&r.host));
130        let conf_paths = search::parse_ld_so_conf(&root);
131        Resolver {
132            root,
133            library_paths: Vec::new(),
134            conf_paths,
135            cache,
136            elf: ElfCache::new(),
137            digests: DigestCache::new(),
138            notes: Vec::new(),
139        }
140    }
141
142    pub fn with_library_paths(mut self, paths: Vec<PathBuf>) -> Resolver {
143        self.library_paths = paths.iter().map(|p| normalize_absolute(p)).collect();
144        self
145    }
146
147    pub fn root(&self) -> &SourceRoot {
148        &self.root
149    }
150
151    pub fn ld_cache(&self) -> Option<&LdCache> {
152        self.cache.as_ref()
153    }
154
155    /// Libraries that resolved through something the bundle does not reproduce.
156    pub fn notes(&self) -> &[ResolutionNote] {
157        &self.notes
158    }
159
160    fn note(&mut self, request: &LibraryRequest, directory: &Path, origin: SearchOrigin) {
161        assert!(directory.is_absolute());
162
163        if origin.survives_packaging() {
164            return;
165        }
166        let is_default = search::default_library_paths(&request.architecture)
167            .iter()
168            .any(|default| default == directory);
169        if is_default {
170            // The packaged loader searches this directory anyway, so it does not
171            // matter how the library was found here.
172            return;
173        }
174        let note = ResolutionNote {
175            soname: request.soname.clone(),
176            directory: directory.to_path_buf(),
177            origin,
178        };
179        if !self.notes.contains(&note) {
180            self.notes.push(note);
181        }
182    }
183
184    /// Map a host path to its logical path inside the source root.
185    pub fn logical_of_host(&self, host: &Path) -> PathBuf {
186        let host = std::path::absolute(host).unwrap_or_else(|_| host.to_path_buf());
187        let root = self.root.path();
188        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
189        let host = host.canonicalize().unwrap_or(host);
190        match host.strip_prefix(&root) {
191            Ok(rest) => normalize_absolute(&Path::new("/").join(rest)),
192            Err(_) => normalize_absolute(&host),
193        }
194    }
195
196    /// Build the full runtime closure of an executable.
197    ///
198    /// `binary` is a host path; `install` is where the executable will live in
199    /// the generated rootfs. Every other object keeps its original location.
200    pub fn closure(&mut self, binary: &Path, install: &Path) -> Result<DependencyGraph> {
201        let metadata = ElfMetadata::parse_file(binary)?;
202        if !metadata.architecture.machine.is_supported_target() {
203            return Err(Error::UnsupportedArchitecture {
204                path: binary.to_path_buf(),
205                architecture: metadata.architecture.to_string(),
206                machine: metadata.e_machine,
207            });
208        }
209        let architecture = metadata.architecture;
210        let logical = self.logical_of_host(binary);
211        let mut graph = DependencyGraph::new();
212        graph.declared_interpreter = metadata.interpreter.as_deref().map(normalize_absolute);
213        graph.executable_search_paths = metadata
214            .rpath
215            .iter()
216            .chain(metadata.runpath.iter())
217            .cloned()
218            .collect();
219
220        let (digest, size) = self.digests.get(binary)?;
221        let root_id = graph.insert(Node {
222            source: binary.to_path_buf(),
223            logical: logical.clone(),
224            destination: normalize_absolute(install),
225            kind: NodeKind::Executable,
226            soname: metadata.soname.clone(),
227            architecture,
228            sha256: digest,
229            size,
230            links: Vec::new(),
231            dlopen_references: metadata.dlopen_references.clone(),
232        })?;
233        graph.root = root_id;
234
235        if metadata.interpreter.is_some() {
236            self.attach_interpreter(&mut graph, &metadata, root_id)?;
237        }
238
239        self.walk_needed(&mut graph, root_id, metadata, Vec::new())?;
240
241        Ok(graph)
242    }
243
244    /// `PT_INTERP`: the loader is a hard runtime dependency of the image, and
245    /// the one dependency the kernel resolves rather than the loader.
246    fn attach_interpreter(
247        &mut self,
248        graph: &mut DependencyGraph,
249        metadata: &ElfMetadata,
250        root_id: NodeId,
251    ) -> Result<()> {
252        let interp = metadata
253            .interpreter
254            .as_ref()
255            .expect("only called for an object that declares PT_INTERP");
256        let architecture = metadata.architecture;
257
258        let resolved = self
259            .root
260            .resolve(interp)?
261            .filter(|r| r.kind == EntryKind::File);
262        let Some(resolved) = resolved else {
263            return Err(Error::UnresolvedLibrary {
264                soname: interp.to_string_lossy().into_owned(),
265                required_by: graph.node(root_id).logical.clone(),
266                searched: vec![self.root.host_path(interp)],
267            });
268        };
269
270        let interp_meta = self.elf.require(&resolved.host)?;
271        self.check_architecture(&interp_meta, &architecture, interp, &resolved)?;
272        let id = self.insert_object(graph, &resolved, &interp_meta, NodeKind::Interpreter)?;
273        graph.connect(root_id, id, DependencyReason::Interpreter)?;
274        Ok(())
275    }
276
277    /// Add everything reachable from `start` through `DT_NEEDED`, depth first.
278    ///
279    /// `inherited` is the `DT_RPATH` chain of the objects that loaded `start`,
280    /// nearest first; the loader consults it for every lookup further down the
281    /// chain, which is the whole difference between `DT_RPATH` and `DT_RUNPATH`.
282    fn walk_needed(
283        &mut self,
284        graph: &mut DependencyGraph,
285        start: NodeId,
286        metadata: ElfMetadata,
287        inherited: Vec<Vec<PathBuf>>,
288    ) -> Result<()> {
289        assert!(graph.contains(start));
290
291        let architecture = metadata.architecture;
292        let mut queue = vec![(start, metadata, inherited)];
293        // Only an object that was not already in the graph is queued, so the
294        // walk visits each object once and is bounded by the graph's own limit.
295        while let Some((id, meta, inherited)) = queue.pop() {
296            assert_eq!(meta.architecture, architecture);
297
298            let requester = graph.node(id).logical.clone();
299            let mut chain: Vec<Vec<PathBuf>> = Vec::new();
300            if !meta.runpath_is_authoritative() && !meta.rpath.is_empty() {
301                let ctx = self.token_context(&requester, &architecture);
302                chain.push(
303                    meta.rpath
304                        .iter()
305                        .map(|entry| tokens::expand_search_path(entry, &ctx))
306                        .collect(),
307                );
308            }
309            chain.extend(inherited);
310            for soname in &meta.needed {
311                let request = LibraryRequest {
312                    soname: soname.clone(),
313                    requester: requester.clone(),
314                    rpath_chain: chain.clone(),
315                    runpath: meta.runpath.clone(),
316                    nodeflib: meta.nodeflib,
317                    architecture,
318                };
319                let library = self.resolve(&request)?;
320                let known = graph.find(&library.resolved.logical);
321                let child = self.insert_object(
322                    graph,
323                    &library.resolved,
324                    &library.metadata,
325                    NodeKind::SharedObject,
326                )?;
327                graph.connect(
328                    id,
329                    child,
330                    DependencyReason::Needed {
331                        soname: soname.clone(),
332                    },
333                )?;
334                if known.is_none() {
335                    queue.push((child, library.metadata, chain.clone()));
336                }
337            }
338        }
339        Ok(())
340    }
341
342    /// Resolve a soname that is already known to be a library the policy wants,
343    /// e.g. NSS modules pulled in by runtime policy rather than by `DT_NEEDED`.
344    pub fn resolve_extra_library(
345        &mut self,
346        soname: &str,
347        architecture: Architecture,
348        requester: &Path,
349    ) -> Result<Option<ResolvedLibrary>> {
350        let request = LibraryRequest {
351            soname: soname.to_string(),
352            requester: requester.to_path_buf(),
353            rpath_chain: Vec::new(),
354            runpath: Vec::new(),
355            nodeflib: false,
356            architecture,
357        };
358        match self.resolve(&request) {
359            Ok(library) => Ok(Some(library)),
360            Err(Error::UnresolvedLibrary { .. }) => Ok(None),
361            Err(e) => Err(e),
362        }
363    }
364
365    /// Add an already-resolved object (and its own `DT_NEEDED` closure) to a graph.
366    pub fn attach_library(
367        &mut self,
368        graph: &mut DependencyGraph,
369        library: &ResolvedLibrary,
370        from: NodeId,
371        reason: DependencyReason,
372    ) -> Result<NodeId> {
373        let existing = graph.find(&library.resolved.logical);
374        let id = self.insert_object(
375            graph,
376            &library.resolved,
377            &library.metadata,
378            NodeKind::SharedObject,
379        )?;
380        graph.connect(from, id, reason)?;
381        if existing.is_none() {
382            // A policy-loaded module is opened by `dlopen` from libc, not by the
383            // application, so it inherits no RPATH from the loading chain.
384            self.walk_needed(graph, id, library.metadata.clone(), Vec::new())?;
385        }
386        Ok(id)
387    }
388
389    fn insert_object(
390        &mut self,
391        graph: &mut DependencyGraph,
392        resolved: &Resolved,
393        metadata: &ElfMetadata,
394        kind: NodeKind,
395    ) -> Result<NodeId> {
396        assert!(resolved.logical.is_absolute());
397        assert_eq!(resolved.kind, EntryKind::File);
398
399        let (digest, size) = self.digests.get(&resolved.host)?;
400        graph.insert(Node {
401            source: resolved.host.clone(),
402            logical: resolved.logical.clone(),
403            destination: resolved.logical.clone(),
404            kind,
405            soname: metadata.soname.clone(),
406            architecture: metadata.architecture,
407            sha256: digest,
408            size,
409            links: resolved.links.clone(),
410            dlopen_references: metadata.dlopen_references.clone(),
411        })
412    }
413
414    fn check_architecture(
415        &self,
416        metadata: &ElfMetadata,
417        expected: &Architecture,
418        soname: &Path,
419        resolved: &Resolved,
420    ) -> Result<()> {
421        assert_eq!(metadata.path, resolved.host);
422
423        if metadata.architecture.is_compatible_with(expected) {
424            return Ok(());
425        }
426        Err(Error::IncompatibleArchitecture {
427            soname: soname.to_string_lossy().into_owned(),
428            expected: expected.to_string(),
429            found: resolved.logical.clone(),
430            found_architecture: metadata.architecture.to_string(),
431        })
432    }
433
434    fn token_context(&self, requester: &Path, architecture: &Architecture) -> TokenContext {
435        TokenContext {
436            origin: logical_parent(requester),
437            lib: architecture.lib_token().to_string(),
438            platform: architecture.machine.platform_token().map(str::to_string),
439        }
440    }
441
442    /// Candidate directories in glibc's documented order, each tagged with
443    /// where it came from so the planner can tell what survives packaging.
444    fn search_directories(&self, request: &LibraryRequest) -> Result<Vec<(PathBuf, SearchOrigin)>> {
445        let ctx = self.token_context(&request.requester, &request.architecture);
446        let mut dirs: Vec<(PathBuf, SearchOrigin)> = Vec::new();
447
448        // 1. DT_RPATH of the object and, transitively, of its loaders.
449        for level in &request.rpath_chain {
450            for dir in level {
451                push_directory(&mut dirs, dir.clone(), SearchOrigin::ObjectPath)?;
452            }
453        }
454        // 2. LD_LIBRARY_PATH equivalent.
455        for dir in &self.library_paths {
456            push_directory(&mut dirs, dir.clone(), SearchOrigin::LibraryPath)?;
457        }
458        // 3. DT_RUNPATH of the requesting object only.
459        for entry in &request.runpath {
460            let dir = tokens::expand_search_path(entry, &ctx);
461            push_directory(&mut dirs, dir, SearchOrigin::ObjectPath)?;
462        }
463
464        Ok(dirs)
465    }
466
467    fn default_directories(
468        &self,
469        architecture: &Architecture,
470    ) -> Result<Vec<(PathBuf, SearchOrigin)>> {
471        let mut dirs: Vec<(PathBuf, SearchOrigin)> = Vec::new();
472        let configured = self
473            .conf_paths
474            .iter()
475            .cloned()
476            .map(|dir| (dir, SearchOrigin::ConfiguredDirectory));
477        let builtin = search::default_library_paths(architecture)
478            .into_iter()
479            .map(|dir| (dir, SearchOrigin::DefaultDirectory));
480        for (dir, origin) in configured.chain(builtin) {
481            push_directory(&mut dirs, dir, origin)?;
482        }
483
484        Ok(dirs)
485    }
486
487    /// Optional glibc-hwcaps subdirectories are deliberately not selected.
488    /// Their availability is a property of the deployment CPU, not the ELF
489    /// target or sysroot. Picking the highest variant while planning can make
490    /// an otherwise portable bundle fault on older CPUs.
491    fn hwcaps_subdirs(architecture: &Architecture) -> &'static [&'static str] {
492        let _ = architecture;
493        &[]
494    }
495
496    fn try_directory(
497        &mut self,
498        dir: &Path,
499        request: &LibraryRequest,
500        searched: &mut Vec<PathBuf>,
501        mismatch: &mut Option<(PathBuf, Architecture)>,
502    ) -> Result<Option<ResolvedLibrary>> {
503        for hwcap in Self::hwcaps_subdirs(&request.architecture) {
504            let hwcap_dir = dir.join("glibc-hwcaps").join(hwcap);
505            if let Some(found) = self.try_path(
506                &hwcap_dir.join(&request.soname),
507                request,
508                searched,
509                mismatch,
510            )? {
511                return Ok(Some(found));
512            }
513        }
514        self.try_path(&dir.join(&request.soname), request, searched, mismatch)
515    }
516
517    fn try_path(
518        &mut self,
519        logical: &Path,
520        request: &LibraryRequest,
521        searched: &mut Vec<PathBuf>,
522        mismatch: &mut Option<(PathBuf, Architecture)>,
523    ) -> Result<Option<ResolvedLibrary>> {
524        let dir = logical_parent(logical);
525        if !searched.contains(&dir) {
526            searched.push(dir);
527        }
528        let Some(resolved) = self.root.resolve(logical)? else {
529            return Ok(None);
530        };
531        if resolved.kind != EntryKind::File {
532            return Ok(None);
533        }
534        let Some(metadata) = self.elf.get(&resolved.host)? else {
535            return Ok(None);
536        };
537        if metadata.object_type != ObjectType::SharedObject {
538            return Ok(None);
539        }
540        if !metadata
541            .architecture
542            .is_compatible_with(&request.architecture)
543        {
544            if mismatch.is_none() {
545                *mismatch = Some((resolved.logical.clone(), metadata.architecture));
546            }
547            return Ok(None);
548        }
549        Ok(Some(ResolvedLibrary { resolved, metadata }))
550    }
551}
552
553/// Append a directory unless it is already listed. The loader probes each
554/// directory once, in first-seen order, and so does this.
555fn push_directory(
556    dirs: &mut Vec<(PathBuf, SearchOrigin)>,
557    dir: PathBuf,
558    origin: SearchOrigin,
559) -> Result<()> {
560    assert!(dir.is_absolute());
561
562    if dirs.iter().any(|(known, _)| known == &dir) {
563        return Ok(());
564    }
565    if dirs.len() >= SEARCH_DIRECTORIES_MAX {
566        return Err(Error::LimitExceeded {
567            resource: "library search path",
568            limit: SEARCH_DIRECTORIES_MAX,
569        });
570    }
571    dirs.push((dir, origin));
572    Ok(())
573}
574
575impl DynamicLinkerResolver for Resolver {
576    /// One `DT_NEEDED` lookup: a soname is either a path or a search, and a
577    /// search finds a compatible object, an incompatible one, or nothing.
578    fn resolve(&mut self, request: &LibraryRequest) -> Result<ResolvedLibrary> {
579        if request.soname.is_empty() {
580            return Err(Error::Config {
581                message: "library name cannot be empty".to_string(),
582            });
583        }
584        if !request.requester.is_absolute() {
585            return Err(Error::Config {
586                message: format!(
587                    "library requester `{}` is not an absolute logical path",
588                    request.requester.display()
589                ),
590            });
591        }
592
593        let mut searched = Vec::new();
594        let mut mismatch = None;
595
596        // A soname containing a slash is a path, not a search request.
597        let found = if request.soname.contains('/') {
598            let ctx = self.token_context(&request.requester, &request.architecture);
599            let expanded = tokens::expand(&request.soname, &ctx);
600            let path = Path::new(&expanded);
601            if !path.is_absolute() {
602                return Err(Error::Config {
603                    message: format!(
604                        "relative DT_NEEDED path `{}` depends on the runtime working directory",
605                        request.soname
606                    ),
607                });
608            }
609            let path = normalize_absolute(path);
610            self.try_path(&path, request, &mut searched, &mut mismatch)?
611        } else {
612            self.search(request, &mut searched, &mut mismatch)?
613        };
614
615        if let Some(library) = found {
616            return Ok(library);
617        }
618
619        // Nothing was found. An incompatible candidate is worth reporting over
620        // the plain absence, because it names what went wrong.
621        if let Some((found, architecture)) = mismatch {
622            return Err(Error::IncompatibleArchitecture {
623                soname: request.soname.clone(),
624                expected: request.architecture.to_string(),
625                found,
626                found_architecture: architecture.to_string(),
627            });
628        }
629        Err(Error::UnresolvedLibrary {
630            soname: request.soname.clone(),
631            required_by: request.requester.clone(),
632            searched,
633        })
634    }
635}
636
637impl Resolver {
638    /// glibc's search order for a bare soname: the object's own paths, then the
639    /// cache, then the default directories.
640    fn search(
641        &mut self,
642        request: &LibraryRequest,
643        searched: &mut Vec<PathBuf>,
644        mismatch: &mut Option<(PathBuf, Architecture)>,
645    ) -> Result<Option<ResolvedLibrary>> {
646        assert!(!request.soname.contains('/'));
647
648        // 1-3. DT_RPATH, --library-path, DT_RUNPATH.
649        for (dir, origin) in self.search_directories(request)? {
650            if let Some(found) = self.try_directory(&dir, request, searched, mismatch)? {
651                self.note(request, &dir, origin);
652                return Ok(Some(found));
653            }
654        }
655
656        // 4. /etc/ld.so.cache, which names absolute paths rather than directories.
657        let cached: Vec<PathBuf> = self
658            .cache
659            .as_ref()
660            .map(|c| c.lookup_compatible(&request.soname, &request.architecture))
661            .unwrap_or_default();
662        let default_dirs = if request.nodeflib {
663            // `DF_1_NODEFLIB` suppresses glibc's built-in trusted directories,
664            // not directories that `/etc/ld.so.conf` added to the cache.
665            search::default_library_paths(&request.architecture)
666        } else {
667            Vec::new()
668        };
669        for candidate in cached {
670            if default_dirs.iter().any(|dir| candidate.starts_with(dir)) {
671                continue;
672            }
673            if let Some(found) = self.try_path(&candidate, request, searched, mismatch)? {
674                self.note(request, &logical_parent(&candidate), SearchOrigin::Cache);
675                return Ok(Some(found));
676            }
677        }
678
679        // 5. Default directories, unless DF_1_NODEFLIB opted the object out.
680        if request.nodeflib {
681            return Ok(None);
682        }
683        for (dir, origin) in self.default_directories(&request.architecture)? {
684            if let Some(found) = self.try_directory(&dir, request, searched, mismatch)? {
685                self.note(request, &dir, origin);
686                return Ok(Some(found));
687            }
688        }
689        Ok(None)
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696    use crate::elf::{ElfClass, Endianness, Machine};
697
698    #[test]
699    fn an_oversized_search_path_is_an_error() {
700        let temp = tempfile::tempdir().unwrap();
701        let paths = (0..=SEARCH_DIRECTORIES_MAX)
702            .map(|index| PathBuf::from(format!("/search/{index}")))
703            .collect();
704        let resolver = Resolver::new(SourceRoot::new(temp.path())).with_library_paths(paths);
705        let request = LibraryRequest {
706            soname: "libexample.so.1".to_string(),
707            requester: PathBuf::from("/app/server"),
708            rpath_chain: Vec::new(),
709            runpath: Vec::new(),
710            nodeflib: false,
711            architecture: Architecture {
712                machine: Machine::X86_64,
713                class: ElfClass::Elf64,
714                endianness: Endianness::Little,
715            },
716        };
717
718        let error = resolver.search_directories(&request).unwrap_err();
719        assert!(matches!(
720            error,
721            Error::LimitExceeded {
722                resource: "library search path",
723                limit: SEARCH_DIRECTORIES_MAX,
724            }
725        ));
726    }
727}