use crate::{
diagnostics::warning as code,
error::{Error, Result, io},
graph::{DependencyGraph, DependencyReason, Node, NodeId, NodeKind},
paths::{logical_parent, normalize_absolute},
policy::{DependencyPolicy, Preset, RuntimeFeature, RuntimePolicy},
resolver::{
Resolver,
cache::{self, CacheEntry},
},
source::SourceRoot,
};
use std::path::{Path, PathBuf};
mod builder;
mod model;
use builder::PlanBuilder;
pub use model::{BundlePlan, InclusionReason, PlannedFile, PlannedFileKind, Warning};
pub const LD_SO_CACHE: &str = "/etc/ld.so.cache";
pub const PLAN_ENTRIES_MAX: usize = 1 << 20;
#[derive(Debug)]
pub struct Planner {
source_root: SourceRoot,
binary: PathBuf,
install_path: PathBuf,
runtime_policy: RuntimePolicy,
dependency_policy: DependencyPolicy,
library_paths: Vec<PathBuf>,
preset: Option<Preset>,
}
impl Planner {
pub fn new(source_root: SourceRoot, binary: impl Into<PathBuf>) -> Planner {
let binary = binary.into();
let install_path = PathBuf::from("/").join(
binary
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("app")),
);
Planner {
source_root,
binary,
install_path,
runtime_policy: RuntimePolicy::default(),
dependency_policy: DependencyPolicy::allow_all(),
library_paths: Vec::new(),
preset: None,
}
}
pub fn preset(mut self, preset: Preset) -> Planner {
self.preset = Some(preset);
self.runtime_policy = RuntimePolicy::from_preset(preset);
self
}
pub fn install_as(mut self, path: impl Into<PathBuf>) -> Planner {
self.install_path = normalize_absolute(&path.into());
self
}
pub fn runtime_policy(mut self, policy: RuntimePolicy) -> Planner {
self.runtime_policy = policy;
self
}
pub fn dependency_policy(mut self, policy: DependencyPolicy) -> Planner {
self.dependency_policy = policy;
self
}
pub fn library_paths(mut self, paths: Vec<PathBuf>) -> Planner {
self.library_paths = paths;
self
}
pub fn plan(&self) -> Result<BundlePlan> {
self.check_install_path()?;
let mut resolver =
Resolver::new(self.source_root.clone()).with_library_paths(self.library_paths.clone());
let mut graph = resolver.closure(&self.binary, &self.install_path)?;
if self.runtime_policy.nsswitch {
self.attach_nss_modules(&mut resolver, &mut graph)?;
}
self.validate_dependencies(&graph)?;
self.check_install_collision(&graph)?;
let mut warnings: Vec<Warning> = Vec::new();
let mut builder = PlanBuilder::new(&self.source_root);
self.plan_loader_cache(&graph, &resolver, &mut builder, &mut warnings);
self.plan_closure(&graph, &mut builder, &mut warnings)?;
self.apply_runtime_policy(&mut builder, &mut warnings)?;
let files = builder.finish();
let executable = files
.iter()
.find(|f| f.kind == PlannedFileKind::Executable)
.cloned()
.expect("the plan always contains the executable");
assert_eq!(executable.destination, graph.root_node().destination);
assert!(files.len() >= graph.node_count());
Ok(BundlePlan {
executable,
architecture: graph.root_node().architecture,
interpreter: graph.declared_interpreter.clone(),
interpreter_resolved: graph
.nodes
.iter()
.find(|n| n.kind == NodeKind::Interpreter)
.map(|n| n.destination.clone()),
files,
graph,
preset: self.preset,
runtime_policy: self.runtime_policy.clone(),
dependency_policy: self.dependency_policy.clone(),
warnings,
})
}
fn check_install_path(&self) -> Result<()> {
assert!(self.install_path.is_absolute());
if self.install_path.file_name().is_some() {
return Ok(());
}
Err(Error::Config {
message: format!(
"install path `{}` does not name a file",
self.install_path.display()
),
})
}
fn attach_nss_modules(
&self,
resolver: &mut Resolver,
graph: &mut DependencyGraph,
) -> Result<()> {
assert!(self.runtime_policy.nsswitch);
let root_id = graph.root;
let architecture = graph.root_node().architecture;
for soname in RuntimePolicy::NSS_MODULES {
let requester = graph.root_node().logical.clone();
let Some(library) = resolver.resolve_extra_library(soname, architecture, &requester)?
else {
continue;
};
resolver.attach_library(
graph,
&library,
root_id,
DependencyReason::RuntimePolicy {
feature: RuntimeFeature::Nsswitch,
},
)?;
}
Ok(())
}
fn plan_loader_cache(
&self,
graph: &DependencyGraph,
resolver: &Resolver,
builder: &mut PlanBuilder<'_>,
warnings: &mut Vec<Warning>,
) {
let unreachable = unreachable_libraries(resolver);
let relocated = relocated_search_paths(graph);
let needs_cache = !unreachable.is_empty() || !relocated.is_empty();
let cache = self
.runtime_policy
.ld_so_cache
.applies(needs_cache)
.then(|| self.ld_so_cache(graph))
.flatten();
if let Some(bytes) = cache {
builder.push_generated(
Path::new(LD_SO_CACHE),
bytes,
InclusionReason::RuntimePolicy {
feature: RuntimeFeature::LdSoCache,
},
);
return;
}
if !unreachable.is_empty() {
warnings.push(warn_unreachable(unreachable, uses_glibc_loader(graph)));
}
if !relocated.is_empty() {
warnings.push(warn_relocated(relocated, graph));
}
}
fn plan_closure(
&self,
graph: &DependencyGraph,
builder: &mut PlanBuilder<'_>,
warnings: &mut Vec<Warning>,
) -> Result<()> {
let mut dlopen_libraries: Vec<String> = Vec::new();
for (id, node) in graph.iter() {
let reason = inclusion_reason(graph, id, node);
builder.push_file(PlannedFile {
source: Some(node.source.clone()),
destination: node.destination.clone(),
kind: planned_kind(node.kind),
reason: reason.clone(),
mode: mode_of(&node.source)?,
size: node.size,
sha256: Some(node.sha256.clone()),
link_target: None,
content: None,
});
for link in &node.links {
builder.push_symlink(&link.logical, &link.target, reason.clone());
}
if node.dlopen_references.is_empty() {
continue;
}
if id == graph.root {
warnings.push(warn_dlopen_executable(node));
} else {
dlopen_libraries.push(node.destination.display().to_string());
}
}
if !dlopen_libraries.is_empty() {
warnings.push(Warning {
code: code::DLOPEN,
message: format!(
"{} bundled shared object(s) reference dlopen()",
dlopen_libraries.len()
),
details: dlopen_libraries,
});
}
Ok(())
}
fn ld_so_cache(&self, graph: &DependencyGraph) -> Option<Vec<u8>> {
if !uses_glibc_loader(graph) {
return None;
}
let architecture = graph.root_node().architecture;
let entries: Vec<CacheEntry> = graph
.nodes
.iter()
.filter(|node| matches!(node.kind, NodeKind::SharedObject | NodeKind::Interpreter))
.map(|node| CacheEntry {
soname: node.soname.clone().unwrap_or_else(|| {
node.destination
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default()
}),
path: node.destination.clone(),
})
.filter(|entry| !entry.soname.is_empty())
.collect();
if entries.is_empty() {
return None;
}
cache::build(&architecture, &entries)
}
fn check_install_collision(&self, graph: &DependencyGraph) -> Result<()> {
let install = &graph.root_node().destination;
assert!(install.is_absolute());
for (id, node) in graph.iter() {
if id == graph.root {
continue;
}
if &node.destination != install {
continue;
}
return Err(Error::Config {
message: format!(
"install path `{}` collides with `{}`, which the closure \
needs at that exact path",
self.install_path.display(),
node.logical.display()
),
});
}
Ok(())
}
fn validate_dependencies(&self, graph: &DependencyGraph) -> Result<()> {
if self.dependency_policy.allow.is_none() {
return Ok(());
}
let application = graph.application_closure();
for (id, node) in graph.iter() {
if node.kind != NodeKind::SharedObject {
continue;
}
if !application.contains(&id) {
continue;
}
let soname = library_name(node);
if self.dependency_policy.is_allowed(&soname, &node.logical) {
continue;
}
let required_by = graph
.first_dependent(id)
.map(|(_, parent)| parent.destination.clone())
.unwrap_or_else(|| self.install_path.clone());
return Err(Error::DisallowedLibrary {
soname,
required_by,
});
}
Ok(())
}
fn apply_runtime_policy(
&self,
builder: &mut PlanBuilder<'_>,
warnings: &mut Vec<Warning>,
) -> Result<()> {
let policy = &self.runtime_policy;
if policy.ca_certificates {
self.plan_ca_certificates(builder)?;
}
if policy.tmp {
builder.push_dir_with_mode(
Path::new("/tmp"),
0o1777,
InclusionReason::RuntimePolicy {
feature: RuntimeFeature::Tmp,
},
);
}
if policy.passwd_group {
self.plan_passwd_group(builder);
}
if policy.nsswitch {
builder.push_generated(
Path::new("/etc/nsswitch.conf"),
policy.nsswitch_contents(),
InclusionReason::RuntimePolicy {
feature: RuntimeFeature::Nsswitch,
},
);
}
if policy.tzdata {
self.plan_tzdata(builder)?;
}
for include in &policy.includes {
self.plan_include(builder, include)?;
}
if policy.user.is_some() && !policy.passwd_group {
warnings.push(Warning {
code: code::USER_WITHOUT_PASSWD_GROUP,
message: "--user was given without passwd/group files".to_string(),
details: vec![
"Add --passwd-group (or --preset web) if the application resolves its own uid."
.to_string(),
],
});
}
Ok(())
}
fn plan_ca_certificates(&self, builder: &mut PlanBuilder<'_>) -> Result<()> {
for candidate in RuntimePolicy::CA_BUNDLE_CANDIDATES {
let logical = PathBuf::from(candidate);
let found = builder.copy_path(
&logical,
PlannedFileKind::CertificateBundle,
InclusionReason::RuntimePolicy {
feature: RuntimeFeature::CaCertificates,
},
false,
)?;
if found {
return Ok(());
}
}
Err(Error::MissingRuntimeFile {
feature: "ca-certificates",
searched: RuntimePolicy::CA_BUNDLE_CANDIDATES
.iter()
.map(PathBuf::from)
.collect(),
})
}
fn plan_passwd_group(&self, builder: &mut PlanBuilder<'_>) {
let reason = InclusionReason::RuntimePolicy {
feature: RuntimeFeature::PasswdGroup,
};
builder.push_generated(
Path::new("/etc/passwd"),
self.runtime_policy.passwd_contents(),
reason.clone(),
);
builder.push_generated(
Path::new("/etc/group"),
self.runtime_policy.group_contents(),
reason,
);
}
fn plan_tzdata(&self, builder: &mut PlanBuilder<'_>) -> Result<()> {
let reason = InclusionReason::RuntimePolicy {
feature: RuntimeFeature::Tzdata,
};
let zoneinfo = PathBuf::from("/usr/share/zoneinfo");
let found = builder.copy_path(
&zoneinfo,
PlannedFileKind::ApplicationData,
reason.clone(),
true,
)?;
if !found {
return Err(Error::MissingRuntimeFile {
feature: "tzdata",
searched: vec![zoneinfo],
});
}
builder.copy_path(
Path::new("/etc/localtime"),
PlannedFileKind::RuntimeConfig,
reason,
false,
)?;
Ok(())
}
fn plan_include(&self, builder: &mut PlanBuilder<'_>, include: &Path) -> Result<()> {
let logical = normalize_absolute(include);
let found = builder.copy_path(
&logical,
PlannedFileKind::ApplicationData,
InclusionReason::ExplicitInclude,
true,
)?;
if found {
return Ok(());
}
Err(Error::MissingSourcePath { path: logical })
}
}
fn inclusion_reason(graph: &DependencyGraph, id: NodeId, node: &Node) -> InclusionReason {
if id == graph.root {
return InclusionReason::Application;
}
if node.kind == NodeKind::Interpreter {
return InclusionReason::Interpreter;
}
match graph.first_dependent(id) {
Some((edge, parent)) => match &edge.reason {
DependencyReason::Needed { soname } => InclusionReason::NeededBy {
binary: parent.destination.clone(),
soname: soname.clone(),
},
DependencyReason::Interpreter => InclusionReason::Interpreter,
DependencyReason::RuntimePolicy { feature } => {
InclusionReason::RuntimePolicy { feature: *feature }
}
},
None => InclusionReason::Application,
}
}
fn planned_kind(kind: NodeKind) -> PlannedFileKind {
match kind {
NodeKind::Executable => PlannedFileKind::Executable,
NodeKind::Interpreter => PlannedFileKind::Interpreter,
NodeKind::SharedObject => PlannedFileKind::SharedObject,
}
}
fn library_name(node: &Node) -> String {
node.soname
.clone()
.or_else(|| {
node.logical
.file_name()
.map(|name| name.to_string_lossy().into_owned())
})
.unwrap_or_default()
}
fn unreachable_libraries(resolver: &Resolver) -> Vec<String> {
resolver
.notes()
.iter()
.map(|note| {
format!(
"{} in {} (found through {})",
note.soname,
note.directory.display(),
note.origin.as_str()
)
})
.collect()
}
fn relocated_search_paths(graph: &DependencyGraph) -> Vec<String> {
let source_dir = logical_parent(&graph.root_node().logical);
let install_dir = logical_parent(&graph.root_node().destination);
if install_dir == source_dir {
return Vec::new();
}
graph
.executable_search_paths
.iter()
.filter(|entry| entry.contains("$ORIGIN") || entry.contains("${ORIGIN}"))
.cloned()
.collect()
}
fn warn_unreachable(libraries: Vec<String>, glibc: bool) -> Warning {
assert!(!libraries.is_empty());
let explanation = if glibc {
format!(
"Without {LD_SO_CACHE} the packaged application finds these \
only if its DT_RPATH/DT_RUNPATH covers them."
)
} else {
"This loader does not read an ld.so.cache, so the paths have to \
come from the objects themselves."
.to_string()
};
Warning {
code: code::LIBRARY_UNREACHABLE,
message: match libraries.len() {
1 => "a library lives outside the directories the loader searches".to_string(),
n => format!("{n} libraries live outside the directories the loader searches"),
},
details: libraries.into_iter().chain([explanation]).collect(),
}
}
fn warn_relocated(paths: Vec<String>, graph: &DependencyGraph) -> Warning {
assert!(!paths.is_empty());
let source_dir = logical_parent(&graph.root_node().logical);
let install_dir = logical_parent(&graph.root_node().destination);
assert_ne!(source_dir, install_dir);
let advice = format!(
"Install it at {} to keep those paths pointing where they did.",
graph.root_node().logical.display()
);
Warning {
code: code::EXECUTABLE_RELOCATED,
message: format!(
"the executable declares $ORIGIN-relative search paths and moves from {} to {}",
source_dir.display(),
install_dir.display()
),
details: paths.into_iter().chain([advice]).collect(),
}
}
fn warn_dlopen_executable(node: &Node) -> Warning {
assert!(!node.dlopen_references.is_empty());
Warning {
code: code::DLOPEN,
message: format!("{} references dlopen()", node.destination.display()),
details: vec![
"Runtime-loaded libraries cannot be determined using static ELF dependency analysis."
.to_string(),
"Consider adding them with --include.".to_string(),
],
}
}
fn uses_glibc_loader(graph: &DependencyGraph) -> bool {
match &graph.declared_interpreter {
Some(interpreter) => !interpreter
.file_name()
.map(|name| name.to_string_lossy().contains("ld-musl"))
.unwrap_or(false),
None => false,
}
}
fn mode_of(path: &Path) -> Result<u32> {
use std::os::unix::fs::PermissionsExt;
let metadata = std::fs::metadata(path).map_err(|e| io(path, e))?;
let mode = metadata.permissions().mode();
let normalized = if metadata.is_dir() || mode & 0o111 != 0 {
0o755
} else {
0o644
};
Ok(normalized)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
elf::{Architecture, ElfClass, Endianness, Machine},
graph::Node,
hash::sha256_bytes,
};
fn graph_with_interpreter(interpreter: Option<&str>) -> DependencyGraph {
let architecture = Architecture {
machine: Machine::X86_64,
class: ElfClass::Elf64,
endianness: Endianness::Little,
};
let node = |kind, logical: &str, soname: Option<&str>| Node {
source: PathBuf::from(logical),
logical: PathBuf::from(logical),
destination: PathBuf::from(logical),
kind,
soname: soname.map(str::to_string),
architecture,
sha256: sha256_bytes(logical.as_bytes()),
size: 0,
links: Vec::new(),
dlopen_references: Vec::new(),
};
let mut graph = DependencyGraph::new();
graph.root = graph
.insert(node(NodeKind::Executable, "/app/server", None))
.unwrap();
graph.declared_interpreter = interpreter.map(PathBuf::from);
if let Some(interpreter) = interpreter {
graph
.insert(node(NodeKind::Interpreter, interpreter, Some("ld.so")))
.unwrap();
}
graph
.insert(node(
NodeKind::SharedObject,
"/opt/vendor/lib/libvendor.so.1",
Some("libvendor.so.1"),
))
.unwrap();
graph
}
fn planner() -> Planner {
Planner::new(SourceRoot::new("/"), "/app/server")
}
#[test]
fn a_glibc_bundle_gets_a_cache_naming_its_libraries() {
let graph = graph_with_interpreter(Some("/lib64/ld-linux-x86-64.so.2"));
let bytes = planner().ld_so_cache(&graph).expect("a cache is built");
let cache = crate::resolver::LdCache::parse(&bytes);
assert_eq!(
cache.lookup("libvendor.so.1"),
[PathBuf::from("/opt/vendor/lib/libvendor.so.1")]
);
assert!(
!cache.lookup("ld.so").is_empty(),
"the interpreter is listed too, as ldconfig lists it"
);
}
#[test]
fn a_musl_bundle_gets_no_cache() {
let graph = graph_with_interpreter(Some("/lib/ld-musl-x86_64.so.1"));
assert!(planner().ld_so_cache(&graph).is_none());
assert!(!uses_glibc_loader(&graph));
}
#[test]
fn a_static_binary_gets_no_cache() {
let mut graph = graph_with_interpreter(None);
graph.nodes.retain(|node| node.kind == NodeKind::Executable);
assert!(planner().ld_so_cache(&graph).is_none());
}
}