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