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::path::{Path, PathBuf};
19
20mod builder;
21mod model;
22
23use builder::PlanBuilder;
24pub use model::{BundlePlan, InclusionReason, PlannedFile, PlannedFileKind, Warning};
25
26pub const LD_SO_CACHE: &str = "/etc/ld.so.cache";
29
30pub const PLAN_ENTRIES_MAX: usize = 1 << 20;
37
38#[derive(Debug)]
39pub struct Planner {
40 source_root: SourceRoot,
41 binary: PathBuf,
42 install_path: PathBuf,
43 runtime_policy: RuntimePolicy,
44 dependency_policy: DependencyPolicy,
45 library_paths: Vec<PathBuf>,
46 preset: Option<Preset>,
47}
48
49impl Planner {
50 pub fn new(source_root: SourceRoot, binary: impl Into<PathBuf>) -> Planner {
51 let binary = binary.into();
52 let install_path = PathBuf::from("/").join(
53 binary
54 .file_name()
55 .map(PathBuf::from)
56 .unwrap_or_else(|| PathBuf::from("app")),
57 );
58 Planner {
59 source_root,
60 binary,
61 install_path,
62 runtime_policy: RuntimePolicy::default(),
63 dependency_policy: DependencyPolicy::allow_all(),
64 library_paths: Vec::new(),
65 preset: None,
66 }
67 }
68
69 pub fn preset(mut self, preset: Preset) -> Planner {
71 self.preset = Some(preset);
72 self.runtime_policy = RuntimePolicy::from_preset(preset);
73 self
74 }
75
76 pub fn install_as(mut self, path: impl Into<PathBuf>) -> Planner {
77 self.install_path = normalize_absolute(&path.into());
78 self
79 }
80
81 pub fn runtime_policy(mut self, policy: RuntimePolicy) -> Planner {
82 self.runtime_policy = policy;
83 self
84 }
85
86 pub fn dependency_policy(mut self, policy: DependencyPolicy) -> Planner {
87 self.dependency_policy = policy;
88 self
89 }
90
91 pub fn library_paths(mut self, paths: Vec<PathBuf>) -> Planner {
92 self.library_paths = paths;
93 self
94 }
95
96 pub fn plan(&self) -> Result<BundlePlan> {
100 self.check_install_path()?;
101
102 let mut resolver =
103 Resolver::new(self.source_root.clone()).with_library_paths(self.library_paths.clone());
104 let mut graph = resolver.closure(&self.binary, &self.install_path)?;
105 if self.runtime_policy.nsswitch {
106 self.attach_nss_modules(&mut resolver, &mut graph)?;
107 }
108
109 self.validate_dependencies(&graph)?;
110 self.check_install_collision(&graph)?;
111
112 let mut warnings: Vec<Warning> = Vec::new();
113 let mut builder = PlanBuilder::new(&self.source_root);
114 self.plan_loader_cache(&graph, &resolver, &mut builder, &mut warnings);
115 self.plan_closure(&graph, &mut builder, &mut warnings)?;
116 self.apply_runtime_policy(&mut builder, &mut warnings)?;
117
118 let files = builder.finish();
119 let executable = files
120 .iter()
121 .find(|f| f.kind == PlannedFileKind::Executable)
122 .cloned()
123 .expect("the plan always contains the executable");
124
125 assert_eq!(executable.destination, graph.root_node().destination);
128 assert!(files.len() >= graph.node_count());
129
130 Ok(BundlePlan {
131 executable,
132 architecture: graph.root_node().architecture,
133 interpreter: graph.declared_interpreter.clone(),
134 interpreter_resolved: graph
135 .nodes
136 .iter()
137 .find(|n| n.kind == NodeKind::Interpreter)
138 .map(|n| n.destination.clone()),
139 files,
140 graph,
141 preset: self.preset,
142 runtime_policy: self.runtime_policy.clone(),
143 dependency_policy: self.dependency_policy.clone(),
144 warnings,
145 })
146 }
147
148 fn check_install_path(&self) -> Result<()> {
150 assert!(self.install_path.is_absolute());
151
152 if self.install_path.file_name().is_some() {
153 return Ok(());
154 }
155 Err(Error::Config {
156 message: format!(
157 "install path `{}` does not name a file",
158 self.install_path.display()
159 ),
160 })
161 }
162
163 fn attach_nss_modules(
167 &self,
168 resolver: &mut Resolver,
169 graph: &mut DependencyGraph,
170 ) -> Result<()> {
171 assert!(self.runtime_policy.nsswitch);
172
173 let root_id = graph.root;
174 let architecture = graph.root_node().architecture;
175 for soname in RuntimePolicy::NSS_MODULES {
176 let requester = graph.root_node().logical.clone();
177 let Some(library) = resolver.resolve_extra_library(soname, architecture, &requester)?
178 else {
179 continue;
180 };
181 resolver.attach_library(
182 graph,
183 &library,
184 root_id,
185 DependencyReason::RuntimePolicy {
186 feature: RuntimeFeature::Nsswitch,
187 },
188 )?;
189 }
190 Ok(())
191 }
192
193 fn plan_loader_cache(
201 &self,
202 graph: &DependencyGraph,
203 resolver: &Resolver,
204 builder: &mut PlanBuilder<'_>,
205 warnings: &mut Vec<Warning>,
206 ) {
207 let unreachable = unreachable_libraries(resolver);
208 let relocated = relocated_search_paths(graph);
209 let needs_cache = !unreachable.is_empty() || !relocated.is_empty();
210
211 let cache = self
212 .runtime_policy
213 .ld_so_cache
214 .applies(needs_cache)
215 .then(|| self.ld_so_cache(graph))
216 .flatten();
217
218 if let Some(bytes) = cache {
219 builder.push_generated(
220 Path::new(LD_SO_CACHE),
221 bytes,
222 InclusionReason::RuntimePolicy {
223 feature: RuntimeFeature::LdSoCache,
224 },
225 );
226 return;
227 }
228
229 if !unreachable.is_empty() {
231 warnings.push(warn_unreachable(unreachable, uses_glibc_loader(graph)));
232 }
233 if !relocated.is_empty() {
234 warnings.push(warn_relocated(relocated, graph));
235 }
236 }
237
238 fn plan_closure(
241 &self,
242 graph: &DependencyGraph,
243 builder: &mut PlanBuilder<'_>,
244 warnings: &mut Vec<Warning>,
245 ) -> Result<()> {
246 let mut dlopen_libraries: Vec<String> = Vec::new();
247
248 for (id, node) in graph.iter() {
249 let reason = inclusion_reason(graph, id, node);
250 builder.push_file(PlannedFile {
251 source: Some(node.source.clone()),
252 destination: node.destination.clone(),
253 kind: planned_kind(node.kind),
254 reason: reason.clone(),
255 mode: mode_of(&node.source)?,
256 size: node.size,
257 sha256: Some(node.sha256.clone()),
258 link_target: None,
259 content: None,
260 });
261 for link in &node.links {
262 builder.push_symlink(&link.logical, &link.target, reason.clone());
263 }
264
265 if node.dlopen_references.is_empty() {
266 continue;
267 }
268 if id == graph.root {
269 warnings.push(warn_dlopen_executable(node));
270 } else {
271 dlopen_libraries.push(node.destination.display().to_string());
272 }
273 }
274
275 if !dlopen_libraries.is_empty() {
276 warnings.push(Warning {
277 code: code::DLOPEN,
278 message: format!(
279 "{} bundled shared object(s) reference dlopen()",
280 dlopen_libraries.len()
281 ),
282 details: dlopen_libraries,
283 });
284 }
285 Ok(())
286 }
287
288 fn ld_so_cache(&self, graph: &DependencyGraph) -> Option<Vec<u8>> {
294 if !uses_glibc_loader(graph) {
295 return None;
298 }
299 let architecture = graph.root_node().architecture;
300 let entries: Vec<CacheEntry> = graph
301 .nodes
302 .iter()
303 .filter(|node| matches!(node.kind, NodeKind::SharedObject | NodeKind::Interpreter))
304 .map(|node| CacheEntry {
305 soname: node.soname.clone().unwrap_or_else(|| {
308 node.destination
309 .file_name()
310 .map(|name| name.to_string_lossy().into_owned())
311 .unwrap_or_default()
312 }),
313 path: node.destination.clone(),
314 })
315 .filter(|entry| !entry.soname.is_empty())
316 .collect();
317 if entries.is_empty() {
318 return None;
319 }
320 cache::build(&architecture, &entries)
321 }
322
323 fn check_install_collision(&self, graph: &DependencyGraph) -> Result<()> {
326 let install = &graph.root_node().destination;
327 assert!(install.is_absolute());
328
329 for (id, node) in graph.iter() {
330 if id == graph.root {
331 continue;
332 }
333 if &node.destination != install {
334 continue;
335 }
336 return Err(Error::Config {
337 message: format!(
338 "install path `{}` collides with `{}`, which the closure \
339 needs at that exact path",
340 self.install_path.display(),
341 node.logical.display()
342 ),
343 });
344 }
345 Ok(())
346 }
347
348 fn validate_dependencies(&self, graph: &DependencyGraph) -> Result<()> {
355 if self.dependency_policy.allow.is_none() {
356 return Ok(());
358 }
359
360 let application = graph.application_closure();
361
362 for (id, node) in graph.iter() {
363 if node.kind != NodeKind::SharedObject {
364 continue;
365 }
366 if !application.contains(&id) {
367 continue;
368 }
369 let soname = library_name(node);
370 if self.dependency_policy.is_allowed(&soname, &node.logical) {
371 continue;
372 }
373 let required_by = graph
374 .first_dependent(id)
375 .map(|(_, parent)| parent.destination.clone())
376 .unwrap_or_else(|| self.install_path.clone());
377 return Err(Error::DisallowedLibrary {
378 soname,
379 required_by,
380 });
381 }
382 Ok(())
383 }
384
385 fn apply_runtime_policy(
387 &self,
388 builder: &mut PlanBuilder<'_>,
389 warnings: &mut Vec<Warning>,
390 ) -> Result<()> {
391 let policy = &self.runtime_policy;
392
393 if policy.ca_certificates {
394 self.plan_ca_certificates(builder)?;
395 }
396 if policy.tmp {
397 builder.push_dir_with_mode(
399 Path::new("/tmp"),
400 0o1777,
401 InclusionReason::RuntimePolicy {
402 feature: RuntimeFeature::Tmp,
403 },
404 );
405 }
406 if policy.passwd_group {
407 self.plan_passwd_group(builder);
408 }
409 if policy.nsswitch {
410 builder.push_generated(
411 Path::new("/etc/nsswitch.conf"),
412 policy.nsswitch_contents(),
413 InclusionReason::RuntimePolicy {
414 feature: RuntimeFeature::Nsswitch,
415 },
416 );
417 }
418 if policy.tzdata {
419 self.plan_tzdata(builder)?;
420 }
421 for include in &policy.includes {
422 self.plan_include(builder, include)?;
423 }
424
425 if policy.user.is_some() && !policy.passwd_group {
426 warnings.push(Warning {
427 code: code::USER_WITHOUT_PASSWD_GROUP,
428 message: "--user was given without passwd/group files".to_string(),
429 details: vec![
430 "Add --passwd-group (or --preset web) if the application resolves its own uid."
431 .to_string(),
432 ],
433 });
434 }
435 Ok(())
436 }
437
438 fn plan_ca_certificates(&self, builder: &mut PlanBuilder<'_>) -> Result<()> {
441 for candidate in RuntimePolicy::CA_BUNDLE_CANDIDATES {
442 let logical = PathBuf::from(candidate);
443 let found = builder.copy_path(
444 &logical,
445 PlannedFileKind::CertificateBundle,
446 InclusionReason::RuntimePolicy {
447 feature: RuntimeFeature::CaCertificates,
448 },
449 false,
450 )?;
451 if found {
452 return Ok(());
453 }
454 }
455 Err(Error::MissingRuntimeFile {
456 feature: "ca-certificates",
457 searched: RuntimePolicy::CA_BUNDLE_CANDIDATES
458 .iter()
459 .map(PathBuf::from)
460 .collect(),
461 })
462 }
463
464 fn plan_passwd_group(&self, builder: &mut PlanBuilder<'_>) {
465 let reason = InclusionReason::RuntimePolicy {
466 feature: RuntimeFeature::PasswdGroup,
467 };
468 builder.push_generated(
469 Path::new("/etc/passwd"),
470 self.runtime_policy.passwd_contents(),
471 reason.clone(),
472 );
473 builder.push_generated(
474 Path::new("/etc/group"),
475 self.runtime_policy.group_contents(),
476 reason,
477 );
478 }
479
480 fn plan_tzdata(&self, builder: &mut PlanBuilder<'_>) -> Result<()> {
482 let reason = InclusionReason::RuntimePolicy {
483 feature: RuntimeFeature::Tzdata,
484 };
485 let zoneinfo = PathBuf::from("/usr/share/zoneinfo");
486 let found = builder.copy_path(
487 &zoneinfo,
488 PlannedFileKind::ApplicationData,
489 reason.clone(),
490 true,
491 )?;
492 if !found {
493 return Err(Error::MissingRuntimeFile {
494 feature: "tzdata",
495 searched: vec![zoneinfo],
496 });
497 }
498 builder.copy_path(
500 Path::new("/etc/localtime"),
501 PlannedFileKind::RuntimeConfig,
502 reason,
503 false,
504 )?;
505 Ok(())
506 }
507
508 fn plan_include(&self, builder: &mut PlanBuilder<'_>, include: &Path) -> Result<()> {
509 let logical = normalize_absolute(include);
510 let found = builder.copy_path(
511 &logical,
512 PlannedFileKind::ApplicationData,
513 InclusionReason::ExplicitInclude,
514 true,
515 )?;
516 if found {
517 return Ok(());
518 }
519 Err(Error::MissingSourcePath { path: logical })
520 }
521}
522
523fn inclusion_reason(graph: &DependencyGraph, id: NodeId, node: &Node) -> InclusionReason {
525 if id == graph.root {
526 return InclusionReason::Application;
527 }
528 if node.kind == NodeKind::Interpreter {
529 return InclusionReason::Interpreter;
530 }
531 match graph.first_dependent(id) {
532 Some((edge, parent)) => match &edge.reason {
533 DependencyReason::Needed { soname } => InclusionReason::NeededBy {
534 binary: parent.destination.clone(),
535 soname: soname.clone(),
536 },
537 DependencyReason::Interpreter => InclusionReason::Interpreter,
538 DependencyReason::RuntimePolicy { feature } => {
539 InclusionReason::RuntimePolicy { feature: *feature }
540 }
541 },
542 None => InclusionReason::Application,
545 }
546}
547
548fn planned_kind(kind: NodeKind) -> PlannedFileKind {
549 match kind {
550 NodeKind::Executable => PlannedFileKind::Executable,
551 NodeKind::Interpreter => PlannedFileKind::Interpreter,
552 NodeKind::SharedObject => PlannedFileKind::SharedObject,
553 }
554}
555
556fn library_name(node: &Node) -> String {
559 node.soname
560 .clone()
561 .or_else(|| {
562 node.logical
563 .file_name()
564 .map(|name| name.to_string_lossy().into_owned())
565 })
566 .unwrap_or_default()
567}
568
569fn unreachable_libraries(resolver: &Resolver) -> Vec<String> {
571 resolver
572 .notes()
573 .iter()
574 .map(|note| {
575 format!(
576 "{} in {} (found through {})",
577 note.soname,
578 note.directory.display(),
579 note.origin.as_str()
580 )
581 })
582 .collect()
583}
584
585fn relocated_search_paths(graph: &DependencyGraph) -> Vec<String> {
588 let source_dir = logical_parent(&graph.root_node().logical);
589 let install_dir = logical_parent(&graph.root_node().destination);
590 if install_dir == source_dir {
591 return Vec::new();
592 }
593 graph
594 .executable_search_paths
595 .iter()
596 .filter(|entry| entry.contains("$ORIGIN") || entry.contains("${ORIGIN}"))
597 .cloned()
598 .collect()
599}
600
601fn warn_unreachable(libraries: Vec<String>, glibc: bool) -> Warning {
602 assert!(!libraries.is_empty());
603
604 let explanation = if glibc {
605 format!(
606 "Without {LD_SO_CACHE} the packaged application finds these \
607 only if its DT_RPATH/DT_RUNPATH covers them."
608 )
609 } else {
610 "This loader does not read an ld.so.cache, so the paths have to \
611 come from the objects themselves."
612 .to_string()
613 };
614 Warning {
615 code: code::LIBRARY_UNREACHABLE,
616 message: match libraries.len() {
617 1 => "a library lives outside the directories the loader searches".to_string(),
618 n => format!("{n} libraries live outside the directories the loader searches"),
619 },
620 details: libraries.into_iter().chain([explanation]).collect(),
621 }
622}
623
624fn warn_relocated(paths: Vec<String>, graph: &DependencyGraph) -> Warning {
625 assert!(!paths.is_empty());
626
627 let source_dir = logical_parent(&graph.root_node().logical);
628 let install_dir = logical_parent(&graph.root_node().destination);
629 assert_ne!(source_dir, install_dir);
630
631 let advice = format!(
632 "Install it at {} to keep those paths pointing where they did.",
633 graph.root_node().logical.display()
634 );
635 Warning {
636 code: code::EXECUTABLE_RELOCATED,
637 message: format!(
638 "the executable declares $ORIGIN-relative search paths and moves from {} to {}",
639 source_dir.display(),
640 install_dir.display()
641 ),
642 details: paths.into_iter().chain([advice]).collect(),
643 }
644}
645
646fn warn_dlopen_executable(node: &Node) -> Warning {
647 assert!(!node.dlopen_references.is_empty());
648
649 Warning {
650 code: code::DLOPEN,
651 message: format!("{} references dlopen()", node.destination.display()),
652 details: vec![
653 "Runtime-loaded libraries cannot be determined using static ELF dependency analysis."
654 .to_string(),
655 "Consider adding them with --include.".to_string(),
656 ],
657 }
658}
659
660fn uses_glibc_loader(graph: &DependencyGraph) -> bool {
663 match &graph.declared_interpreter {
664 Some(interpreter) => !interpreter
665 .file_name()
666 .map(|name| name.to_string_lossy().contains("ld-musl"))
667 .unwrap_or(false),
668 None => false,
670 }
671}
672
673fn mode_of(path: &Path) -> Result<u32> {
676 use std::os::unix::fs::PermissionsExt;
677
678 let metadata = std::fs::metadata(path).map_err(|e| io(path, e))?;
679 let mode = metadata.permissions().mode();
680 let normalized = if metadata.is_dir() || mode & 0o111 != 0 {
681 0o755
682 } else {
683 0o644
684 };
685 Ok(normalized)
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691 use crate::{
692 elf::{Architecture, ElfClass, Endianness, Machine},
693 graph::Node,
694 hash::sha256_bytes,
695 };
696
697 fn graph_with_interpreter(interpreter: Option<&str>) -> DependencyGraph {
698 let architecture = Architecture {
699 machine: Machine::X86_64,
700 class: ElfClass::Elf64,
701 endianness: Endianness::Little,
702 };
703 let node = |kind, logical: &str, soname: Option<&str>| Node {
704 source: PathBuf::from(logical),
705 logical: PathBuf::from(logical),
706 destination: PathBuf::from(logical),
707 kind,
708 soname: soname.map(str::to_string),
709 architecture,
710 sha256: sha256_bytes(logical.as_bytes()),
712 size: 0,
713 links: Vec::new(),
714 dlopen_references: Vec::new(),
715 };
716
717 let mut graph = DependencyGraph::new();
718 graph.root = graph
719 .insert(node(NodeKind::Executable, "/app/server", None))
720 .unwrap();
721 graph.declared_interpreter = interpreter.map(PathBuf::from);
722 if let Some(interpreter) = interpreter {
723 graph
724 .insert(node(NodeKind::Interpreter, interpreter, Some("ld.so")))
725 .unwrap();
726 }
727 graph
728 .insert(node(
729 NodeKind::SharedObject,
730 "/opt/vendor/lib/libvendor.so.1",
731 Some("libvendor.so.1"),
732 ))
733 .unwrap();
734 graph
735 }
736
737 fn planner() -> Planner {
738 Planner::new(SourceRoot::new("/"), "/app/server")
739 }
740
741 #[test]
742 fn a_glibc_bundle_gets_a_cache_naming_its_libraries() {
743 let graph = graph_with_interpreter(Some("/lib64/ld-linux-x86-64.so.2"));
744 let bytes = planner().ld_so_cache(&graph).expect("a cache is built");
745
746 let cache = crate::resolver::LdCache::parse(&bytes);
747 assert_eq!(
748 cache.lookup("libvendor.so.1"),
749 [PathBuf::from("/opt/vendor/lib/libvendor.so.1")]
750 );
751 assert!(
752 !cache.lookup("ld.so").is_empty(),
753 "the interpreter is listed too, as ldconfig lists it"
754 );
755 }
756
757 #[test]
758 fn a_musl_bundle_gets_no_cache() {
759 let graph = graph_with_interpreter(Some("/lib/ld-musl-x86_64.so.1"));
760 assert!(planner().ld_so_cache(&graph).is_none());
761 assert!(!uses_glibc_loader(&graph));
762 }
763
764 #[test]
765 fn a_static_binary_gets_no_cache() {
766 let mut graph = graph_with_interpreter(None);
767 graph.nodes.retain(|node| node.kind == NodeKind::Executable);
768 assert!(planner().ld_so_cache(&graph).is_none());
769 }
770}