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::{Authority, Conflict, 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        // The closure is planned first: its objects have to sit exactly where
162        // the loader will look, so nothing may displace them.
163        builder.acting_as(Authority::Closure);
164        for (_, graph) in &resolved {
165            self.plan_closure(graph, &mut builder, &mut warnings)?;
166        }
167        builder.acting_as(Authority::RuntimePolicy);
168        self.plan_loader_cache(&resolved, &mut builder, &mut warnings)?;
169        self.apply_runtime_policy(&mut builder, &mut warnings)?;
170        deduplicate_warnings(&mut warnings);
171
172        let (files, conflicts) = builder.finish();
173        if files.len() > PLAN_ENTRIES_MAX {
174            return Err(Error::Config {
175                message: format!("bundle plan exceeds {PLAN_ENTRIES_MAX} entries"),
176            });
177        }
178        check_destination_conflicts(&conflicts)?;
179        check_nesting(&files)?;
180        let applications = resolved
181            .into_iter()
182            .map(|(_, graph)| {
183                let destination = &graph.root_node().destination;
184                let executable = files
185                    .iter()
186                    .find(|file| {
187                        file.kind == PlannedFileKind::Executable && &file.destination == destination
188                    })
189                    .cloned()
190                    .expect("every graph root has one planned executable");
191                ApplicationPlan {
192                    executable,
193                    interpreter: graph.declared_interpreter.clone(),
194                    interpreter_resolved: graph
195                        .nodes
196                        .iter()
197                        .find(|node| node.kind == NodeKind::Interpreter)
198                        .map(|node| node.destination.clone()),
199                    graph,
200                }
201            })
202            .collect();
203
204        Ok(BundlePlan {
205            applications,
206            architecture,
207            files,
208            preset: self.preset,
209            runtime_policy: self.runtime_policy.clone(),
210            dependency_policy: self.dependency_policy.clone(),
211            warnings,
212        })
213    }
214
215    /// The executable has to land somewhere the rootfs can name.
216    fn check_install_path(&self, input: &PlannerInput) -> Result<()> {
217        assert!(input.install_path.is_absolute());
218
219        if input.install_path.file_name().is_some() {
220            return Ok(());
221        }
222        Err(Error::Config {
223            message: format!(
224                "install path `{}` does not name a file",
225                input.install_path.display()
226            ),
227        })
228    }
229
230    fn check_architectures(
231        &self,
232        resolved: &[(Resolver, DependencyGraph)],
233    ) -> Result<crate::Architecture> {
234        let first = resolved
235            .first()
236            .expect("a planner always has at least one binary")
237            .1
238            .root_node()
239            .architecture;
240        for (_, graph) in &resolved[1..] {
241            let architecture = graph.root_node().architecture;
242            if architecture != first {
243                return Err(Error::Config {
244                    message: format!(
245                        "executable `{}` has architecture {architecture}, expected {first}",
246                        graph.root_node().logical.display()
247                    ),
248                });
249            }
250        }
251        Ok(first)
252    }
253
254    /// NSS modules are `dlopen`ed by glibc rather than named by `DT_NEEDED`, so
255    /// they are included when the policy asks for name-service configuration
256    /// and the source root still ships them.
257    fn attach_nss_modules(
258        &self,
259        resolver: &mut Resolver,
260        graph: &mut DependencyGraph,
261    ) -> Result<()> {
262        assert!(self.runtime_policy.nsswitch);
263
264        let root_id = graph.root;
265        let architecture = graph.root_node().architecture;
266        for soname in RuntimePolicy::NSS_MODULES {
267            let requester = graph.root_node().logical.clone();
268            let Some(library) = resolver.resolve_extra_library(soname, architecture, &requester)?
269            else {
270                continue;
271            };
272            resolver.attach_library(
273                graph,
274                &library,
275                root_id,
276                DependencyReason::RuntimePolicy {
277                    feature: RuntimeFeature::Nsswitch,
278                },
279            )?;
280        }
281        Ok(())
282    }
283
284    /// Decide whether the bundle needs a generated `/etc/ld.so.cache`, and warn
285    /// about what it cannot load when it does not get one.
286    ///
287    /// Two things leave a bundle unable to load a library it contains: a library
288    /// outside the directories the loader searches, and an executable whose
289    /// `$ORIGIN`-relative search paths point elsewhere once it is installed
290    /// somewhere else. A cache fixes both, and only `elfpak` can write it.
291    fn plan_loader_cache(
292        &self,
293        resolved: &[(Resolver, DependencyGraph)],
294        builder: &mut PlanBuilder<'_>,
295        warnings: &mut Vec<Warning>,
296    ) -> Result<()> {
297        let needs_cache = resolved.iter().any(|(resolver, graph)| {
298            !unreachable_libraries(resolver).is_empty() || !relocated_search_paths(graph).is_empty()
299        });
300
301        let writing_cache = self.runtime_policy.ld_so_cache.applies(needs_cache);
302        if writing_cache {
303            warn_ambiguous_sonames(resolved.iter().map(|(_, graph)| graph), warnings);
304        }
305        let cache = writing_cache
306            .then(|| self.ld_so_cache_many(resolved.iter().map(|(_, graph)| graph)))
307            .flatten();
308
309        let wrote_cache = if let Some(bytes) = cache {
310            builder.push_generated(
311                Path::new(LD_SO_CACHE),
312                bytes,
313                InclusionReason::RuntimePolicy {
314                    feature: RuntimeFeature::LdSoCache,
315                },
316            );
317            true
318        } else {
319            false
320        };
321
322        // A generated cache serves glibc applications only. Other loaders still
323        // need a warning for paths they cannot reproduce inside the bundle.
324        for (resolver, graph) in resolved {
325            if wrote_cache && uses_glibc_loader(graph) {
326                continue;
327            }
328            let unreachable = unreachable_libraries(resolver);
329            let relocated = relocated_search_paths(graph);
330            if !unreachable.is_empty() {
331                warnings.push(warn_unreachable(unreachable, uses_glibc_loader(graph)));
332            }
333            if !relocated.is_empty() {
334                warnings.push(warn_relocated(relocated, graph));
335            }
336        }
337        Ok(())
338    }
339
340    /// Turn every object in the closure into a plan entry, together with the
341    /// symlinks it is reached through and any `dlopen` warning it earns.
342    fn plan_closure(
343        &self,
344        graph: &DependencyGraph,
345        builder: &mut PlanBuilder<'_>,
346        warnings: &mut Vec<Warning>,
347    ) -> Result<()> {
348        let mut dlopen_libraries: Vec<String> = Vec::new();
349
350        for (id, node) in graph.iter() {
351            let reason = inclusion_reason(graph, id, node);
352            builder.push_file(PlannedFile {
353                source: Some(node.source.clone()),
354                destination: node.destination.clone(),
355                kind: planned_kind(node.kind),
356                reason: reason.clone(),
357                mode: mode_of(&node.source)?,
358                size: node.size,
359                sha256: Some(node.sha256.clone()),
360                link_target: None,
361                content: None,
362            });
363            for link in &node.links {
364                builder.push_symlink(&link.logical, &link.target, reason.clone());
365            }
366
367            if node.dlopen_references.is_empty() {
368                continue;
369            }
370            if id == graph.root {
371                warnings.push(warn_dlopen_executable(node));
372            } else {
373                dlopen_libraries.push(node.destination.display().to_string());
374            }
375        }
376
377        if !dlopen_libraries.is_empty() {
378            warnings.push(Warning {
379                code: code::DLOPEN,
380                message: format!(
381                    "{} bundled shared object(s) reference dlopen()",
382                    dlopen_libraries.len()
383                ),
384                details: dlopen_libraries,
385            });
386        }
387        Ok(())
388    }
389
390    /// A `/etc/ld.so.cache` describing every shared object in the bundle.
391    ///
392    /// `None` when there is nothing to record, or when the target is one the
393    /// cache format cannot describe — the caller then reports the problem
394    /// instead of writing a cache the loader would reject.
395    #[cfg(test)]
396    fn ld_so_cache(&self, graph: &DependencyGraph) -> Option<Vec<u8>> {
397        self.ld_so_cache_many(std::iter::once(graph))
398    }
399
400    fn ld_so_cache_many<'a>(
401        &self,
402        graphs: impl IntoIterator<Item = &'a DependencyGraph>,
403    ) -> Option<Vec<u8>> {
404        let graphs: Vec<_> = graphs
405            .into_iter()
406            .filter(|graph| uses_glibc_loader(graph))
407            .collect();
408        let architecture = graphs.first()?.root_node().architecture;
409        let entries: Vec<CacheEntry> = graphs
410            .iter()
411            .flat_map(|graph| graph.nodes.iter())
412            .filter(|node| matches!(node.kind, NodeKind::SharedObject | NodeKind::Interpreter))
413            .map(|node| CacheEntry {
414                soname: cache_soname(node),
415                path: node.destination.clone(),
416            })
417            .filter(|entry| !entry.soname.is_empty())
418            .collect();
419        if entries.is_empty() {
420            return None;
421        }
422        cache::build(&architecture, &entries)
423    }
424
425    /// The executable would otherwise displace a library that has to keep its
426    /// own path, leaving the bundle with a dependency it cannot load.
427    fn check_install_collision(&self, graph: &DependencyGraph, install_path: &Path) -> Result<()> {
428        let install = &graph.root_node().destination;
429        assert!(install.is_absolute());
430
431        for (id, node) in graph.iter() {
432            if id == graph.root {
433                continue;
434            }
435            if &node.destination != install {
436                continue;
437            }
438            return Err(Error::Config {
439                message: format!(
440                    "install path `{}` collides with `{}`, which the closure \
441                     needs at that exact path",
442                    install_path.display(),
443                    node.logical.display()
444                ),
445            });
446        }
447        Ok(())
448    }
449
450    /// Enforce the dependency allow-list.
451    ///
452    /// Only the application's own ELF closure is policed. The interpreter is
453    /// exempt because it is not a `DT_NEEDED` dependency, and so is anything
454    /// runtime policy pulled in: the caller asked for those by name, and cannot
455    /// be expected to know the sonames of the NSS modules a source root ships.
456    fn validate_dependencies(&self, graph: &DependencyGraph, install_path: &Path) -> Result<()> {
457        if self.dependency_policy.allow.is_none() {
458            // No allow-list means no contract to enforce.
459            return Ok(());
460        }
461
462        let application = graph.application_closure();
463
464        for (id, node) in graph.iter() {
465            if node.kind != NodeKind::SharedObject {
466                continue;
467            }
468            if !application.contains(&id) {
469                continue;
470            }
471            let soname = library_name(node);
472            if self.dependency_policy.is_allowed(&soname, &node.logical) {
473                continue;
474            }
475            let required_by = graph
476                .first_dependent(id)
477                .map(|(_, parent)| parent.destination.clone())
478                .unwrap_or_else(|| install_path.to_path_buf());
479            return Err(Error::DisallowedLibrary {
480                soname,
481                required_by,
482            });
483        }
484        Ok(())
485    }
486
487    /// Everything runtime policy contributes, in one place.
488    fn apply_runtime_policy(
489        &self,
490        builder: &mut PlanBuilder<'_>,
491        warnings: &mut Vec<Warning>,
492    ) -> Result<()> {
493        let policy = &self.runtime_policy;
494
495        if policy.ca_certificates {
496            self.plan_ca_certificates(builder)?;
497        }
498        if policy.tmp {
499            // 1777: every user may write, only the owner may unlink.
500            builder.push_dir_with_mode(
501                Path::new("/tmp"),
502                0o1777,
503                InclusionReason::RuntimePolicy {
504                    feature: RuntimeFeature::Tmp,
505                },
506            );
507        }
508        if policy.passwd_group {
509            self.plan_passwd_group(builder);
510        }
511        if policy.nsswitch {
512            builder.push_generated(
513                Path::new("/etc/nsswitch.conf"),
514                policy.nsswitch_contents(),
515                InclusionReason::RuntimePolicy {
516                    feature: RuntimeFeature::Nsswitch,
517                },
518            );
519        }
520        if policy.tzdata {
521            self.plan_tzdata(builder)?;
522        }
523        builder.acting_as(Authority::IncludedTree);
524        for include in &policy.includes {
525            self.plan_include(builder, include)?;
526        }
527        builder.acting_as(Authority::RuntimePolicy);
528
529        if policy.user.is_some() && !policy.passwd_group {
530            warnings.push(Warning {
531                code: code::USER_WITHOUT_PASSWD_GROUP,
532                message: "--user was given without passwd/group files".to_string(),
533                details: vec![
534                    "Add --passwd-group (or --preset web) if the application resolves its own uid."
535                        .to_string(),
536                ],
537            });
538        }
539        Ok(())
540    }
541
542    /// The first CA bundle the source root actually has. A `web` preset that
543    /// silently shipped no trust store would fail at the first HTTPS request.
544    fn plan_ca_certificates(&self, builder: &mut PlanBuilder<'_>) -> Result<()> {
545        for candidate in RuntimePolicy::CA_BUNDLE_CANDIDATES {
546            let logical = PathBuf::from(candidate);
547            let found = builder.copy_path(
548                &logical,
549                PlannedFileKind::CertificateBundle,
550                InclusionReason::RuntimePolicy {
551                    feature: RuntimeFeature::CaCertificates,
552                },
553                false,
554            )?;
555            if found {
556                return Ok(());
557            }
558        }
559        Err(Error::MissingRuntimeFile {
560            feature: "ca-certificates",
561            searched: RuntimePolicy::CA_BUNDLE_CANDIDATES
562                .iter()
563                .map(PathBuf::from)
564                .collect(),
565        })
566    }
567
568    fn plan_passwd_group(&self, builder: &mut PlanBuilder<'_>) {
569        let reason = InclusionReason::RuntimePolicy {
570            feature: RuntimeFeature::PasswdGroup,
571        };
572        builder.push_generated(
573            Path::new("/etc/passwd"),
574            self.runtime_policy.passwd_contents(),
575            reason.clone(),
576        );
577        builder.push_generated(
578            Path::new("/etc/group"),
579            self.runtime_policy.group_contents(),
580            reason,
581        );
582    }
583
584    /// The zone database, plus `/etc/localtime` when the source root sets one.
585    fn plan_tzdata(&self, builder: &mut PlanBuilder<'_>) -> Result<()> {
586        let reason = InclusionReason::RuntimePolicy {
587            feature: RuntimeFeature::Tzdata,
588        };
589        let zoneinfo = PathBuf::from("/usr/share/zoneinfo");
590        let found = builder.copy_path(
591            &zoneinfo,
592            PlannedFileKind::ApplicationData,
593            reason.clone(),
594            true,
595        )?;
596        if !found {
597            return Err(Error::MissingRuntimeFile {
598                feature: "tzdata",
599                searched: vec![zoneinfo],
600            });
601        }
602        // A missing /etc/localtime is not an error: UTC is a valid default.
603        builder.copy_path(
604            Path::new("/etc/localtime"),
605            PlannedFileKind::RuntimeConfig,
606            reason,
607            false,
608        )?;
609        Ok(())
610    }
611
612    fn plan_include(&self, builder: &mut PlanBuilder<'_>, include: &Path) -> Result<()> {
613        let logical = normalize_absolute(include);
614        let found = builder.copy_path(
615            &logical,
616            PlannedFileKind::ApplicationData,
617            InclusionReason::ExplicitInclude,
618            true,
619        )?;
620        if found {
621            return Ok(());
622        }
623        Err(Error::MissingSourcePath { path: logical })
624    }
625}
626
627/// Report objects the generated `/etc/ld.so.cache` cannot describe unambiguously.
628///
629/// One cache serves every application in the bundle. If two closures hold
630/// different files under the same soname, the cache can name only one, and an
631/// application that reaches the cache loads a library it was never analyzed
632/// against. This is a warning rather than an error because the cache is the
633/// *last* place glibc looks: a closure that finds its own copy through
634/// `DT_RPATH` or `DT_RUNPATH` never consults it and is unaffected.
635fn warn_ambiguous_sonames<'a>(
636    graphs: impl IntoIterator<Item = &'a DependencyGraph>,
637    warnings: &mut Vec<Warning>,
638) {
639    let mut by_soname: BTreeMap<String, Vec<(&Path, &crate::graph::Digest)>> = BTreeMap::new();
640    for graph in graphs {
641        if !uses_glibc_loader(graph) {
642            continue;
643        }
644        for node in &graph.nodes {
645            if !matches!(node.kind, NodeKind::SharedObject | NodeKind::Interpreter) {
646                continue;
647            }
648            let soname = cache_soname(node);
649            if soname.is_empty() {
650                continue;
651            }
652            let candidates = by_soname.entry(soname).or_default();
653            let candidate = (node.destination.as_path(), &node.sha256);
654            if !candidates.contains(&candidate) {
655                candidates.push(candidate);
656            }
657        }
658    }
659
660    let details: Vec<String> = by_soname
661        .into_iter()
662        .filter(|(_, candidates)| {
663            // Identical bytes at two paths are not ambiguous: either answer is
664            // the same library.
665            candidates
666                .iter()
667                .any(|(_, digest)| *digest != candidates[0].1)
668        })
669        .map(|(soname, candidates)| {
670            let paths: Vec<String> = candidates
671                .iter()
672                .map(|(path, _)| path.display().to_string())
673                .collect();
674            format!("{soname}: {}", paths.join(", "))
675        })
676        .collect();
677
678    if details.is_empty() {
679        return;
680    }
681    warnings.push(Warning {
682        code: code::LOADER_CACHE_AMBIGUOUS,
683        message: format!(
684            "{} soname(s) name different files; the generated /etc/ld.so.cache lists one of each",
685            details.len()
686        ),
687        details,
688    });
689}
690
691/// The name a cache entry is looked up by: `DT_SONAME`, or the file name for a
692/// library that declares none, which is what its dependents recorded.
693///
694/// This is the single definition of that rule; [`Planner::ld_so_cache_many`]
695/// builds its entries from it, so the warning above and the cache itself can
696/// never disagree about what a library is called.
697fn cache_soname(node: &Node) -> String {
698    match node.soname.as_deref() {
699        Some(soname) if !soname.is_empty() => soname.to_string(),
700        _ => node
701            .destination
702            .file_name()
703            .map(|name| name.to_string_lossy().into_owned())
704            .unwrap_or_default(),
705    }
706}
707
708/// Reject a destination two non-scaffolding entries wanted incompatibly.
709///
710/// One case is legitimate and stays silent: runtime policy keeps its place
711/// against the same path arriving inside an `--include` tree, which is what
712/// lets `--include /etc` coexist with generated files and policy directories.
713/// Everything else means the caller asked for two different things in one
714/// place, and the bundle can only express one of them.
715fn check_destination_conflicts(conflicts: &[Conflict]) -> Result<()> {
716    for conflict in conflicts {
717        let documented = conflict.kept.1 == Authority::RuntimePolicy
718            && conflict.dropped.1 == Authority::IncludedTree;
719        if documented {
720            continue;
721        }
722        return Err(Error::Config {
723            message: format!(
724                "`{}` is planned both as {} and as {}; only one entry can occupy a path",
725                conflict.destination.display(),
726                conflict.kept.0.as_str(),
727                conflict.dropped.0.as_str(),
728            ),
729        });
730    }
731    Ok(())
732}
733
734/// Reject a plan whose entries nest inside something that is not a directory.
735///
736/// Directory output would fail part-way through and tar output would silently
737/// write through the symlink or file instead, so the two backends would stop
738/// describing the same tree. Entries are sorted by destination, so every parent
739/// has already been seen by the time its children are.
740fn check_nesting(files: &[PlannedFile]) -> Result<()> {
741    let mut by_destination: BTreeMap<&Path, PlannedFileKind> = BTreeMap::new();
742    for file in files {
743        let parent = logical_parent(&file.destination);
744        if let Some(&kind) = by_destination.get(parent.as_path())
745            && kind != PlannedFileKind::Directory
746        {
747            return Err(Error::Config {
748                message: format!(
749                    "`{}` would be created inside `{}`, which is planned as {}",
750                    file.destination.display(),
751                    parent.display(),
752                    kind.as_str(),
753                ),
754            });
755        }
756        by_destination.insert(&file.destination, file.kind);
757    }
758    Ok(())
759}
760
761#[derive(Debug)]
762enum ClosureEntry {
763    Regular {
764        digest: String,
765        kind: NodeKind,
766        source: PathBuf,
767    },
768    Symlink {
769        target: PathBuf,
770        source: PathBuf,
771    },
772}
773
774impl ClosureEntry {
775    fn is_compatible_with(&self, other: &ClosureEntry) -> bool {
776        match (self, other) {
777            (
778                ClosureEntry::Regular {
779                    digest: left,
780                    kind: left_kind,
781                    ..
782                },
783                ClosureEntry::Regular {
784                    digest: right,
785                    kind: right_kind,
786                    ..
787                },
788            ) => left_kind == right_kind && *left_kind != NodeKind::Executable && left == right,
789            (
790                ClosureEntry::Symlink { target: left, .. },
791                ClosureEntry::Symlink { target: right, .. },
792            ) => left == right,
793            _ => false,
794        }
795    }
796
797    fn source(&self) -> &Path {
798        match self {
799            ClosureEntry::Regular { source, .. } | ClosureEntry::Symlink { source, .. } => source,
800        }
801    }
802}
803
804/// Every application closure shares one output namespace. Identical libraries
805/// and links are deduplicated, while executable or content collisions would
806/// make at least one application differ from the plan and are rejected.
807fn check_closure_collisions(resolved: &[(Resolver, DependencyGraph)]) -> Result<()> {
808    let mut entries = BTreeMap::<PathBuf, ClosureEntry>::new();
809    for (_, graph) in resolved {
810        for (_, node) in graph.iter() {
811            insert_closure_entry(
812                &mut entries,
813                node.destination.clone(),
814                ClosureEntry::Regular {
815                    digest: node.sha256.0.clone(),
816                    kind: node.kind,
817                    source: node.logical.clone(),
818                },
819            )?;
820            for link in &node.links {
821                insert_closure_entry(
822                    &mut entries,
823                    link.logical.clone(),
824                    ClosureEntry::Symlink {
825                        target: link.target.clone(),
826                        source: link.logical.clone(),
827                    },
828                )?;
829            }
830        }
831    }
832    Ok(())
833}
834
835fn insert_closure_entry(
836    entries: &mut BTreeMap<PathBuf, ClosureEntry>,
837    destination: PathBuf,
838    incoming: ClosureEntry,
839) -> Result<()> {
840    if let Some(existing) = entries.get(&destination) {
841        if existing.is_compatible_with(&incoming) {
842            return Ok(());
843        }
844        return Err(Error::Config {
845            message: format!(
846                "bundle path `{}` collides between `{}` and `{}`",
847                destination.display(),
848                existing.source().display(),
849                incoming.source().display()
850            ),
851        });
852    }
853    entries.insert(destination, incoming);
854    Ok(())
855}
856
857fn deduplicate_warnings(warnings: &mut Vec<Warning>) {
858    let mut seen = BTreeSet::new();
859    warnings.retain(|warning| {
860        seen.insert((
861            warning.code,
862            warning.message.clone(),
863            warning.details.clone(),
864        ))
865    });
866}
867
868/// Why an object is in the bundle, as recorded for the manifest.
869fn inclusion_reason(graph: &DependencyGraph, id: NodeId, node: &Node) -> InclusionReason {
870    if id == graph.root {
871        return InclusionReason::Application;
872    }
873    if node.kind == NodeKind::Interpreter {
874        return InclusionReason::Interpreter;
875    }
876    match graph.first_dependent(id) {
877        Some((edge, parent)) => match &edge.reason {
878            DependencyReason::Needed { soname } => InclusionReason::NeededBy {
879                binary: parent.destination.clone(),
880                soname: soname.clone(),
881            },
882            DependencyReason::Interpreter => InclusionReason::Interpreter,
883            DependencyReason::RuntimePolicy { feature } => {
884                InclusionReason::RuntimePolicy { feature: *feature }
885            }
886        },
887        // Unreachable in a graph built by the resolver: only the executable has
888        // no dependent, and it was handled above.
889        None => InclusionReason::Application,
890    }
891}
892
893fn planned_kind(kind: NodeKind) -> PlannedFileKind {
894    match kind {
895        NodeKind::Executable => PlannedFileKind::Executable,
896        NodeKind::Interpreter => PlannedFileKind::Interpreter,
897        NodeKind::SharedObject => PlannedFileKind::SharedObject,
898    }
899}
900
901/// How a library is named on the command line: its `DT_SONAME` when it has one,
902/// otherwise its file name.
903fn library_name(node: &Node) -> String {
904    node.soname
905        .clone()
906        .or_else(|| {
907            node.logical
908                .file_name()
909                .map(|name| name.to_string_lossy().into_owned())
910        })
911        .unwrap_or_default()
912}
913
914/// Libraries that resolved through something the bundle does not reproduce.
915fn unreachable_libraries(resolver: &Resolver) -> Vec<String> {
916    resolver
917        .notes()
918        .iter()
919        .map(|note| {
920            format!(
921                "{} in {} (found through {})",
922                note.soname,
923                note.directory.display(),
924                note.origin.as_str()
925            )
926        })
927        .collect()
928}
929
930/// `$ORIGIN`-relative search paths of an executable that is being installed
931/// somewhere other than where it was built. They point somewhere else now.
932fn relocated_search_paths(graph: &DependencyGraph) -> Vec<String> {
933    let source_dir = logical_parent(&graph.root_node().logical);
934    let install_dir = logical_parent(&graph.root_node().destination);
935    if install_dir == source_dir {
936        return Vec::new();
937    }
938    graph
939        .executable_search_paths
940        .iter()
941        .filter(|entry| entry.contains("$ORIGIN") || entry.contains("${ORIGIN}"))
942        .cloned()
943        .collect()
944}
945
946fn warn_unreachable(libraries: Vec<String>, glibc: bool) -> Warning {
947    assert!(!libraries.is_empty());
948
949    let explanation = if glibc {
950        format!(
951            "Without {LD_SO_CACHE} the packaged application finds these \
952             only if its DT_RPATH/DT_RUNPATH covers them."
953        )
954    } else {
955        "This loader does not read an ld.so.cache, so the paths have to \
956         come from the objects themselves."
957            .to_string()
958    };
959    Warning {
960        code: code::LIBRARY_UNREACHABLE,
961        message: match libraries.len() {
962            1 => "a library lives outside the directories the loader searches".to_string(),
963            n => format!("{n} libraries live outside the directories the loader searches"),
964        },
965        details: libraries.into_iter().chain([explanation]).collect(),
966    }
967}
968
969fn warn_relocated(paths: Vec<String>, graph: &DependencyGraph) -> Warning {
970    assert!(!paths.is_empty());
971
972    let source_dir = logical_parent(&graph.root_node().logical);
973    let install_dir = logical_parent(&graph.root_node().destination);
974    assert_ne!(source_dir, install_dir);
975
976    let advice = format!(
977        "Install it at {} to keep those paths pointing where they did.",
978        graph.root_node().logical.display()
979    );
980    Warning {
981        code: code::EXECUTABLE_RELOCATED,
982        message: format!(
983            "the executable declares $ORIGIN-relative search paths and moves from {} to {}",
984            source_dir.display(),
985            install_dir.display()
986        ),
987        details: paths.into_iter().chain([advice]).collect(),
988    }
989}
990
991fn warn_dlopen_executable(node: &Node) -> Warning {
992    assert!(!node.dlopen_references.is_empty());
993
994    Warning {
995        code: code::DLOPEN,
996        message: format!("{} references dlopen()", node.destination.display()),
997        details: vec![
998            "Runtime-loaded libraries cannot be determined using static ELF dependency analysis."
999                .to_string(),
1000            "Consider adding them with --include.".to_string(),
1001        ],
1002    }
1003}
1004
1005/// Whether `PT_INTERP` is a glibc loader, and therefore whether an
1006/// `ld.so.cache` means anything to the packaged application.
1007fn uses_glibc_loader(graph: &DependencyGraph) -> bool {
1008    match &graph.declared_interpreter {
1009        Some(interpreter) => !interpreter
1010            .file_name()
1011            .map(|name| name.to_string_lossy().contains("ld-musl"))
1012            .unwrap_or(false),
1013        // A static binary has no loader, and no libraries for a cache to name.
1014        None => false,
1015    }
1016}
1017
1018/// Normalized permissions: executables and directories are `0755`, everything
1019/// else `0644`.
1020fn mode_of(path: &Path) -> Result<u32> {
1021    use std::os::unix::fs::PermissionsExt;
1022
1023    let metadata = std::fs::metadata(path).map_err(|e| io(path, e))?;
1024    let mode = metadata.permissions().mode();
1025    let normalized = if metadata.is_dir() || mode & 0o111 != 0 {
1026        0o755
1027    } else {
1028        0o644
1029    };
1030    Ok(normalized)
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036    use crate::{
1037        elf::{Architecture, ElfClass, Endianness, Machine},
1038        graph::Node,
1039        hash::sha256_bytes,
1040    };
1041
1042    fn graph_with_interpreter(interpreter: Option<&str>) -> DependencyGraph {
1043        let architecture = Architecture {
1044            machine: Machine::X86_64,
1045            class: ElfClass::Elf64,
1046            endianness: Endianness::Little,
1047        };
1048        let node = |kind, logical: &str, soname: Option<&str>| Node {
1049            source: PathBuf::from(logical),
1050            logical: PathBuf::from(logical),
1051            destination: PathBuf::from(logical),
1052            kind,
1053            soname: soname.map(str::to_string),
1054            architecture,
1055            // A real digest: the graph asserts that every node carries one.
1056            sha256: sha256_bytes(logical.as_bytes()),
1057            size: 0,
1058            links: Vec::new(),
1059            dlopen_references: Vec::new(),
1060        };
1061
1062        let mut graph = DependencyGraph::new();
1063        graph.root = graph
1064            .insert(node(NodeKind::Executable, "/app/server", None))
1065            .unwrap();
1066        graph.declared_interpreter = interpreter.map(PathBuf::from);
1067        if let Some(interpreter) = interpreter {
1068            graph
1069                .insert(node(NodeKind::Interpreter, interpreter, Some("ld.so")))
1070                .unwrap();
1071        }
1072        graph
1073            .insert(node(
1074                NodeKind::SharedObject,
1075                "/opt/vendor/lib/libvendor.so.1",
1076                Some("libvendor.so.1"),
1077            ))
1078            .unwrap();
1079        graph
1080    }
1081
1082    fn planner() -> Planner {
1083        Planner::new(SourceRoot::new("/"), "/app/server")
1084    }
1085
1086    #[test]
1087    fn closure_entries_require_matching_regular_file_kinds() {
1088        let digest = sha256_bytes(b"same bytes").0;
1089        let mut entries = BTreeMap::new();
1090        insert_closure_entry(
1091            &mut entries,
1092            PathBuf::from("/lib/same.so"),
1093            ClosureEntry::Regular {
1094                digest: digest.clone(),
1095                kind: NodeKind::Interpreter,
1096                source: PathBuf::from("/lib/ld.so"),
1097            },
1098        )
1099        .unwrap();
1100
1101        let error = insert_closure_entry(
1102            &mut entries,
1103            PathBuf::from("/lib/same.so"),
1104            ClosureEntry::Regular {
1105                digest,
1106                kind: NodeKind::SharedObject,
1107                source: PathBuf::from("/lib/libsame.so"),
1108            },
1109        )
1110        .unwrap_err();
1111
1112        assert!(error.to_string().contains("/lib/same.so"), "{error}");
1113    }
1114
1115    #[test]
1116    fn a_glibc_bundle_gets_a_cache_naming_its_libraries() {
1117        let graph = graph_with_interpreter(Some("/lib64/ld-linux-x86-64.so.2"));
1118        let bytes = planner().ld_so_cache(&graph).expect("a cache is built");
1119
1120        let cache = crate::resolver::LdCache::parse(&bytes);
1121        assert_eq!(
1122            cache.lookup("libvendor.so.1"),
1123            [PathBuf::from("/opt/vendor/lib/libvendor.so.1")]
1124        );
1125        assert!(
1126            !cache.lookup("ld.so").is_empty(),
1127            "the interpreter is listed too, as ldconfig lists it"
1128        );
1129    }
1130
1131    #[test]
1132    fn a_musl_bundle_gets_no_cache() {
1133        let graph = graph_with_interpreter(Some("/lib/ld-musl-x86_64.so.1"));
1134        assert!(planner().ld_so_cache(&graph).is_none());
1135        assert!(!uses_glibc_loader(&graph));
1136    }
1137
1138    #[test]
1139    fn a_static_binary_gets_no_cache() {
1140        let mut graph = graph_with_interpreter(None);
1141        graph.nodes.retain(|node| node.kind == NodeKind::Executable);
1142        assert!(planner().ld_so_cache(&graph).is_none());
1143    }
1144}