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::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 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 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 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 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 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 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 #[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 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 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 fn validate_dependencies(&self, graph: &DependencyGraph, install_path: &Path) -> Result<()> {
456 if self.dependency_policy.allow.is_none() {
457 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 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 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 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 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 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
667fn 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
731fn 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 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
764fn 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
777fn 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
793fn 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
868fn 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 None => false,
878 }
879}
880
881fn 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 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}