Skip to main content

elfpak_core/plan/
mod.rs

1//! Two-phase packaging: DISCOVER -> BundlePlan -> VALIDATE -> MATERIALIZE.
2//!
3//! A [`BundlePlan`] is immutable once built and fully describes the output, so
4//! `inspect`, `--dry-run`, manifests and tests all share one code path.
5
6use crate::{
7    diagnostics::warning as code,
8    error::{Error, Result, io},
9    graph::{DependencyGraph, DependencyReason, Node, NodeId, NodeKind},
10    paths::{logical_parent, normalize_absolute},
11    policy::{DependencyPolicy, Preset, RuntimeFeature, RuntimePolicy},
12    resolver::{
13        Resolver,
14        cache::{self, CacheEntry},
15    },
16    source::SourceRoot,
17};
18use std::path::{Path, PathBuf};
19
20mod builder;
21mod model;
22
23use builder::PlanBuilder;
24pub use model::{BundlePlan, InclusionReason, PlannedFile, PlannedFileKind, Warning};
25
26/// Where the loader looks for its cache, and therefore where a generated one
27/// has to go.
28pub const LD_SO_CACHE: &str = "/etc/ld.so.cache";
29
30/// Upper bound on the entries in one plan.
31///
32/// A minimal rootfs is tens of entries, while timezone data or an explicit
33/// application tree can add thousands. This bound catches an accidental walk
34/// into an unexpectedly large filesystem before the plan consumes unbounded
35/// memory.
36pub const PLAN_ENTRIES_MAX: usize = 1 << 20;
37
38#[derive(Debug)]
39pub struct Planner {
40    source_root: SourceRoot,
41    binary: PathBuf,
42    install_path: PathBuf,
43    runtime_policy: RuntimePolicy,
44    dependency_policy: DependencyPolicy,
45    library_paths: Vec<PathBuf>,
46    preset: Option<Preset>,
47}
48
49impl Planner {
50    pub fn new(source_root: SourceRoot, binary: impl Into<PathBuf>) -> Planner {
51        let binary = binary.into();
52        let install_path = PathBuf::from("/").join(
53            binary
54                .file_name()
55                .map(PathBuf::from)
56                .unwrap_or_else(|| PathBuf::from("app")),
57        );
58        Planner {
59            source_root,
60            binary,
61            install_path,
62            runtime_policy: RuntimePolicy::default(),
63            dependency_policy: DependencyPolicy::allow_all(),
64            library_paths: Vec::new(),
65            preset: None,
66        }
67    }
68
69    /// Record which preset the runtime policy came from, for the manifest.
70    pub fn preset(mut self, preset: Preset) -> Planner {
71        self.preset = Some(preset);
72        self.runtime_policy = RuntimePolicy::from_preset(preset);
73        self
74    }
75
76    pub fn install_as(mut self, path: impl Into<PathBuf>) -> Planner {
77        self.install_path = normalize_absolute(&path.into());
78        self
79    }
80
81    pub fn runtime_policy(mut self, policy: RuntimePolicy) -> Planner {
82        self.runtime_policy = policy;
83        self
84    }
85
86    pub fn dependency_policy(mut self, policy: DependencyPolicy) -> Planner {
87        self.dependency_policy = policy;
88        self
89    }
90
91    pub fn library_paths(mut self, paths: Vec<PathBuf>) -> Planner {
92        self.library_paths = paths;
93        self
94    }
95
96    /// Phase one: resolve, validate, then describe the output. Nothing is
97    /// written until [`crate::RootFsBuilder`] or [`crate::TarBuilder`] gets the
98    /// plan.
99    pub fn plan(&self) -> Result<BundlePlan> {
100        self.check_install_path()?;
101
102        let mut resolver =
103            Resolver::new(self.source_root.clone()).with_library_paths(self.library_paths.clone());
104        let mut graph = resolver.closure(&self.binary, &self.install_path)?;
105        if self.runtime_policy.nsswitch {
106            self.attach_nss_modules(&mut resolver, &mut graph)?;
107        }
108
109        self.validate_dependencies(&graph)?;
110        self.check_install_collision(&graph)?;
111
112        let mut warnings: Vec<Warning> = Vec::new();
113        let mut builder = PlanBuilder::new(&self.source_root);
114        self.plan_loader_cache(&graph, &resolver, &mut builder, &mut warnings);
115        self.plan_closure(&graph, &mut builder, &mut warnings)?;
116        self.apply_runtime_policy(&mut builder, &mut warnings)?;
117
118        let files = builder.finish();
119        let executable = files
120            .iter()
121            .find(|f| f.kind == PlannedFileKind::Executable)
122            .cloned()
123            .expect("the plan always contains the executable");
124
125        // Every object in the closure is an entry, and every entry needs its
126        // parent directories, so a plan is never smaller than the graph.
127        assert_eq!(executable.destination, graph.root_node().destination);
128        assert!(files.len() >= graph.node_count());
129
130        Ok(BundlePlan {
131            executable,
132            architecture: graph.root_node().architecture,
133            interpreter: graph.declared_interpreter.clone(),
134            interpreter_resolved: graph
135                .nodes
136                .iter()
137                .find(|n| n.kind == NodeKind::Interpreter)
138                .map(|n| n.destination.clone()),
139            files,
140            graph,
141            preset: self.preset,
142            runtime_policy: self.runtime_policy.clone(),
143            dependency_policy: self.dependency_policy.clone(),
144            warnings,
145        })
146    }
147
148    /// The executable has to land somewhere the rootfs can name.
149    fn check_install_path(&self) -> Result<()> {
150        assert!(self.install_path.is_absolute());
151
152        if self.install_path.file_name().is_some() {
153            return Ok(());
154        }
155        Err(Error::Config {
156            message: format!(
157                "install path `{}` does not name a file",
158                self.install_path.display()
159            ),
160        })
161    }
162
163    /// NSS modules are `dlopen`ed by glibc rather than named by `DT_NEEDED`, so
164    /// they are included when the policy asks for name-service configuration
165    /// and the source root still ships them.
166    fn attach_nss_modules(
167        &self,
168        resolver: &mut Resolver,
169        graph: &mut DependencyGraph,
170    ) -> Result<()> {
171        assert!(self.runtime_policy.nsswitch);
172
173        let root_id = graph.root;
174        let architecture = graph.root_node().architecture;
175        for soname in RuntimePolicy::NSS_MODULES {
176            let requester = graph.root_node().logical.clone();
177            let Some(library) = resolver.resolve_extra_library(soname, architecture, &requester)?
178            else {
179                continue;
180            };
181            resolver.attach_library(
182                graph,
183                &library,
184                root_id,
185                DependencyReason::RuntimePolicy {
186                    feature: RuntimeFeature::Nsswitch,
187                },
188            )?;
189        }
190        Ok(())
191    }
192
193    /// Decide whether the bundle needs a generated `/etc/ld.so.cache`, and warn
194    /// about what it cannot load when it does not get one.
195    ///
196    /// Two things leave a bundle unable to load a library it contains: a library
197    /// outside the directories the loader searches, and an executable whose
198    /// `$ORIGIN`-relative search paths point elsewhere once it is installed
199    /// somewhere else. A cache fixes both, and only `elfpak` can write it.
200    fn plan_loader_cache(
201        &self,
202        graph: &DependencyGraph,
203        resolver: &Resolver,
204        builder: &mut PlanBuilder<'_>,
205        warnings: &mut Vec<Warning>,
206    ) {
207        let unreachable = unreachable_libraries(resolver);
208        let relocated = relocated_search_paths(graph);
209        let needs_cache = !unreachable.is_empty() || !relocated.is_empty();
210
211        let cache = self
212            .runtime_policy
213            .ld_so_cache
214            .applies(needs_cache)
215            .then(|| self.ld_so_cache(graph))
216            .flatten();
217
218        if let Some(bytes) = cache {
219            builder.push_generated(
220                Path::new(LD_SO_CACHE),
221                bytes,
222                InclusionReason::RuntimePolicy {
223                    feature: RuntimeFeature::LdSoCache,
224                },
225            );
226            return;
227        }
228
229        // No cache: say exactly what the packaged loader will not find.
230        if !unreachable.is_empty() {
231            warnings.push(warn_unreachable(unreachable, uses_glibc_loader(graph)));
232        }
233        if !relocated.is_empty() {
234            warnings.push(warn_relocated(relocated, graph));
235        }
236    }
237
238    /// Turn every object in the closure into a plan entry, together with the
239    /// symlinks it is reached through and any `dlopen` warning it earns.
240    fn plan_closure(
241        &self,
242        graph: &DependencyGraph,
243        builder: &mut PlanBuilder<'_>,
244        warnings: &mut Vec<Warning>,
245    ) -> Result<()> {
246        let mut dlopen_libraries: Vec<String> = Vec::new();
247
248        for (id, node) in graph.iter() {
249            let reason = inclusion_reason(graph, id, node);
250            builder.push_file(PlannedFile {
251                source: Some(node.source.clone()),
252                destination: node.destination.clone(),
253                kind: planned_kind(node.kind),
254                reason: reason.clone(),
255                mode: mode_of(&node.source)?,
256                size: node.size,
257                sha256: Some(node.sha256.clone()),
258                link_target: None,
259                content: None,
260            });
261            for link in &node.links {
262                builder.push_symlink(&link.logical, &link.target, reason.clone());
263            }
264
265            if node.dlopen_references.is_empty() {
266                continue;
267            }
268            if id == graph.root {
269                warnings.push(warn_dlopen_executable(node));
270            } else {
271                dlopen_libraries.push(node.destination.display().to_string());
272            }
273        }
274
275        if !dlopen_libraries.is_empty() {
276            warnings.push(Warning {
277                code: code::DLOPEN,
278                message: format!(
279                    "{} bundled shared object(s) reference dlopen()",
280                    dlopen_libraries.len()
281                ),
282                details: dlopen_libraries,
283            });
284        }
285        Ok(())
286    }
287
288    /// A `/etc/ld.so.cache` describing every shared object in the bundle.
289    ///
290    /// `None` when there is nothing to record, or when the target is one the
291    /// cache format cannot describe — the caller then reports the problem
292    /// instead of writing a cache the loader would reject.
293    fn ld_so_cache(&self, graph: &DependencyGraph) -> Option<Vec<u8>> {
294        if !uses_glibc_loader(graph) {
295            // musl resolves libraries through /etc/ld-musl-<arch>.path and
296            // ignores ld.so.cache entirely, so the caller reports instead.
297            return None;
298        }
299        let architecture = graph.root_node().architecture;
300        let entries: Vec<CacheEntry> = graph
301            .nodes
302            .iter()
303            .filter(|node| matches!(node.kind, NodeKind::SharedObject | NodeKind::Interpreter))
304            .map(|node| CacheEntry {
305                // A library without DT_SONAME is looked up by file name, which
306                // is also what its dependents will have recorded.
307                soname: node.soname.clone().unwrap_or_else(|| {
308                    node.destination
309                        .file_name()
310                        .map(|name| name.to_string_lossy().into_owned())
311                        .unwrap_or_default()
312                }),
313                path: node.destination.clone(),
314            })
315            .filter(|entry| !entry.soname.is_empty())
316            .collect();
317        if entries.is_empty() {
318            return None;
319        }
320        cache::build(&architecture, &entries)
321    }
322
323    /// The executable would otherwise displace a library that has to keep its
324    /// own path, leaving the bundle with a dependency it cannot load.
325    fn check_install_collision(&self, graph: &DependencyGraph) -> Result<()> {
326        let install = &graph.root_node().destination;
327        assert!(install.is_absolute());
328
329        for (id, node) in graph.iter() {
330            if id == graph.root {
331                continue;
332            }
333            if &node.destination != install {
334                continue;
335            }
336            return Err(Error::Config {
337                message: format!(
338                    "install path `{}` collides with `{}`, which the closure \
339                     needs at that exact path",
340                    self.install_path.display(),
341                    node.logical.display()
342                ),
343            });
344        }
345        Ok(())
346    }
347
348    /// Enforce the dependency allow-list.
349    ///
350    /// Only the application's own ELF closure is policed. The interpreter is
351    /// exempt because it is not a `DT_NEEDED` dependency, and so is anything
352    /// runtime policy pulled in: the caller asked for those by name, and cannot
353    /// be expected to know the sonames of the NSS modules a source root ships.
354    fn validate_dependencies(&self, graph: &DependencyGraph) -> Result<()> {
355        if self.dependency_policy.allow.is_none() {
356            // No allow-list means no contract to enforce.
357            return Ok(());
358        }
359
360        let application = graph.application_closure();
361
362        for (id, node) in graph.iter() {
363            if node.kind != NodeKind::SharedObject {
364                continue;
365            }
366            if !application.contains(&id) {
367                continue;
368            }
369            let soname = library_name(node);
370            if self.dependency_policy.is_allowed(&soname, &node.logical) {
371                continue;
372            }
373            let required_by = graph
374                .first_dependent(id)
375                .map(|(_, parent)| parent.destination.clone())
376                .unwrap_or_else(|| self.install_path.clone());
377            return Err(Error::DisallowedLibrary {
378                soname,
379                required_by,
380            });
381        }
382        Ok(())
383    }
384
385    /// Everything runtime policy contributes, in one place.
386    fn apply_runtime_policy(
387        &self,
388        builder: &mut PlanBuilder<'_>,
389        warnings: &mut Vec<Warning>,
390    ) -> Result<()> {
391        let policy = &self.runtime_policy;
392
393        if policy.ca_certificates {
394            self.plan_ca_certificates(builder)?;
395        }
396        if policy.tmp {
397            // 1777: every user may write, only the owner may unlink.
398            builder.push_dir_with_mode(
399                Path::new("/tmp"),
400                0o1777,
401                InclusionReason::RuntimePolicy {
402                    feature: RuntimeFeature::Tmp,
403                },
404            );
405        }
406        if policy.passwd_group {
407            self.plan_passwd_group(builder);
408        }
409        if policy.nsswitch {
410            builder.push_generated(
411                Path::new("/etc/nsswitch.conf"),
412                policy.nsswitch_contents(),
413                InclusionReason::RuntimePolicy {
414                    feature: RuntimeFeature::Nsswitch,
415                },
416            );
417        }
418        if policy.tzdata {
419            self.plan_tzdata(builder)?;
420        }
421        for include in &policy.includes {
422            self.plan_include(builder, include)?;
423        }
424
425        if policy.user.is_some() && !policy.passwd_group {
426            warnings.push(Warning {
427                code: code::USER_WITHOUT_PASSWD_GROUP,
428                message: "--user was given without passwd/group files".to_string(),
429                details: vec![
430                    "Add --passwd-group (or --preset web) if the application resolves its own uid."
431                        .to_string(),
432                ],
433            });
434        }
435        Ok(())
436    }
437
438    /// The first CA bundle the source root actually has. A `web` preset that
439    /// silently shipped no trust store would fail at the first HTTPS request.
440    fn plan_ca_certificates(&self, builder: &mut PlanBuilder<'_>) -> Result<()> {
441        for candidate in RuntimePolicy::CA_BUNDLE_CANDIDATES {
442            let logical = PathBuf::from(candidate);
443            let found = builder.copy_path(
444                &logical,
445                PlannedFileKind::CertificateBundle,
446                InclusionReason::RuntimePolicy {
447                    feature: RuntimeFeature::CaCertificates,
448                },
449                false,
450            )?;
451            if found {
452                return Ok(());
453            }
454        }
455        Err(Error::MissingRuntimeFile {
456            feature: "ca-certificates",
457            searched: RuntimePolicy::CA_BUNDLE_CANDIDATES
458                .iter()
459                .map(PathBuf::from)
460                .collect(),
461        })
462    }
463
464    fn plan_passwd_group(&self, builder: &mut PlanBuilder<'_>) {
465        let reason = InclusionReason::RuntimePolicy {
466            feature: RuntimeFeature::PasswdGroup,
467        };
468        builder.push_generated(
469            Path::new("/etc/passwd"),
470            self.runtime_policy.passwd_contents(),
471            reason.clone(),
472        );
473        builder.push_generated(
474            Path::new("/etc/group"),
475            self.runtime_policy.group_contents(),
476            reason,
477        );
478    }
479
480    /// The zone database, plus `/etc/localtime` when the source root sets one.
481    fn plan_tzdata(&self, builder: &mut PlanBuilder<'_>) -> Result<()> {
482        let reason = InclusionReason::RuntimePolicy {
483            feature: RuntimeFeature::Tzdata,
484        };
485        let zoneinfo = PathBuf::from("/usr/share/zoneinfo");
486        let found = builder.copy_path(
487            &zoneinfo,
488            PlannedFileKind::ApplicationData,
489            reason.clone(),
490            true,
491        )?;
492        if !found {
493            return Err(Error::MissingRuntimeFile {
494                feature: "tzdata",
495                searched: vec![zoneinfo],
496            });
497        }
498        // A missing /etc/localtime is not an error: UTC is a valid default.
499        builder.copy_path(
500            Path::new("/etc/localtime"),
501            PlannedFileKind::RuntimeConfig,
502            reason,
503            false,
504        )?;
505        Ok(())
506    }
507
508    fn plan_include(&self, builder: &mut PlanBuilder<'_>, include: &Path) -> Result<()> {
509        let logical = normalize_absolute(include);
510        let found = builder.copy_path(
511            &logical,
512            PlannedFileKind::ApplicationData,
513            InclusionReason::ExplicitInclude,
514            true,
515        )?;
516        if found {
517            return Ok(());
518        }
519        Err(Error::MissingSourcePath { path: logical })
520    }
521}
522
523/// Why an object is in the bundle, as recorded for the manifest.
524fn inclusion_reason(graph: &DependencyGraph, id: NodeId, node: &Node) -> InclusionReason {
525    if id == graph.root {
526        return InclusionReason::Application;
527    }
528    if node.kind == NodeKind::Interpreter {
529        return InclusionReason::Interpreter;
530    }
531    match graph.first_dependent(id) {
532        Some((edge, parent)) => match &edge.reason {
533            DependencyReason::Needed { soname } => InclusionReason::NeededBy {
534                binary: parent.destination.clone(),
535                soname: soname.clone(),
536            },
537            DependencyReason::Interpreter => InclusionReason::Interpreter,
538            DependencyReason::RuntimePolicy { feature } => {
539                InclusionReason::RuntimePolicy { feature: *feature }
540            }
541        },
542        // Unreachable in a graph built by the resolver: only the executable has
543        // no dependent, and it was handled above.
544        None => InclusionReason::Application,
545    }
546}
547
548fn planned_kind(kind: NodeKind) -> PlannedFileKind {
549    match kind {
550        NodeKind::Executable => PlannedFileKind::Executable,
551        NodeKind::Interpreter => PlannedFileKind::Interpreter,
552        NodeKind::SharedObject => PlannedFileKind::SharedObject,
553    }
554}
555
556/// How a library is named on the command line: its `DT_SONAME` when it has one,
557/// otherwise its file name.
558fn library_name(node: &Node) -> String {
559    node.soname
560        .clone()
561        .or_else(|| {
562            node.logical
563                .file_name()
564                .map(|name| name.to_string_lossy().into_owned())
565        })
566        .unwrap_or_default()
567}
568
569/// Libraries that resolved through something the bundle does not reproduce.
570fn unreachable_libraries(resolver: &Resolver) -> Vec<String> {
571    resolver
572        .notes()
573        .iter()
574        .map(|note| {
575            format!(
576                "{} in {} (found through {})",
577                note.soname,
578                note.directory.display(),
579                note.origin.as_str()
580            )
581        })
582        .collect()
583}
584
585/// `$ORIGIN`-relative search paths of an executable that is being installed
586/// somewhere other than where it was built. They point somewhere else now.
587fn relocated_search_paths(graph: &DependencyGraph) -> Vec<String> {
588    let source_dir = logical_parent(&graph.root_node().logical);
589    let install_dir = logical_parent(&graph.root_node().destination);
590    if install_dir == source_dir {
591        return Vec::new();
592    }
593    graph
594        .executable_search_paths
595        .iter()
596        .filter(|entry| entry.contains("$ORIGIN") || entry.contains("${ORIGIN}"))
597        .cloned()
598        .collect()
599}
600
601fn warn_unreachable(libraries: Vec<String>, glibc: bool) -> Warning {
602    assert!(!libraries.is_empty());
603
604    let explanation = if glibc {
605        format!(
606            "Without {LD_SO_CACHE} the packaged application finds these \
607             only if its DT_RPATH/DT_RUNPATH covers them."
608        )
609    } else {
610        "This loader does not read an ld.so.cache, so the paths have to \
611         come from the objects themselves."
612            .to_string()
613    };
614    Warning {
615        code: code::LIBRARY_UNREACHABLE,
616        message: match libraries.len() {
617            1 => "a library lives outside the directories the loader searches".to_string(),
618            n => format!("{n} libraries live outside the directories the loader searches"),
619        },
620        details: libraries.into_iter().chain([explanation]).collect(),
621    }
622}
623
624fn warn_relocated(paths: Vec<String>, graph: &DependencyGraph) -> Warning {
625    assert!(!paths.is_empty());
626
627    let source_dir = logical_parent(&graph.root_node().logical);
628    let install_dir = logical_parent(&graph.root_node().destination);
629    assert_ne!(source_dir, install_dir);
630
631    let advice = format!(
632        "Install it at {} to keep those paths pointing where they did.",
633        graph.root_node().logical.display()
634    );
635    Warning {
636        code: code::EXECUTABLE_RELOCATED,
637        message: format!(
638            "the executable declares $ORIGIN-relative search paths and moves from {} to {}",
639            source_dir.display(),
640            install_dir.display()
641        ),
642        details: paths.into_iter().chain([advice]).collect(),
643    }
644}
645
646fn warn_dlopen_executable(node: &Node) -> Warning {
647    assert!(!node.dlopen_references.is_empty());
648
649    Warning {
650        code: code::DLOPEN,
651        message: format!("{} references dlopen()", node.destination.display()),
652        details: vec![
653            "Runtime-loaded libraries cannot be determined using static ELF dependency analysis."
654                .to_string(),
655            "Consider adding them with --include.".to_string(),
656        ],
657    }
658}
659
660/// Whether `PT_INTERP` is a glibc loader, and therefore whether an
661/// `ld.so.cache` means anything to the packaged application.
662fn uses_glibc_loader(graph: &DependencyGraph) -> bool {
663    match &graph.declared_interpreter {
664        Some(interpreter) => !interpreter
665            .file_name()
666            .map(|name| name.to_string_lossy().contains("ld-musl"))
667            .unwrap_or(false),
668        // A static binary has no loader, and no libraries for a cache to name.
669        None => false,
670    }
671}
672
673/// Normalized permissions: executables and directories are `0755`, everything
674/// else `0644`.
675fn mode_of(path: &Path) -> Result<u32> {
676    use std::os::unix::fs::PermissionsExt;
677
678    let metadata = std::fs::metadata(path).map_err(|e| io(path, e))?;
679    let mode = metadata.permissions().mode();
680    let normalized = if metadata.is_dir() || mode & 0o111 != 0 {
681        0o755
682    } else {
683        0o644
684    };
685    Ok(normalized)
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use crate::{
692        elf::{Architecture, ElfClass, Endianness, Machine},
693        graph::Node,
694        hash::sha256_bytes,
695    };
696
697    fn graph_with_interpreter(interpreter: Option<&str>) -> DependencyGraph {
698        let architecture = Architecture {
699            machine: Machine::X86_64,
700            class: ElfClass::Elf64,
701            endianness: Endianness::Little,
702        };
703        let node = |kind, logical: &str, soname: Option<&str>| Node {
704            source: PathBuf::from(logical),
705            logical: PathBuf::from(logical),
706            destination: PathBuf::from(logical),
707            kind,
708            soname: soname.map(str::to_string),
709            architecture,
710            // A real digest: the graph asserts that every node carries one.
711            sha256: sha256_bytes(logical.as_bytes()),
712            size: 0,
713            links: Vec::new(),
714            dlopen_references: Vec::new(),
715        };
716
717        let mut graph = DependencyGraph::new();
718        graph.root = graph
719            .insert(node(NodeKind::Executable, "/app/server", None))
720            .unwrap();
721        graph.declared_interpreter = interpreter.map(PathBuf::from);
722        if let Some(interpreter) = interpreter {
723            graph
724                .insert(node(NodeKind::Interpreter, interpreter, Some("ld.so")))
725                .unwrap();
726        }
727        graph
728            .insert(node(
729                NodeKind::SharedObject,
730                "/opt/vendor/lib/libvendor.so.1",
731                Some("libvendor.so.1"),
732            ))
733            .unwrap();
734        graph
735    }
736
737    fn planner() -> Planner {
738        Planner::new(SourceRoot::new("/"), "/app/server")
739    }
740
741    #[test]
742    fn a_glibc_bundle_gets_a_cache_naming_its_libraries() {
743        let graph = graph_with_interpreter(Some("/lib64/ld-linux-x86-64.so.2"));
744        let bytes = planner().ld_so_cache(&graph).expect("a cache is built");
745
746        let cache = crate::resolver::LdCache::parse(&bytes);
747        assert_eq!(
748            cache.lookup("libvendor.so.1"),
749            [PathBuf::from("/opt/vendor/lib/libvendor.so.1")]
750        );
751        assert!(
752            !cache.lookup("ld.so").is_empty(),
753            "the interpreter is listed too, as ldconfig lists it"
754        );
755    }
756
757    #[test]
758    fn a_musl_bundle_gets_no_cache() {
759        let graph = graph_with_interpreter(Some("/lib/ld-musl-x86_64.so.1"));
760        assert!(planner().ld_so_cache(&graph).is_none());
761        assert!(!uses_glibc_loader(&graph));
762    }
763
764    #[test]
765    fn a_static_binary_gets_no_cache() {
766        let mut graph = graph_with_interpreter(None);
767        graph.nodes.retain(|node| node.kind == NodeKind::Executable);
768        assert!(planner().ld_so_cache(&graph).is_none());
769    }
770}