1use 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
31pub const LD_SO_CACHE: &str = "/etc/ld.so.cache";
34
35pub 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 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 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 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 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 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 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 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 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 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 #[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 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 fn validate_dependencies(&self, graph: &DependencyGraph, install_path: &Path) -> Result<()> {
457 if self.dependency_policy.allow.is_none() {
458 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 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 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 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 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 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
627fn 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 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
691fn 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
708fn 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
734fn 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
804fn 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
868fn 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 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
901fn 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
914fn 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
930fn 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
1005fn 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 None => false,
1015 }
1016}
1017
1018fn 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 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}