use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::fs;
use std::io::{self, BufRead, Read};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use onelf_sysroot::{Database, PlatformLine, Policy, Trace};
use crate::recipe;
pub(crate) mod ui;
pub(crate) use ui::{color, format_size};
mod datadirs;
mod discover;
pub(crate) mod gpu;
mod infer;
use gpu::{bundle_gpu, bundle_gtk_data, bundle_wayland};
mod activate;
mod collect;
mod completeness;
mod plan;
mod redir;
mod resolve;
pub(crate) use resolve::*;
pub(crate) mod elf;
pub mod sysroot;
use activate::{Discovery, WorkItem, publish_activations};
pub use activate::{RuleActivation, take_activations};
pub(crate) use completeness::record as record_finding;
pub use completeness::{Finding, Outcome, take_reported as take_findings};
pub(crate) use elf::*;
use onelf_format::paths::PathPair;
use onelf_format::plugindirs::{self, PluginDir};
use onelf_format::rules::{
BuildHookId, Descriptor, HelperGroup, Requirement, ResourceGroup, ResourceKind, RuntimeBase,
RuntimePathGroup, SourceBase, SourceCandidate,
};
use plan::{Admission, DestinationPlan, Existing};
pub use sysroot::SysrootOptions;
fn ensure_writable(path: &Path) {
if let Ok(meta) = fs::metadata(path) {
let mode = meta.permissions().mode();
if mode & 0o200 == 0 {
let _ = fs::set_permissions(path, PermissionsExt::from_mode(mode | 0o200));
}
}
}
pub(crate) fn private_temp_dir(tag: &str) -> io::Result<PathBuf> {
use std::os::unix::fs::DirBuilderExt;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let base = std::env::temp_dir();
for _ in 0..100 {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = base.join(format!(
"onelf-{tag}-{}-{nanos:08x}{n:x}",
std::process::id()
));
match fs::DirBuilder::new().mode(0o700).create(&dir) {
Ok(()) => return Ok(dir),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("no free scratch directory name under {}", base.display()),
))
}
fn verb_str(dry_run: bool) -> String {
if dry_run {
color::bold("Would copy")
} else {
color::bold_green("Copied")
}
}
fn build_lib_search_dirs(
elf_files: &[PathBuf],
extra_search: &[PathBuf],
nix_store_paths: &[String],
) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
for elf in elf_files {
for rdir in parse_rpaths(elf) {
if rdir.is_dir() && !dirs.contains(&rdir) {
dirs.push(rdir);
}
}
}
for path in STANDARD_LIB_PATHS {
let p = PathBuf::from(path);
if p.is_dir() && !dirs.contains(&p) {
dirs.push(p);
}
}
for sp in nix_store_paths {
let lib = PathBuf::from(sp).join("lib");
if lib.is_dir() && !dirs.contains(&lib) {
dirs.push(lib);
}
}
for dir in extra_search {
if dir.is_dir() && !dirs.contains(dir) {
dirs.push(dir.clone());
}
}
dirs
}
fn is_same_file(src: &Path, dest: &Path) -> bool {
fs::canonicalize(dest).map(|c| c == src).unwrap_or(false)
}
fn repair_admits(
dir: &Path,
tree_root: &Path,
tree_dir: &Path,
declared: &[PathBuf],
sysroot_root: Option<&Path>,
) -> bool {
if dir.starts_with(tree_root) || dir.starts_with(tree_dir) {
return true;
}
if declared.iter().any(|path| path == dir) {
return true;
}
sysroot_root.is_some_and(|root| dir.starts_with(root))
}
fn host_provenance(
copied_from: Option<&Path>,
tree: &[&Path],
target_libc: Option<LibcFamily>,
lookup: impl FnOnce() -> Option<PathBuf>,
) -> Option<PathBuf> {
let found = copied_from.map(Path::to_path_buf).or_else(lookup)?;
let real = fs::canonicalize(&found).unwrap_or(found);
if tree.iter().any(|root| real.starts_with(root)) {
return None;
}
let Some(want) = target_libc else {
return Some(real);
};
let family = parse_needed(&real)
.unwrap_or_default()
.iter()
.find_map(|d| libc_family_of_soname(d));
match family {
Some(have) if have != want => None,
_ => Some(real),
}
}
fn admit_tree_copy(
soname: &str,
copy: &Path,
copied_from: &HashMap<String, PathBuf>,
tree: &[&Path],
search_paths: &[PathBuf],
ldconfig_cache: &HashMap<String, Vec<PathBuf>>,
(class, machine, libc): (Option<u8>, Option<u16>, Option<LibcFamily>),
) -> AdmittedLibrary {
let library = AdmittedLibrary::new(soname, copy);
let twin = host_twin(
soname,
copied_from,
tree,
&host_dirs(search_paths, tree),
ldconfig_cache,
(class, machine, libc),
LookupScope::Host,
);
match twin {
Some(twin) => library.with_provenance(twin),
None => library,
}
}
fn host_dirs(search_paths: &[PathBuf], tree: &[&Path]) -> Vec<PathBuf> {
search_paths
.iter()
.filter(|d| !tree.iter().any(|root| d.starts_with(root)))
.cloned()
.collect()
}
fn host_twin(
soname: &str,
copied_from: &HashMap<String, PathBuf>,
tree: &[&Path],
host_dirs: &[PathBuf],
ldconfig_cache: &HashMap<String, Vec<PathBuf>>,
(class, machine, libc): (Option<u8>, Option<u16>, Option<LibcFamily>),
scope: LookupScope,
) -> Option<PathBuf> {
host_provenance(
copied_from.get(soname).map(PathBuf::as_path),
tree,
libc,
|| {
locate_lib(
soname,
ldconfig_cache,
host_dirs,
class,
machine,
None,
scope,
)
},
)
}
#[cfg(test)]
mod provenance_tests {
use super::*;
#[test]
fn provenance_prefers_the_copy_source_and_refuses_tree_and_libc_mismatch() {
let dir =
std::env::temp_dir().join(format!("onelf-twin-{}-{}", std::process::id(), line!()));
let _ = fs::remove_dir_all(&dir);
let tree = dir.join("appdir");
let host = dir.join("host/usr/lib");
fs::create_dir_all(tree.join("lib")).unwrap();
fs::create_dir_all(&host).unwrap();
fs::write(tree.join("lib/libX11.so.6"), b"tree").unwrap();
fs::write(host.join("libX11.so.6"), b"host").unwrap();
fs::write(host.join("libxkbcommon.so.0"), b"copied").unwrap();
let tree_root = fs::canonicalize(&tree).unwrap();
let host = fs::canonicalize(&host).unwrap();
let roots = [tree_root.as_path()];
assert_eq!(
host_provenance(Some(&host.join("libxkbcommon.so.0")), &roots, None, || {
panic!("a recorded copy source needs no host search")
}),
Some(host.join("libxkbcommon.so.0"))
);
assert_eq!(
host_provenance(None, &roots, None, || Some(host.join("libX11.so.6"))),
Some(host.join("libX11.so.6"))
);
assert_eq!(
host_provenance(None, &roots, None, || Some(
tree_root.join("lib/libX11.so.6")
)),
None,
"the tree's own copy is not its provenance"
);
assert_eq!(host_provenance(None, &roots, None, || None), None);
let exe = std::env::current_exe().unwrap();
if let Some(family) = parse_needed(&exe)
.unwrap_or_default()
.iter()
.find_map(|d| libc_family_of_soname(d))
{
let other = match family {
LibcFamily::Glibc => LibcFamily::Musl,
LibcFamily::Musl => LibcFamily::Glibc,
};
assert_eq!(
host_provenance(None, &roots, Some(other), || Some(exe.clone())),
None,
"a host object of another libc family is refused"
);
assert!(host_provenance(None, &roots, Some(family), || Some(exe.clone())).is_some());
}
let _ = fs::remove_dir_all(&dir);
}
}
pub(crate) struct LibCopy {
pub(crate) name: String,
pub(crate) resolved: PathBuf,
pub(crate) size: u64,
}
pub(crate) fn select_libs(
search_dirs: &[PathBuf],
name_pred: impl Fn(&str) -> bool,
target_class: Option<u8>,
target_machine: Option<u16>,
excludes: &[&str],
) -> Vec<LibCopy> {
let mut selected = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for dir in search_dirs {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if !path.is_file() && !path.is_symlink() {
continue;
}
let name = match path.file_name() {
Some(n) => n.to_string_lossy().into_owned(),
None => continue,
};
if !name_pred(&name) {
continue;
}
if is_excluded(&name, excludes) || seen.contains(&name) {
continue;
}
let resolved = fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
if !resolved.is_file() {
continue;
}
if let Some(tc) = target_class
&& read_elf_class(&resolved) != Some(tc)
{
continue;
}
if let Some(tm) = target_machine
&& read_elf_machine(&resolved) != Some(tm)
{
continue;
}
seen.insert(name.clone());
let size = fs::metadata(&resolved).map(|m| m.len()).unwrap_or(0);
selected.push(LibCopy {
name,
resolved,
size,
});
}
}
selected
}
pub(crate) fn report_lib(copy: &LibCopy) {
eprintln!(
" {} <- {} ({})",
color::bold_green(©.name),
copy.resolved.display(),
color::dim(&format_size(copy.size))
);
}
pub(crate) fn write_lib(copy: &LibCopy, dest: &Path, strip: bool) -> io::Result<()> {
fs::create_dir_all(dest)?;
let dest_path = dest.join(©.name);
if dest_path.is_symlink() {
fs::remove_file(&dest_path)?;
}
if !is_same_file(©.resolved, &dest_path) {
ensure_writable(&dest_path);
fs::copy(©.resolved, &dest_path)?;
let _ = fs::set_permissions(&dest_path, PermissionsExt::from_mode(0o755));
if strip {
strip_debug(&dest_path);
}
normalize_mtime(&dest_path);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn copy_libs(
search_dirs: &[PathBuf],
dest: &Path,
name_pred: impl Fn(&str) -> bool,
target_class: Option<u8>,
target_machine: Option<u16>,
excludes: &[&str],
dry_run: bool,
strip: bool,
) -> io::Result<(usize, u64)> {
let selected = select_libs(
search_dirs,
name_pred,
target_class,
target_machine,
excludes,
);
let mut total_bytes = 0u64;
for copy in &selected {
report_lib(copy);
if !dry_run {
write_lib(copy, dest, strip)?;
}
total_bytes += copy.size;
}
Ok((selected.len(), total_bytes))
}
#[allow(clippy::too_many_arguments)]
fn copy_prefixed_libs(
search_dirs: &[PathBuf],
prefixes: &[&str],
dest: &Path,
target_class: Option<u8>,
target_machine: Option<u16>,
excludes: &[&str],
dry_run: bool,
strip: bool,
) -> io::Result<(usize, u64)> {
copy_libs(
search_dirs,
dest,
|name| prefixes.iter().any(|p| name.starts_with(p)),
target_class,
target_machine,
excludes,
dry_run,
strip,
)
}
const GCONV_MODULES: &[&str] = &[
"UTF", "UNICODE", "ANSI", "CP", "LATIN", "ISO8859", "SJIS", "EUC-JP", "EUC-KR", "EUC-CN", "lib",
];
fn bundle_gconv(libc: &Path, lib_dest: &Path, strip: bool) -> io::Result<Vec<String>> {
let Some(src) = libc.parent().map(|d| d.join("gconv")) else {
return Ok(Vec::new());
};
if !src.is_dir() {
return Ok(Vec::new());
}
let dest = lib_dest.join("gconv");
let mut copied = Vec::new();
let mut copy = |from: &Path, rel: &str| -> io::Result<()> {
let to = dest.join(rel);
if let Some(parent) = to.parent() {
fs::create_dir_all(parent)?;
}
ensure_writable(&to);
fs::copy(from, &to)?;
normalize_mtime(&to);
copied.push(format!("gconv/{rel}"));
Ok(())
};
let modules_conf = src.join("gconv-modules");
if modules_conf.is_file() {
copy(&modules_conf, "gconv-modules")?;
}
if let Ok(entries) = fs::read_dir(src.join("gconv-modules.d")) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if entry.path().is_file() && name.ends_with(".conf") {
copy(&entry.path(), &format!("gconv-modules.d/{name}"))?;
}
}
}
for entry in fs::read_dir(&src)?.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.ends_with(".so") || !GCONV_MODULES.iter().any(|p| name.starts_with(p)) {
continue;
}
copy(&entry.path(), &name)?;
if strip {
strip_debug(&dest.join(&*name));
}
}
copied.sort();
Ok(copied)
}
fn bundle_c_utf8(libc: &Path, lib_dest: &Path) -> io::Result<Vec<String>> {
let Some(libc_dir) = libc.parent() else {
return Ok(Vec::new());
};
let found = ["locale", "../locale", "../lib/locale"]
.iter()
.flat_map(|rel| ["C.utf8", "C.UTF-8"].map(|name| (libc_dir.join(rel).join(name), name)))
.find(|(dir, _)| dir.join("LC_CTYPE").is_file());
let Some((src, name)) = found else {
return Ok(Vec::new());
};
let mut copied = Vec::new();
let mut pending = vec![(src, PathBuf::from("locale").join(name))];
while let Some((from, rel)) = pending.pop() {
for entry in fs::read_dir(&from)?.flatten() {
let rel = rel.join(entry.file_name());
let kind = entry.file_type()?;
if kind.is_dir() {
pending.push((entry.path(), rel));
} else if kind.is_file() {
let to = lib_dest.join(&rel);
if let Some(parent) = to.parent() {
fs::create_dir_all(parent)?;
}
ensure_writable(&to);
fs::copy(entry.path(), &to)?;
normalize_mtime(&to);
copied.push(rel.to_string_lossy().into_owned());
}
}
}
copied.sort();
Ok(copied)
}
const GLIBC_COMPAT_LIBS: &[&str] = &[
"libpthread.so.0",
"libdl.so.2",
"librt.so.1",
"libutil.so.1",
"libanl.so.1",
"libresolv.so.2",
];
const DEFAULT_EXCLUDES: &[&str] = &[
"libnss_",
"libcuda.so",
"libnvidia",
"libamdhip64.so",
"libze_loader.so",
"linux-vdso.so",
];
pub(crate) use onelf_sysroot::LIB_PATHS as STANDARD_LIB_PATHS;
fn link_target(from_dir: &Path, to: &Path) -> String {
if to.parent().is_some_and(|p| p == from_dir) {
return to
.file_name()
.unwrap_or(to.as_os_str())
.to_string_lossy()
.into_owned();
}
let mut target = String::new();
for _ in 0..from_dir.components().count() {
target.push_str("../");
}
target.push_str(&to.to_string_lossy());
target
}
fn copy_data_dir(src: &Path, dst: &Path, written: &mut Vec<PathBuf>) -> io::Result<u64> {
let mut total = 0u64;
fs::create_dir_all(dst)?;
let mut entries: Vec<std::fs::DirEntry> = fs::read_dir(src)?.collect::<Result<Vec<_>, _>>()?;
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let kind = entry.file_type()?;
if kind.is_dir() {
total += copy_data_dir(&entry.path(), &dst.join(entry.file_name()), written)?;
} else if kind.is_file() {
let to = dst.join(entry.file_name());
ensure_writable(&to);
fs::copy(entry.path(), &to)?;
written.push(to.clone());
normalize_mtime(&to);
total += entry.metadata().map(|m| m.len()).unwrap_or(0);
} else if kind.is_symlink() {
let target = fs::read_link(entry.path())?;
if target.is_relative() {
let to = dst.join(entry.file_name());
if to.symlink_metadata().is_ok() {
let _ = fs::remove_file(&to);
}
std::os::unix::fs::symlink(target, &to)?;
written.push(to);
} else {
eprintln!(
" {} absolute symlink in {} left out: {} -> {}",
color::bold_red("warning:"),
src.display(),
entry.path().display(),
target.display()
);
}
}
}
Ok(total)
}
fn copy_data(
src: &Path,
dst: &Path,
plan: &DestinationPlan,
written: &mut Vec<PathBuf>,
) -> io::Result<u64> {
if src.is_dir() {
return copy_data_dir(src, dst, written);
}
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
let bytes = plan.read_verified(src)?;
ensure_writable(dst);
fs::write(dst, &bytes)?;
let mode = fs::metadata(src)?.permissions().mode();
fs::set_permissions(dst, fs::Permissions::from_mode(mode & 0o7777))?;
written.push(dst.to_path_buf());
normalize_mtime(dst);
Ok(bytes.len() as u64)
}
fn copy_members(
members: &[(PathBuf, PathBuf)],
directory: &Path,
plan: &DestinationPlan,
written: &mut Vec<PathBuf>,
) -> io::Result<u64> {
let mut total = 0u64;
for (from, rel) in members {
let to = directory.join(rel);
if let Some(parent) = to.parent() {
fs::create_dir_all(parent)?;
}
let meta = fs::symlink_metadata(from)?;
if meta.file_type().is_symlink() {
let target = fs::read_link(from)?;
if target.is_relative() {
if to.symlink_metadata().is_ok() {
let _ = fs::remove_file(&to);
}
std::os::unix::fs::symlink(target, &to)?;
written.push(to);
}
continue;
}
total += copy_data(from, &to, plan, written)?;
}
Ok(total)
}
fn patch_alsa_conf_hooks(conf: &Path) -> io::Result<bool> {
const CARRIED: &str = "{ @func datadir } \"/alsa.conf.d\"";
let text = fs::read_to_string(conf)?;
if text.contains(CARRIED) {
return Ok(false);
}
let lines: Vec<&str> = text.lines().collect();
let Some((start, end)) = hooks_block(&lines) else {
return Ok(false);
};
let mut insert_at = None;
for (at, line) in lines.iter().enumerate().skip(start).take(end - start) {
let line = line.trim();
if line.contains("conf.d") && !line.contains("@func") {
insert_at = Some(at + 1);
}
}
let Some(insert_at) = insert_at else {
eprintln!(
" {} {} has a hooks block but no conf.d entry; the carried snippets are never loaded by it",
color::bold_red("warning:"),
conf.display()
);
return Ok(false);
};
let mut out = String::with_capacity(text.len() + CARRIED.len() + 64);
for (at, line) in lines.iter().enumerate() {
if at == insert_at {
out.push_str("\t\t\t{ @func concat strings [ ");
out.push_str(CARRIED);
out.push_str(" ] }\n");
}
out.push_str(line);
out.push('\n');
}
ensure_writable(conf);
fs::write(conf, out)?;
normalize_mtime(conf);
Ok(true)
}
fn hooks_block(lines: &[&str]) -> Option<(usize, usize)> {
let mut depth = 0usize;
let mut start = None;
for (at, line) in lines.iter().enumerate() {
if depth == 0 {
let trimmed = line.trim_start();
if !(trimmed.starts_with("@hooks") && trimmed.contains('[')) {
continue;
}
start = Some(at);
depth = line
.matches('[')
.count()
.saturating_sub(line.matches(']').count());
} else {
depth += line.matches('[').count();
depth = depth.saturating_sub(line.matches(']').count());
}
if start.is_some() && depth == 0 {
return Some((start?, at + 1));
}
}
None
}
#[allow(clippy::too_many_arguments)]
fn carry_data_group(
directory: &Path,
descriptor: &Descriptor,
declared: &ResourceGroup,
admitted: &[AdmittedLibrary],
sysroot: Option<&sysroot::SysrootUniverse>,
plan: &mut DestinationPlan,
actions: &mut Vec<WalkAction>,
preparation: &mut SetPreparation,
required: &mut HashSet<PathBuf>,
) -> io::Result<()> {
let rule = descriptor.id.as_str();
if declared.requirement == Requirement::Required {
required.insert(PathBuf::from(declared.destination.as_str()));
}
let hooks: Vec<BuildHookId> = descriptor.build.hooks.clone();
let origin = format!("rule {rule} group {} data carry", declared.id.as_str());
let owners: Vec<AdmittedLibrary> = admitted
.iter()
.filter(|lib| {
descriptor
.triggers
.iter()
.any(|t| t.matches_soname(lib.soname()))
})
.cloned()
.collect();
let outcome = discover_group(
&GroupQuery {
rule,
group: declared.id.as_str(),
kind: declared.kind,
destination: &declared.destination,
candidates: &declared.candidates,
},
&AdmittedUniverse {
libraries: &owners,
sysroot,
},
)?;
if let Some(hit) = outcome.found.first() {
return carry_hit(directory, rule, hit, hooks, sysroot, plan, actions, &origin).map(|_| ());
}
if directory.join(declared.destination.as_str()).exists() {
if !hooks.is_empty() {
actions.push(WalkAction::RunStagedHooks { hooks });
}
return Ok(());
}
let supplied = preparation.supply_group(
plan,
&SupplyQuery {
rule,
group: declared.id.as_str(),
kind: declared.kind,
destination: declared.destination.as_str(),
candidates: &declared.candidates,
executable: false,
hooks: &hooks,
runtime: &descriptor.runtime.paths,
},
)?;
if supplied {
return Ok(());
}
let miss = data_miss(rule, declared, &outcome);
completeness::record(miss.clone());
match declared.requirement {
Requirement::Required if !preparation.dry_run => Err(io::Error::new(
io::ErrorKind::NotFound,
format!("rule {rule} {}", miss.reason),
)),
_ => Ok(()),
}
}
#[allow(clippy::too_many_arguments)]
fn carry_hit(
directory: &Path,
rule: &str,
hit: &CandidateHit,
hooks: Vec<BuildHookId>,
sysroot: Option<&sysroot::SysrootUniverse>,
plan: &mut DestinationPlan,
actions: &mut Vec<WalkAction>,
origin: &str,
) -> io::Result<Carry> {
let dest = directory.join(hit.destination.as_path());
if dest.exists() {
if !hooks.is_empty() {
actions.push(WalkAction::RunStagedHooks { hooks });
}
if hit.kind == ResourceKind::File {
plan.admit_carried(hit.destination.as_path(), &hit.resolved, origin)?;
}
return Ok(Carry::Held);
}
if let (Some(universe), ResourceKind::Directory) = (sysroot, hit.kind) {
let members = admitted_members(universe, hit, plan, origin)?;
if members.is_empty() {
return Ok(Carry::Empty);
}
actions.push(WalkAction::CarryData {
rule: rule.to_string(),
src: hit.resolved.clone(),
rel: hit.destination.clone(),
hooks,
members: Some(members),
});
return Ok(Carry::Planned);
}
match plan.admit_carried(hit.destination.as_path(), &hit.resolved, origin)? {
Admission::Write => {
actions.push(WalkAction::CarryData {
rule: rule.to_string(),
src: hit.resolved.clone(),
rel: hit.destination.clone(),
hooks,
members: None,
});
Ok(Carry::Planned)
}
Admission::Skip => Ok(Carry::Held),
Admission::Rejected => Ok(Carry::Rejected),
}
}
fn held_in_tree(directory: &Path, dest: &Path) -> bool {
tree_link(directory, dest).is_none()
&& fs::symlink_metadata(directory.join(dest)).is_ok_and(|m| m.is_dir())
}
fn tree_link(directory: &Path, dest: &Path) -> Option<PathBuf> {
plan::symlinked_ancestor(directory, dest).or_else(|| {
fs::symlink_metadata(directory.join(dest))
.is_ok_and(|m| m.file_type().is_symlink())
.then(|| dest.to_path_buf())
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Carry {
Planned,
Held,
Rejected,
Empty,
}
struct WalkState<'a> {
already_processed: &'a mut HashSet<String>,
search_paths: &'a mut Vec<PathBuf>,
repair_extra: &'a mut HashSet<PathBuf>,
group_dests: &'a mut HashMap<String, PathBuf>,
group_sources: &'a mut HashMap<String, PathBuf>,
needed_by: &'a mut HashMap<String, String>,
discovery: &'a mut Discovery,
plan: &'a mut DestinationPlan,
actions: &'a mut Vec<WalkAction>,
scan: &'a mut Vec<(String, PathBuf)>,
}
fn exclude_recipe_members(collection: &mut collect::GroupCollection, recipe_exclude: &[String]) {
let (plugins, excluded): (Vec<_>, Vec<_>) = std::mem::take(&mut collection.plugins)
.into_iter()
.partition(|p| !recipe_excludes(&p.soname, recipe_exclude));
collection.plugins = plugins;
collection.skipped.extend(excluded.into_iter().map(|p| {
(
p.destination.display().to_string(),
"left out by [bundle] exclude".to_string(),
)
}));
}
fn plan_collection(
collection: &mut collect::GroupCollection,
origin: &str,
directory: &Path,
queue: &mut WalkState,
) -> io::Result<Vec<(String, PathBuf)>> {
let mut firsts: Vec<(String, PathBuf)> = Vec::new();
for plugin in &collection.plugins {
let first = match firsts.iter().find(|(soname, _)| soname == &plugin.soname) {
Some((_, dest)) => *dest == plugin.destination,
None => match queue.group_dests.get(&plugin.soname) {
Some(dest) => *dest == plugin.destination,
None => {
firsts.push((plugin.soname.clone(), plugin.destination.clone()));
true
}
},
};
if !first {
let origin = format!("{origin} plugin {}", plugin.destination.display());
plan_member_copy(directory, plugin, &origin, queue.plan, queue.actions)?;
}
}
Ok(firsts)
}
fn queue_collection(
collection: &collect::GroupCollection,
origin: &str,
excludes: &[&str],
reachable: &HashSet<String>,
firsts: &[(String, PathBuf)],
queue: &mut WalkState,
sysroot: Option<&sysroot::SysrootUniverse>,
) -> io::Result<()> {
for dir in &collection.search_dirs {
if !queue.search_paths.contains(dir) {
queue.search_paths.push(dir.clone());
}
queue.repair_extra.insert(dir.clone());
}
for plugin in &collection.plugins {
if let Some(dir) = plugin.resolved.parent() {
let dir = dir.to_path_buf();
if !queue.search_paths.contains(&dir) {
queue.search_paths.push(dir.clone());
}
queue.repair_extra.insert(dir);
}
}
for (soname, destination) in firsts {
queue
.group_dests
.entry(soname.clone())
.or_insert_with(|| destination.clone());
}
for plugin in &collection.plugins {
let first = queue
.group_dests
.get(&plugin.soname)
.is_some_and(|dest| *dest == plugin.destination);
if !first {
let origin = format!("{origin} plugin {}", plugin.destination.display());
if sysroot.is_none()
&& let Some(name) = plugin.resolved.file_name().and_then(|n| n.to_str())
{
queue.scan.push((name.to_string(), plugin.resolved.clone()));
}
for dep in parse_needed(&plugin.resolved).unwrap_or_default() {
if queue.already_processed.contains(&dep)
|| is_excluded(&dep, excludes)
|| reachable.contains(&dep)
|| libc_family_of_soname(&dep).is_some()
{
continue;
}
queue
.needed_by
.entry(dep.clone())
.or_insert_with(|| origin.clone());
queue.discovery.enqueue(&dep)?;
}
continue;
}
queue
.group_sources
.insert(plugin.soname.clone(), plugin.resolved.clone());
queue
.needed_by
.entry(plugin.soname.clone())
.or_insert_with(|| origin.to_string());
queue.already_processed.remove(&plugin.soname);
queue.discovery.enqueue(&plugin.soname)?;
}
Ok(())
}
enum InferredPlan {
Data(Carry),
TrimmedData {
files: Vec<(PathBuf, PathBuf)>,
size: u64,
kept: usize,
total: usize,
},
Plugins {
collection: Box<collect::GroupCollection>,
firsts: Vec<(String, PathBuf)>,
files: Vec<(PathBuf, PathBuf)>,
held: usize,
size: u64,
trimmed: Option<(usize, usize)>,
},
}
struct Trim {
opened: BTreeSet<String>,
keep: Option<Policy>,
tree: Option<String>,
objects_only: bool,
}
impl Trim {
fn load(trace: Option<&Path>, keep: Option<&Path>) -> io::Result<Self> {
let opened = trace
.map(Trace::load)
.transpose()?
.map(|t| t.opened_paths().clone())
.unwrap_or_default();
let keep = keep.map(Policy::load).transpose()?;
Ok(Trim {
opened,
keep,
tree: None,
objects_only: false,
})
}
fn from_paths(paths: Vec<PathBuf>, tree: Option<&Path>, objects_only: bool) -> Self {
let slash_trimmed = |p: &Path| {
let rel = p.to_string_lossy();
let rel = rel.trim_start_matches('/');
(!rel.is_empty()).then(|| rel.to_string())
};
Trim {
opened: paths.iter().filter_map(|p| slash_trimmed(p)).collect(),
keep: None,
tree: tree.and_then(slash_trimmed),
objects_only,
}
}
fn keeps(&self, path: &Path) -> bool {
let rel = path.to_string_lossy();
let rel = rel.trim_start_matches('/');
if self.opened.contains(rel) {
return true;
}
self.keep.as_ref().is_some_and(|keep| keep.matches(rel))
}
fn keeps_tree_shadow(&self, rel: &Path) -> bool {
let Some(tree) = &self.tree else {
return false;
};
let Some(name) = rel.file_name() else {
return false;
};
self.opened.iter().any(|opened| {
Path::new(opened).strip_prefix(Path::new(tree)).is_ok()
&& Path::new(opened).file_name() == Some(name)
})
}
}
fn member_spellings(
dir: &infer::InferredDir,
rel: &Path,
sysroot: Option<&sysroot::SysrootUniverse>,
) -> Vec<PathBuf> {
let mut out = vec![dir.prefix.join(&dir.rel).join(rel), dir.source.join(rel)];
out.extend(dir.compiled.iter().map(|s| Path::new(s).join(rel)));
if let Some(universe) = sysroot
&& let Ok(dir_rel) = dir.source.strip_prefix(universe.root())
{
out.push(dir_rel.join(rel));
if let Ok(resolved) = onelf_sysroot::path::in_root(universe.root(), dir_rel)
&& let Ok(resolved_rel) = resolved.strip_prefix(universe.root())
{
out.push(resolved_rel.join(rel));
}
}
out
}
fn trimmed_out(
dir: &infer::InferredDir,
rel: &Path,
sysroot: Option<&sysroot::SysrootUniverse>,
trim: Option<&Trim>,
) -> bool {
let Some(trim) = trim else {
return false;
};
let kept = member_spellings(dir, rel, sysroot)
.iter()
.any(|spelling| trim.keeps(spelling));
!(kept || trim.keeps_tree_shadow(rel))
}
#[allow(clippy::too_many_arguments)]
fn plan_plugin_carry(
dir: &infer::InferredDir,
members: &[(PathBuf, PathBuf)],
mut collection: collect::GroupCollection,
trimmed_total: Option<(usize, usize)>,
origin: &str,
opts: &BundleOptions,
sysroot: Option<&sysroot::SysrootUniverse>,
state: &mut WalkState,
) -> io::Result<InferredPlan> {
exclude_recipe_members(&mut collection, &opts.exclude);
let firsts = plan_collection(&mut collection, origin, &opts.directory, state)?;
let mut size: u64 = collection
.plugins
.iter()
.filter_map(|p| fs::metadata(&p.resolved).ok())
.map(|meta| meta.len())
.sum();
let MemberData {
files,
size: files_size,
held,
} = plan_member_data(dir, members, origin, sysroot, state)?;
size += files_size;
Ok(InferredPlan::Plugins {
collection: Box::new(collection),
firsts,
files,
held,
size,
trimmed: trimmed_total,
})
}
fn plan_member_data(
dir: &infer::InferredDir,
members: &[(PathBuf, PathBuf)],
origin: &str,
sysroot: Option<&sysroot::SysrootUniverse>,
state: &mut WalkState,
) -> io::Result<MemberData> {
let hit = dir.hit();
let member_of = |rel: &Path| -> Option<PathBuf> {
let universe = sysroot?;
let dir_rel = hit.resolved.strip_prefix(universe.root()).ok()?;
onelf_sysroot::path::entry_in_root(universe.root(), &dir_rel.join(rel)).ok()
};
let mut size = 0u64;
let mut held = 0usize;
let mut files: Vec<(PathBuf, PathBuf)> = Vec::new();
for (rel, file) in members {
if read_elf_class(file).is_some() {
continue;
}
let to = dir.destination.join(rel);
let member = member_of(rel).unwrap_or_else(|| file.clone());
let meta = fs::symlink_metadata(&member)?;
let admission = if meta.file_type().is_symlink() {
let target = fs::read_link(&member)?;
if !target.is_relative() {
continue;
}
state.plan.admit_strict_link(
&to,
&target.to_string_lossy(),
origin,
Existing::SkipOnAny,
)?
} else if meta.is_file() {
state.plan.admit_carried(&to, file, origin)?
} else {
continue;
};
if admission == Admission::Rejected {
continue;
}
if !meta.file_type().is_symlink() {
size += fs::metadata(file).map(|meta| meta.len()).unwrap_or(0);
}
if admission == Admission::Write {
files.push((member, to));
} else {
held += 1;
}
}
Ok(MemberData { files, size, held })
}
struct MemberData {
files: Vec<(PathBuf, PathBuf)>,
size: u64,
held: usize,
}
fn disposal_spelling(
sysroot: Option<&sysroot::SysrootUniverse>,
dir: &infer::InferredDir,
rel: &Path,
) -> String {
if let Some(universe) = sysroot
&& let Ok(dir_rel) = dir.source.strip_prefix(universe.root())
{
return sysroot::appdir_path(&dir_rel.join(rel).to_string_lossy()).to_string();
}
dir.destination.join(rel).to_string_lossy().into_owned()
}
fn member_entry(
sysroot: Option<&sysroot::SysrootUniverse>,
resolved_dir: &Path,
rel: &Path,
) -> Option<PathBuf> {
match sysroot {
Some(universe) => {
let dir_rel = resolved_dir.strip_prefix(universe.root()).ok()?;
onelf_sysroot::path::entry_in_root(universe.root(), &dir_rel.join(rel)).ok()
}
None => Some(resolved_dir.join(rel)),
}
}
fn normalize_lexical(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
component => out.push(component.as_os_str()),
}
}
out
}
fn dangling_target(
sysroot: Option<&sysroot::SysrootUniverse>,
resolved_dir: &Path,
rel: &Path,
answered: bool,
) -> Option<PathBuf> {
let entry = member_entry(sysroot, resolved_dir, rel)?;
if !fs::symlink_metadata(&entry).ok()?.is_symlink() {
return None;
}
if answered {
return None;
}
let target = fs::read_link(&entry).ok()?;
if !target.is_relative() {
return None;
}
let named = entry.parent()?.join(target);
let named = normalize_lexical(&named);
let mut anchors = vec![normalize_lexical(resolved_dir)];
if let Some(universe) = sysroot
&& let Ok(dir_rel) = resolved_dir.strip_prefix(universe.root())
&& let Ok(resolved) = onelf_sysroot::path::in_root(universe.root(), dir_rel)
{
let resolved = normalize_lexical(&resolved);
if !anchors.contains(&resolved) {
anchors.push(resolved);
}
}
anchors
.iter()
.find_map(|anchor| named.strip_prefix(anchor).ok().map(Path::to_path_buf))
}
fn keep_link_targets(
sysroot: Option<&sysroot::SysrootUniverse>,
dir: &infer::InferredDir,
objects: Option<&[collect::CollectedPlugin]>,
kept: &mut Vec<(PathBuf, PathBuf)>,
trimmed: &mut Vec<(PathBuf, PathBuf)>,
) {
let resolved_dir = &dir.hit().resolved;
loop {
let mut rescued: Option<usize> = None;
for (rel, _) in kept.iter() {
let answered = objects.is_some_and(|plugins| {
plugins
.iter()
.any(|p| p.destination == dir.destination.join(rel))
});
let Some(target_rel) = dangling_target(sysroot, resolved_dir, rel, answered) else {
continue;
};
if let Some(at) = trimmed.iter().position(|(r, _)| r == &target_rel) {
rescued = Some(at);
break;
}
}
let Some(at) = rescued else {
break;
};
kept.push(trimmed.swap_remove(at));
}
}
#[cfg(test)]
mod trim_link_tests {
use super::*;
#[test]
fn a_link_target_through_dotdot_names_the_member_it_lands_on() {
let dir =
std::env::temp_dir().join(format!("onelf-dangling-target-{}", std::process::id()));
fs::create_dir_all(dir.join("sub")).unwrap();
fs::write(dir.join("a.conf"), b"conf\n").unwrap();
fs::write(dir.join("outside.conf"), b"out\n").unwrap();
std::os::unix::fs::symlink("../a.conf", dir.join("sub/link.conf")).unwrap();
std::os::unix::fs::symlink("../../outside.conf", dir.join("sub/escape.conf")).unwrap();
let named = dangling_target(None, &dir, Path::new("sub/link.conf"), false);
assert_eq!(named.as_deref(), Some(Path::new("a.conf")));
let escaped = dangling_target(None, &dir, Path::new("sub/escape.conf"), false);
assert_eq!(escaped, None);
let answered = dangling_target(None, &dir, Path::new("sub/link.conf"), true);
assert_eq!(answered, None);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_target_beside_a_compatibility_link_anchor_still_names_the_member() {
use sysroot::fixture::{package, populate_universe};
let root = std::env::temp_dir().join(format!("onelf-compat-link-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let sysroot = root.join("sysroot");
fs::create_dir_all(sysroot.join("usr/lib/examplefw/sub")).unwrap();
fs::write(sysroot.join("usr/lib/examplefw/a.conf"), b"conf\n").unwrap();
std::os::unix::fs::symlink("usr/lib", sysroot.join("lib")).unwrap();
std::os::unix::fs::symlink("../a.conf", sysroot.join("usr/lib/examplefw/sub/link.conf"))
.unwrap();
let packages = [package(
"examplefw",
&[
"usr/lib/libexamplefw.so.1",
"usr/lib/examplefw/a.conf",
"usr/lib/examplefw/sub/link.conf",
],
)];
let universe = populate_universe(&sysroot, &packages, "examplefw", &[], None, None);
let dir = sysroot.join("lib/examplefw");
let named = dangling_target(Some(&universe), &dir, Path::new("sub/link.conf"), false);
assert_eq!(named.as_deref(), Some(Path::new("a.conf")));
let dir = infer::InferredDir {
source: sysroot.join("lib/examplefw"),
prefix: sysroot.join("lib"),
rel: PathBuf::from("examplefw"),
destination: PathBuf::from("lib/examplefw"),
kind: infer::InferredKind::Data,
size: 0,
compiled: BTreeSet::from(["/lib/examplefw".to_string()]),
objects: BTreeSet::from(["libexamplefw.so.1".to_string()]),
};
let trace = Trim::from_paths(
vec![PathBuf::from("/usr/lib/examplefw/a.conf")],
None,
false,
);
assert!(
!trimmed_out(&dir, Path::new("a.conf"), Some(&universe), Some(&trace)),
"the trace's root-relative spelling names the member"
);
let none = Trim::from_paths(Vec::new(), None, false);
assert!(trimmed_out(
&dir,
Path::new("a.conf"),
Some(&universe),
Some(&none)
));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn lexical_normalization_keeps_dotted_anchors_comparable() {
let anchor = normalize_lexical(Path::new("./../sysroot/usr/share/examplefw"));
assert_eq!(anchor, PathBuf::from("sysroot/usr/share/examplefw"));
let named = normalize_lexical(Path::new("./../sysroot/usr/share/examplefw/sub/../a.conf"));
assert_eq!(named.strip_prefix(&anchor), Ok(Path::new("a.conf")));
let escaped = normalize_lexical(Path::new("./../sysroot/usr/share/outside.conf"));
assert!(escaped.strip_prefix(&anchor).is_err());
}
}
fn dispose_trimmed_tree_copies(opts: &BundleOptions, trimmed: &[String]) -> io::Result<()> {
if trimmed.is_empty() {
return Ok(());
}
let entries: std::collections::HashMap<String, sysroot::GeneratedEntry> =
sysroot::read_generated(&opts.directory)?
.into_iter()
.map(|entry| (entry.path.clone(), entry))
.collect();
let mut removed: Vec<String> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
for rel in trimmed {
let Some(entry) = entries.get(rel) else {
continue;
};
let at = opts.directory.join(rel);
let actual = sysroot::hash_entry(&at)?;
match sysroot::decide_generated_disposal(entry, actual) {
sysroot::Disposition::Dispose | sysroot::Disposition::Excuse => {
let _ = fs::remove_file(&at);
if let Some(parent) = at.parent() {
dirs.push(parent.to_path_buf());
}
removed.push(rel.clone());
}
_ => {}
}
}
if removed.is_empty() {
return Ok(());
}
dirs.sort_by(|a, b| {
b.components()
.count()
.cmp(&a.components().count())
.then_with(|| a.cmp(b))
});
dirs.dedup();
for dir in dirs {
let mut dir = dir.as_path();
while dir != opts.directory && fs::remove_dir(dir).is_ok() {
let Some(parent) = dir.parent() else {
break;
};
dir = parent;
}
}
sysroot::remove_generated(&opts.directory, &removed)
}
#[allow(clippy::too_many_arguments)]
fn collect_inferred(
dir: &infer::InferredDir,
carried: &mut Vec<(PathBuf, PathBuf, Carry)>,
opts: &BundleOptions,
sysroot: Option<&sysroot::SysrootUniverse>,
trim: Option<&Trim>,
(target_class, target_machine, target_libc): (Option<u8>, Option<u16>, Option<LibcFamily>),
excludes: &[&str],
reachable: &HashSet<String>,
state: &mut WalkState,
) -> io::Result<()> {
let claim = |spelling: &String| {
opts.recipe_pairs
.iter()
.find(|pair| pair.absolute == *spelling && Path::new(&pair.relative) != dir.destination)
};
let recorded = || -> io::Result<bool> {
let persisted = redir::persisted(&opts.directory)?;
let tree = find_elf_files(&opts.directory);
for spelling in &dir.compiled {
if redir::recorded_reach(&persisted, spelling).as_deref()
== Some(dir.destination.as_path())
&& redir::rewritten_in(&tree, spelling)?
{
return Ok(true);
}
}
Ok(false)
};
if !dir.compiled.is_empty()
&& dir
.compiled
.iter()
.all(|spelling| claim(spelling).is_some())
&& !recorded()?
{
let pair = dir
.compiled
.iter()
.find_map(claim)
.expect("a claimed spelling");
infer::omit(
dir,
&format!(
"the recipe pair for {} sends that path to {}",
pair.absolute, pair.relative
),
);
return Ok(());
}
for (dest, source, carry) in carried.iter() {
let inner = dir.destination.strip_prefix(dest).ok();
let outer = dest.strip_prefix(&dir.destination).ok();
match (inner, outer) {
(Some(rel), _) if dir.source != source.join(rel) => {
infer::omit(
dir,
&format!("{} overlaps it at {}", source.display(), dest.display()),
);
return Ok(());
}
(Some(_), _) => {
if *carry == Carry::Planned && trim.is_some() {
infer::report(dir, dir.size, infer::Carried::WithinTrimmed(dest));
return Ok(());
}
if *carry == Carry::Planned {
infer::report(dir, dir.size, infer::Carried::Within(dest));
return Ok(());
}
if held_in_tree(&opts.directory, &dir.destination) {
infer::report(dir, dir.size, infer::Carried::Within(dest));
return Ok(());
}
}
(None, Some(_)) => {
infer::omit(
dir,
&format!("{} inside it is carried already", dest.display()),
);
return Ok(());
}
(None, None) => {}
}
}
let objects = dir.objects.iter().cloned().collect::<Vec<_>>().join(", ");
let origin = format!("inferred from {objects}");
let mut trimmed_total: Option<(usize, usize)> = None;
let plugin = if dir.kind == infer::InferredKind::Plugins {
let mut members = match collect::members(&dir.hit(), sysroot) {
Ok(members) => members,
Err(error) => {
infer::omit(dir, &format!("it cannot be read: {error}"));
return Ok(());
}
};
let mut collection = match collect::collect_objects(
"inference",
&dir.destination.display().to_string(),
&dir.hit(),
&members,
(target_class, target_machine, target_libc),
) {
Ok(collection) => collection,
Err(error) => {
infer::omit(dir, &error.to_string());
return Ok(());
}
};
if let Some(trim) = trim {
let total = members.len();
let mut kept = Vec::new();
for p in std::mem::take(&mut collection.plugins) {
let rel = p
.destination
.strip_prefix(&dir.destination)
.or_else(|_| p.resolved.strip_prefix(&dir.source))
.or_else(|_| p.resolved.strip_prefix(&dir.prefix))
.ok()
.map(|r| r.to_path_buf());
let out = rel
.as_ref()
.filter(|rel| trimmed_out(dir, rel, sysroot, Some(trim)))
.map(|r| r.display().to_string());
if let Some(shown) = out {
collection
.skipped
.push((shown, "the trace did not open it".to_string()));
} else {
kept.push(p);
}
}
collection.plugins = kept;
let mut trimmed_members: Vec<(PathBuf, PathBuf)> = Vec::new();
for (rel, file) in std::mem::take(&mut members) {
let judged = !trim.objects_only || read_elf_class(&file).is_some();
if judged && trimmed_out(dir, &rel, sysroot, Some(trim)) {
trimmed_members.push((rel, file));
} else {
members.push((rel, file));
}
}
keep_link_targets(
sysroot,
dir,
Some(&collection.plugins),
&mut members,
&mut trimmed_members,
);
let trimmed_rels: Vec<String> = trimmed_members
.iter()
.map(|(rel, _)| disposal_spelling(sysroot, dir, rel))
.collect();
trimmed_total = Some((members.len(), total));
dispose_trimmed_tree_copies(opts, &trimmed_rels)?;
if collection.plugins.is_empty() && members.is_empty() {
infer::omit(dir, "the trace opened nothing in it");
return Ok(());
}
}
Some((members, collection))
} else {
None
};
let trimmed_data = if plugin.is_none() {
match trim.filter(|t| !t.objects_only) {
Some(trim) => match collect::members(&dir.hit(), sysroot) {
Ok(members) => {
let total = members.len();
let mut kept = Vec::new();
let mut trimmed: Vec<(PathBuf, PathBuf)> = Vec::new();
for (rel, file) in members {
if trimmed_out(dir, &rel, sysroot, Some(trim)) {
trimmed.push((rel, file));
} else {
kept.push((rel, file));
}
}
keep_link_targets(sysroot, dir, None, &mut kept, &mut trimmed);
let trimmed_rels: Vec<String> = trimmed
.iter()
.map(|(rel, _)| disposal_spelling(sysroot, dir, rel))
.collect();
dispose_trimmed_tree_copies(opts, &trimmed_rels)?;
if kept.is_empty() {
infer::omit(dir, "the trace opened nothing in it");
return Ok(());
}
Some((kept, total))
}
Err(error) => {
infer::omit(dir, &format!("it cannot be read: {error}"));
return Ok(());
}
},
None => None,
}
} else {
None
};
let checkpoint = state.plan.checkpoint();
let planned_actions = state.actions.len();
let planned = match plugin {
Some((members, collection)) => plan_plugin_carry(
dir,
&members,
collection,
trimmed_total,
&origin,
opts,
sysroot,
state,
),
None => match trimmed_data {
Some((members, total)) => {
let kept = members.len();
let hit = dir.hit();
let size: u64 = members
.iter()
.filter_map(|(rel, _)| {
let entry = member_entry(sysroot, &hit.resolved, rel)?;
let meta = fs::symlink_metadata(entry).ok()?;
meta.is_file().then_some(meta.len())
})
.sum();
plan_member_data(dir, &members, &origin, sysroot, state).map(
|MemberData { files, .. }| InferredPlan::TrimmedData {
files,
size,
kept,
total,
},
)
}
_ => carry_hit(
&opts.directory,
&objects,
&dir.hit(),
Vec::new(),
sysroot,
state.plan,
state.actions,
&origin,
)
.map(InferredPlan::Data),
},
};
let planned = match planned {
Ok(planned) if !state.plan.found_since(&checkpoint) => planned,
otherwise => {
let rolled = state.plan.restore(checkpoint);
state.actions.truncate(planned_actions);
let why = match otherwise {
Err(error) => format!("it cannot be carried: {error}"),
Ok(_) => rolled
.first()
.map(|finding| format!("it {}", finding.clause()))
.unwrap_or_else(|| "it collides with another write".to_string()),
};
infer::omit(dir, &why);
return Ok(());
}
};
match planned {
InferredPlan::Data(carry) => match carry {
Carry::Planned => {
infer::report(dir, dir.size, infer::Carried::Data);
carried.push((dir.destination.clone(), dir.source.clone(), carry));
}
Carry::Held => {
if held_in_tree(&opts.directory, &dir.destination) {
infer::report(dir, dir.size, infer::Carried::Held);
carried.push((dir.destination.clone(), dir.source.clone(), carry));
} else {
match tree_link(&opts.directory, &dir.destination) {
Some(link) => infer::omit(
dir,
&format!(
"its destination is reached through the symlink {} in the tree",
link.display()
),
),
None => infer::omit(dir, "its destination is already in the tree"),
}
}
}
Carry::Rejected => {
infer::omit(dir, "its destination clashes with another write");
}
Carry::Empty => {
infer::omit(dir, "the build admitted nothing below it");
}
},
InferredPlan::TrimmedData {
files,
size,
kept,
total,
} => {
let wrote = !files.is_empty();
if wrote {
state.actions.push(WalkAction::CarryData {
rule: objects,
src: dir.source.clone(),
rel: dir.destination.clone(),
hooks: Vec::new(),
members: Some(files),
});
}
infer::report(dir, size, infer::Carried::Trimmed { kept, total });
if wrote {
carried.push((dir.destination.clone(), dir.source.clone(), Carry::Planned));
} else if held_in_tree(&opts.directory, &dir.destination) {
carried.push((dir.destination.clone(), dir.source.clone(), Carry::Held));
}
}
InferredPlan::Plugins {
collection,
firsts,
files,
held,
size,
trimmed,
} => {
queue_collection(
&collection,
&origin,
excludes,
reachable,
&firsts,
state,
sysroot,
)?;
let carried_files = files.len();
if !files.is_empty() {
state.actions.push(WalkAction::CarryData {
rule: objects,
src: dir.source.clone(),
rel: dir.destination.clone(),
hooks: Vec::new(),
members: Some(files),
});
}
infer::report(
dir,
size,
infer::Carried::Plugins(&collection, carried_files + held, trimmed),
);
if !collection.plugins.is_empty() || carried_files > 0 {
carried.push((dir.destination.clone(), dir.source.clone(), Carry::Planned));
} else if held > 0 && held_in_tree(&opts.directory, &dir.destination) {
carried.push((dir.destination.clone(), dir.source.clone(), Carry::Held));
}
}
}
Ok(())
}
fn plan_member_copy(
directory: &Path,
plugin: &collect::CollectedPlugin,
origin: &str,
plan: &mut DestinationPlan,
actions: &mut Vec<WalkAction>,
) -> io::Result<()> {
let rel = &plugin.destination;
if fs::symlink_metadata(directory.join(rel)).is_ok() {
return Ok(());
}
let bytes = fs::read(&plugin.resolved)?;
plan.note_source(&plugin.resolved, &bytes)?;
if plan.admit_file(rel, plan::file_hash(&bytes), origin, Existing::SkipOnAny)?
== Admission::Write
{
actions.push(WalkAction::CopyLib {
soname: plugin.soname.clone(),
resolved: plugin.resolved.clone(),
rel: rel.clone(),
size: bytes.len() as u64,
requirer: origin.to_string(),
stale_flat: false,
libc_data: false,
});
}
Ok(())
}
fn admitted_members(
universe: &sysroot::SysrootUniverse,
hit: &CandidateHit,
plan: &mut DestinationPlan,
origin: &str,
) -> io::Result<Vec<(PathBuf, PathBuf)>> {
let Ok(dir_rel) = hit.resolved.strip_prefix(universe.root()) else {
return Ok(Vec::new());
};
let dir_rel = dir_rel.to_string_lossy().into_owned();
let mut members = Vec::new();
for file in universe.admitted_under(&dir_rel) {
let Ok(under) = Path::new(&file).strip_prefix(&dir_rel) else {
continue;
};
let from = universe.root().join(&file);
let rel = hit.destination.join(under);
let meta = fs::symlink_metadata(&from)?;
let admission = if meta.file_type().is_symlink() {
let target = fs::read_link(&from)?;
if !target.is_relative() {
continue;
}
plan.admit_link(&rel, &target.to_string_lossy(), origin, Existing::SkipOnAny)?
} else if meta.is_file() {
plan.admit_carried(&rel, &from, origin)?
} else {
continue;
};
if admission == Admission::Write {
members.push((from, rel));
}
}
Ok(members)
}
fn data_miss(rule: &str, declared: &ResourceGroup, outcome: &GroupOutcome) -> Finding {
let examined = examined_locations(outcome);
let reason = match declared.requirement {
Requirement::Required => {
let mut text = format!(
"group {} requires {} data at {}, but none of its declared locations \
exist in the selected source",
declared.id.as_str(),
match declared.kind {
ResourceKind::File => "file",
ResourceKind::Directory => "directory",
},
declared.destination.as_str()
);
for location in examined {
text.push_str("; ");
text.push_str(&location);
}
text
}
Requirement::Optional => format!(
"group {}: optional data unavailable ({}); the build continues without it",
declared.id.as_str(),
if examined.is_empty() {
"no declared location".to_string()
} else {
examined.join(", ")
}
),
};
Finding::new(
declared.destination.as_str(),
match declared.requirement {
Requirement::Required => Outcome::RequiredFailure,
Requirement::Optional => Outcome::OptionalOmission,
},
reason,
)
.of_rule(rule)
}
fn examined_locations(outcome: &GroupOutcome) -> Vec<String> {
let mut shown: Vec<String> = Vec::new();
for miss in outcome.misses.iter().take(12) {
let text = format!("{}: {}", miss.root.display(), miss_text(&miss.reason));
if !shown.contains(&text) {
shown.push(text);
}
}
shown.truncate(3);
shown
}
pub(crate) fn miss_text(reason: &MissReason) -> String {
match reason {
MissReason::Absent => "absent".to_string(),
MissReason::WrongKind {
actual: ResourceKind::File,
} => "a file where a directory is declared".to_string(),
MissReason::WrongKind {
actual: ResourceKind::Directory,
} => "a directory where a file is declared".to_string(),
MissReason::OutsideRoot => "outside the admitted root".to_string(),
MissReason::NotAdmitted(refusal) => match refusal {
sysroot::Refusal::Absent => "not in the selected source".to_string(),
sysroot::Refusal::HostPackage { owner } => {
format!("left to the host package {owner}")
}
sysroot::Refusal::UnselectedPackage { owner } => {
format!("owned by the unselected package {owner}")
}
sysroot::Refusal::Pruned { owner } => format!("pruned from the package {owner}"),
},
}
}
#[allow(clippy::too_many_arguments)]
fn collect_helper_group(
directory: &Path,
descriptor: &Descriptor,
declared: &HelperGroup,
admitted: &[AdmittedLibrary],
sysroot: Option<&sysroot::SysrootUniverse>,
search_paths: &mut Vec<PathBuf>,
repair_extra: &mut HashSet<PathBuf>,
destinations: &mut HashMap<String, PathBuf>,
helpers: &mut HashSet<String>,
needed_by: &mut HashMap<String, String>,
processed: &mut HashSet<String>,
discovery: &mut Discovery,
preparation: &mut SetPreparation,
plan: &mut DestinationPlan,
required: &mut HashSet<PathBuf>,
) -> io::Result<()> {
let rule = descriptor.id.as_str();
if declared.requirement == Requirement::Required {
required.insert(PathBuf::from(declared.destination.as_str()));
}
let owners: Vec<AdmittedLibrary> = admitted
.iter()
.filter(|lib| {
descriptor
.triggers
.iter()
.any(|t| t.matches_soname(lib.soname()))
})
.map(AdmittedLibrary::without_provenance)
.collect();
let outcome = discover_group(
&GroupQuery {
rule,
group: declared.id.as_str(),
kind: declared.kind,
destination: &declared.destination,
candidates: &declared.candidates,
},
&AdmittedUniverse {
libraries: &owners,
sysroot,
},
)?;
let Some(hit) = outcome.found.first() else {
if directory.join(declared.destination.as_str()).exists() {
return Ok(());
}
let supplied = preparation.supply_group(
plan,
&SupplyQuery {
rule,
group: declared.id.as_str(),
kind: declared.kind,
destination: declared.destination.as_str(),
candidates: &declared.candidates,
executable: true,
hooks: &descriptor.build.hooks,
runtime: &descriptor.runtime.paths,
},
)?;
if supplied {
return Ok(());
}
let miss = helper_miss(rule, declared, &outcome);
completeness::record(miss.clone());
return match declared.requirement {
Requirement::Required if !preparation.dry_run => Err(io::Error::new(
io::ErrorKind::NotFound,
format!("rule {rule} {}", miss.reason),
)),
_ => Ok(()),
};
};
let Some(source_name) = hit.resolved.file_name().and_then(|n| n.to_str()) else {
return Ok(());
};
if let Some(dir) = hit.resolved.parent() {
let dir = dir.to_path_buf();
if !search_paths.contains(&dir) {
search_paths.push(dir.clone());
}
repair_extra.insert(dir);
}
destinations
.entry(source_name.to_string())
.or_insert_with(|| hit.destination.clone());
helpers.insert(source_name.to_string());
needed_by
.entry(source_name.to_string())
.or_insert_with(|| format!("rule {rule} helper {}", declared.id.as_str()));
processed.remove(source_name);
discovery.enqueue(source_name)?;
eprintln!(
" {} helper {source_name} for {rule}/{} from {}",
color::bold_green("Collected"),
declared.id.as_str(),
hit.resolved.display()
);
Ok(())
}
fn helper_miss(rule: &str, declared: &HelperGroup, outcome: &GroupOutcome) -> Finding {
let examined = examined_locations(outcome);
let reason = match declared.requirement {
Requirement::Required => {
let mut text = format!(
"helper {} requires the executable at {}, but none of its declared \
locations exist in the selected source",
declared.id.as_str(),
declared.destination.as_str()
);
for location in examined {
text.push_str("; ");
text.push_str(&location);
}
text
}
Requirement::Optional => format!(
"helper {}: optional executable unavailable ({}); the build continues without it",
declared.id.as_str(),
if examined.is_empty() {
"no declared location".to_string()
} else {
examined.join(", ")
}
),
};
Finding::new(
declared.destination.as_str(),
match declared.requirement {
Requirement::Required => Outcome::RequiredFailure,
Requirement::Optional => Outcome::OptionalOmission,
},
reason,
)
.of_rule(rule)
}
pub use onelf_format::runtime_metadata::PREPARED_DIR;
pub fn prepared_subtree(set: &str, pin: &str) -> io::Result<PathBuf> {
sysroot::check_set_name(set)?;
let digest = pin.parse::<blake3::Hash>().map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"set pin is not a blake3 digest",
)
})?;
Ok(Path::new(PREPARED_DIR)
.join(set)
.join(digest.to_hex().as_ref()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedPin {
pub set: String,
pub pin: String,
pub root: PathBuf,
pub files: Vec<String>,
pub rules: Vec<String>,
}
const PREPARED_FILE_LIMIT: u64 = 16 * 1024 * 1024;
const PREPARED_TOTAL_LIMIT: u64 = 64 * 1024 * 1024;
const PREPARED_ENTRY_LIMIT: usize = 4096;
fn artifact_candidate_paths(candidate: &SourceCandidate) -> Vec<String> {
let path = candidate.path.as_str();
let mut paths = Vec::with_capacity(4);
let mut push = |p: String| {
if !paths.contains(&p) {
paths.push(p);
}
};
match candidate.base {
SourceBase::InstallationPrefix => {
push(path.to_string());
push(format!("usr/{path}"));
}
SourceBase::LibraryDir => {
push(format!("lib/{path}"));
push(format!("usr/lib/{path}"));
push(path.to_string());
push(format!("usr/{path}"));
}
}
paths
}
fn check_prepared_helper(rel: &Path, bytes: &[u8]) -> io::Result<()> {
if !bytes.starts_with(b"#!") {
return Ok(());
}
let line_end = bytes
.iter()
.position(|&b| b == b'\n')
.unwrap_or(bytes.len());
let line = String::from_utf8_lossy(&bytes[2..line_end]);
let interpreter = line.split_whitespace().next().unwrap_or("");
if interpreter == "/bin/sh" {
return Ok(());
}
Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{}: the set supplies this helper as a script naming the interpreter \
`{interpreter}`, which a launch must never run from a set host. Ship the \
helper as an ELF executable, or as a /bin/sh script that execs the \
application's own interpreter through the package launcher",
rel.display()
),
))
}
struct PinPreparation {
root: PathBuf,
entries: Vec<(PathBuf, Vec<u8>, bool)>,
hooks: Vec<BuildHookId>,
bytes: u64,
files: Vec<String>,
rules: BTreeSet<String>,
}
struct SupplyQuery<'a> {
rule: &'a str,
group: &'a str,
kind: ResourceKind,
destination: &'a str,
candidates: &'a [SourceCandidate],
executable: bool,
hooks: &'a [BuildHookId],
runtime: &'a [RuntimePathGroup],
}
fn runtime_maps(runtime: &[RuntimePathGroup], destination: &str, lib_dir: &Path) -> bool {
let below_lib = Path::new(destination).strip_prefix(lib_dir).ok();
runtime
.iter()
.flat_map(|group| &group.candidates)
.any(|candidate| match candidate.base {
RuntimeBase::PackageRoot => candidate.path.as_str() == destination,
RuntimeBase::LibraryRoot => {
below_lib.is_some_and(|rest| rest == Path::new(candidate.path.as_str()))
}
})
}
struct SetPreparation<'a> {
sets: &'a [sysroot::SharedSet],
sysroot_sets: &'a [sysroot::SharedSet],
lib_dir: &'a Path,
dry_run: bool,
reads: HashMap<String, sysroot::VerifiedSet>,
pins: BTreeMap<(String, String), PinPreparation>,
warned: HashSet<String>,
}
impl<'a> SetPreparation<'a> {
fn new(
sets: &'a [sysroot::SharedSet],
sysroot_sets: &'a [sysroot::SharedSet],
lib_dir: &'a Path,
dry_run: bool,
) -> Self {
Self {
sets,
sysroot_sets,
lib_dir,
dry_run,
reads: HashMap::new(),
pins: BTreeMap::new(),
warned: HashSet::new(),
}
}
fn active(&self) -> bool {
!(self.sets.is_empty() && self.sysroot_sets.is_empty()) && !self.dry_run
}
fn supply_group(
&mut self,
plan: &mut DestinationPlan,
query: &SupplyQuery,
) -> io::Result<bool> {
if !self.active() || !runtime_maps(query.runtime, query.destination, self.lib_dir) {
return Ok(false);
}
let mut supplied = false;
let lists = [self.sets, self.sysroot_sets];
for set in lists.into_iter().flatten() {
let read = match open_set_read(&mut self.reads, &mut self.warned, set) {
Ok(read) => read,
Err(_) => continue,
};
let Some(found) = take_candidate(read, set, query)? else {
continue;
};
let root = prepared_subtree(&set.name, &set.blake3)?;
let key = (set.name.clone(), set.blake3.to_ascii_lowercase());
let bytes_in_pin: u64 = self.pins.get(&key).map(|p| p.bytes).unwrap_or(0);
let origin = format!(
"set {} (blake3 {}) rule {} group {} prepared",
set.name,
short_pin(&key.1),
query.rule,
query.group
);
let mut staged: Vec<(PathBuf, Vec<u8>, bool)> = Vec::new();
let mut staged_files: Vec<String> = Vec::new();
let mut staged_bytes = 0u64;
for (rel, bytes) in found {
if bytes.len() as u64 > PREPARED_FILE_LIMIT {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{}: larger than the {PREPARED_FILE_LIMIT} byte preparation \
limit; keep this resource in the set instead of preparing it",
root.join(&rel).display()
),
));
}
if bytes_in_pin + staged_bytes + bytes.len() as u64 > PREPARED_TOTAL_LIMIT {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"set {} (blake3 {}): prepared resources would pass the \
{PREPARED_TOTAL_LIMIT} byte per-pin limit; keep the bulk in \
the set",
set.name,
short_pin(&key.1)
),
));
}
let app_rel = root.join(&rel);
match plan.admit_file(
&app_rel,
plan::file_hash(&bytes),
&origin,
Existing::SkipOnAny,
)? {
Admission::Write => {
staged_bytes += bytes.len() as u64;
staged_files.push(app_rel.to_string_lossy().into_owned());
staged.push((rel, bytes, query.executable));
}
Admission::Skip | Admission::Rejected => {}
}
}
supplied = true;
let pin = self
.pins
.entry(key.clone())
.or_insert_with(|| PinPreparation {
root,
entries: Vec::new(),
hooks: Vec::new(),
bytes: bytes_in_pin,
files: Vec::new(),
rules: BTreeSet::new(),
});
pin.rules.insert(query.rule.to_string());
pin.bytes += staged_bytes;
pin.entries.extend(staged);
pin.files.extend(staged_files);
pin.files.sort();
pin.files.dedup();
for hook in query.hooks {
if !pin.hooks.contains(hook) {
pin.hooks.push(*hook);
}
}
}
Ok(supplied)
}
fn queue_actions(&mut self, actions: &mut Vec<WalkAction>) -> Vec<PreparedPin> {
let mut prepared = Vec::new();
for ((set, pin), done) in self.pins.iter_mut() {
if !done.entries.is_empty() {
let mut entries = std::mem::take(&mut done.entries);
entries.sort_by(|a, b| a.0.cmp(&b.0));
actions.push(WalkAction::PreparePin {
set: set.clone(),
pin: pin.clone(),
root: done.root.clone(),
entries,
hooks: done.hooks.clone(),
});
}
prepared.push(PreparedPin {
set: set.clone(),
pin: pin.clone(),
root: done.root.clone(),
files: std::mem::take(&mut done.files),
rules: done.rules.iter().cloned().collect(),
});
}
prepared
}
}
thread_local! {
static LAST_PREPARED: RefCell<Vec<PreparedPin>> = const { RefCell::new(Vec::new()) };
static LAST_PAIRS: RefCell<Vec<PathPair>> = const { RefCell::new(Vec::new()) };
}
fn publish_prepared(prepared: Vec<PreparedPin>) {
LAST_PREPARED.with(|cell| *cell.borrow_mut() = prepared);
}
pub fn take_prepared() -> Vec<PreparedPin> {
LAST_PREPARED.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
}
fn write_inferred_paths(directory: &Path, pairs: &[PathPair]) -> io::Result<()> {
let file = onelf_format::paths::INFERRED_PATHS_FILE;
let path = directory.join(file);
if pairs.is_empty() {
match fs::remove_file(&path) {
Ok(()) => sysroot::remove_generated(directory, &[file.to_string()])?,
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
return Ok(());
}
let bytes = onelf_format::paths::encode(pairs).map_err(io::Error::other)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, bytes)?;
normalize_mtime(&path);
sysroot::record_generated(directory, vec![file.to_string()])
}
fn publish_pairs(pairs: Vec<PathPair>) {
LAST_PAIRS.with(|cell| *cell.borrow_mut() = pairs);
}
pub fn take_pairs() -> Vec<PathPair> {
LAST_PAIRS.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
}
fn open_set_read<'r>(
reads: &'r mut HashMap<String, sysroot::VerifiedSet>,
warned: &mut HashSet<String>,
set: &sysroot::SharedSet,
) -> Result<&'r mut sysroot::VerifiedSet, String> {
if reads.contains_key(&set.name) {
return Ok(reads.get_mut(&set.name).unwrap());
}
match sysroot::VerifiedSet::open(set) {
Ok(read) => {
reads.insert(set.name.clone(), read);
Ok(reads.get_mut(&set.name).unwrap())
}
Err(why) => {
if warned.insert(set.name.clone()) {
eprintln!(
" {} set {} could not be prepared from ({why}); its resources stay \
in the set",
color::bold_red("warning:"),
color::cyan(&set.name)
);
}
Err(why)
}
}
}
fn read_verified(read: &mut sysroot::VerifiedSet, path: &str) -> io::Result<Option<Vec<u8>>> {
read.read(path).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("set {}: {e}", read.set()),
)
})
}
type PreparedEntry = (PathBuf, Vec<u8>);
fn take_candidate(
read: &mut sysroot::VerifiedSet,
set: &sysroot::SharedSet,
query: &SupplyQuery,
) -> io::Result<Option<Vec<PreparedEntry>>> {
for candidate in query.candidates {
for base in artifact_candidate_paths(candidate) {
match query.kind {
ResourceKind::File => {
if let Some(bytes) = read_verified(read, &base)? {
let rel = prepared_rel(query, Path::new(""))?;
if query.executable {
check_prepared_helper(&rel, &bytes)?;
}
return Ok(Some(vec![(rel, bytes)]));
}
}
ResourceKind::Directory => {
let prefix = format!("{base}/");
let mut under: Vec<String> = read
.file_paths()
.into_iter()
.filter(|path| path.starts_with(&prefix))
.collect();
if under.is_empty() {
continue;
}
under.sort();
if under.len() > PREPARED_ENTRY_LIMIT {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"set {} rule {} group {}: more than \
{PREPARED_ENTRY_LIMIT} entries under {}; keep the \
tree in the set",
set.name,
query.rule,
query.group,
read.build().display()
),
));
}
let mut found = Vec::with_capacity(under.len());
for path in under {
let bytes = read_verified(read, &path)?.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("{}: vanished from the verified listing", path),
)
})?;
let rel = prepared_rel(query, Path::new(&path[prefix.len()..]))?;
found.push((rel, bytes));
}
return Ok(Some(found));
}
}
}
}
Ok(None)
}
fn prepared_rel(query: &SupplyQuery, under: &Path) -> io::Result<PathBuf> {
let mut rel = PathBuf::from(query.destination);
if !under.as_os_str().is_empty() {
rel.push(under);
}
if !rel
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)))
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"rule {} group {}: {} would leave the prepared subtree",
query.rule,
query.group,
rel.display()
),
));
}
Ok(rel)
}
#[derive(Debug)]
enum WalkAction {
CopyLib {
soname: String,
resolved: PathBuf,
rel: PathBuf,
size: u64,
requirer: String,
stale_flat: bool,
libc_data: bool,
},
LinkAlias {
soname: String,
resolved: PathBuf,
rel: PathBuf,
existing: PathBuf,
requirer: String,
size: u64,
stale_flat: bool,
},
CarryData {
rule: String,
src: PathBuf,
rel: PathBuf,
hooks: Vec<BuildHookId>,
members: Option<Vec<(PathBuf, PathBuf)>>,
},
RunStagedHooks { hooks: Vec<BuildHookId> },
PreparePin {
set: String,
pin: String,
root: PathBuf,
entries: Vec<(PathBuf, Vec<u8>, bool)>,
hooks: Vec<BuildHookId>,
},
MigrateFlat { flat: PathBuf, new_rel: PathBuf },
}
#[derive(Default)]
struct WalkOutcome {
copied: Vec<(String, PathBuf, u64, String)>,
written: Vec<String>,
}
impl WalkOutcome {
fn wrote(&mut self, rel: &Path) {
self.written.push(rel.to_string_lossy().into_owned());
}
}
fn execute_walk(
directory: &Path,
dry_run: bool,
strip: bool,
lib_dest: &Path,
actions: &[WalkAction],
plan: &DestinationPlan,
outcome: &mut WalkOutcome,
) -> io::Result<()> {
let lib_dir = lib_dest
.strip_prefix(directory)
.unwrap_or(lib_dest)
.to_path_buf();
for action in actions {
match action {
WalkAction::CopyLib {
soname,
resolved,
rel,
size,
requirer,
stale_flat,
libc_data,
} => {
let bytes = if dry_run {
plan.verify_source(resolved)?;
Vec::new()
} else {
plan.read_verified(resolved)?
};
let dest = directory.join(rel);
eprintln!(
" {} <- {} (needed by {}, {})",
color::bold_green(&rel.to_string_lossy()),
resolved.display(),
color::cyan(requirer),
color::dim(&format_size(*size))
);
if !dry_run {
if let Some(dir) = dest.parent() {
fs::create_dir_all(dir)?;
}
if dest.is_symlink() {
let _ = fs::remove_file(&dest);
}
ensure_writable(&dest);
fs::write(&dest, &bytes)?;
outcome.wrote(rel);
let _ = fs::set_permissions(
&dest,
std::os::unix::fs::PermissionsExt::from_mode(0o755),
);
normalize_mtime(&dest);
let is_loader = is_dynamic_loader(soname)
|| libc_family_of_soname(soname) == Some(LibcFamily::Musl);
if is_loader
&& let Err(e) = scrub_loader_paths(
&dest,
libc_family_of_soname(soname).unwrap_or(LibcFamily::Glibc),
)
{
eprintln!(
" {} failed to scrub loader paths in {}: {e}",
color::bold_red("warning:"),
soname
);
}
if strip {
strip_debug(&dest);
}
}
if *stale_flat {
migrate_flat_plugin(&lib_dest.join(soname), rel, &lib_dir, lib_dest, dry_run);
}
outcome
.copied
.push((soname.clone(), rel.clone(), *size, requirer.clone()));
if *libc_data && !dry_run {
match bundle_gconv(resolved, lib_dest, strip) {
Ok(files) if !files.is_empty() => {
eprintln!(
" {} {} gconv module(s) beside libc",
color::bold_green("Copied"),
files.len()
);
for file in &files {
outcome.wrote(&lib_dir.join(file));
}
}
Ok(_) => {}
Err(e) => eprintln!(
" {} could not copy gconv modules: {e}",
color::bold_red("warning:")
),
}
match bundle_c_utf8(resolved, lib_dest) {
Ok(files) if !files.is_empty() => {
eprintln!(" {} the C.UTF-8 locale", color::bold_green("Copied"));
for file in &files {
outcome.wrote(&lib_dir.join(file));
}
}
Ok(_) => {}
Err(e) => eprintln!(
" {} could not copy the C.UTF-8 locale: {e}",
color::bold_red("warning:")
),
}
}
}
WalkAction::LinkAlias {
soname,
resolved,
rel,
existing,
requirer,
size,
stale_flat,
} => {
plan.verify_source(resolved)?;
eprintln!(
" {} {} -> {} (alias for {}, {})",
color::bold_green("Linked"),
soname,
existing.display(),
color::cyan(requirer),
color::dim(&format_size(*size))
);
if !dry_run {
let dest = directory.join(rel);
if let Some(dir) = dest.parent() {
fs::create_dir_all(dir)?;
}
if dest.exists() || dest.is_symlink() {
let _ = fs::remove_file(&dest);
}
let target = link_target(rel.parent().unwrap_or(Path::new("")), existing);
match std::os::unix::fs::symlink(&target, &dest) {
Ok(()) => outcome.wrote(rel),
Err(e) => eprintln!(
" {} failed to symlink {} -> {}: {e}",
color::bold_red("warning:"),
soname,
target
),
}
}
if *stale_flat {
migrate_flat_plugin(&lib_dest.join(soname), rel, &lib_dir, lib_dest, dry_run);
}
}
WalkAction::CarryData {
rule,
src,
rel,
hooks,
members,
} => {
if let Some(members) = members {
for (from, _) in members {
plan.verify_source(from)?;
}
} else if src.is_dir() {
plan.verify_tree(src, rel)?;
} else {
plan.verify_source(src)?;
}
let dest = directory.join(rel);
if dry_run {
eprintln!(
" {} {} (data of {})",
color::bold("Would copy"),
rel.display(),
color::cyan(rule)
);
continue;
}
let mut carried: Vec<PathBuf> = Vec::new();
let copied = match members {
Some(members) => copy_members(members, directory, plan, &mut carried),
None => copy_data(src, &dest, plan, &mut carried).and_then(|bytes| {
if src.is_dir() {
plan.verify_tree(&dest, rel)?;
}
Ok(bytes)
}),
};
match copied {
Ok(bytes) => {
for path in &carried {
outcome.wrote(path.strip_prefix(directory).unwrap_or(path));
}
eprintln!(
" {} {} <- {} (data of {}, {})",
color::bold_green("Copied"),
rel.display(),
src.display(),
color::cyan(rule),
color::dim(&format_size(bytes))
);
run_staged_hooks(hooks, directory, dry_run);
}
Err(e) => {
for path in carried.iter().rev() {
let _ = fs::remove_file(path);
}
if e.kind() == io::ErrorKind::InvalidData {
return Err(e);
}
eprintln!(
" {} could not carry {}: {e}",
color::bold_red("warning:"),
rel.display()
);
}
}
}
WalkAction::RunStagedHooks { hooks } => {
run_staged_hooks(hooks, directory, dry_run);
}
WalkAction::PreparePin {
set,
pin,
root,
entries,
hooks,
} => {
if dry_run {
eprintln!(
" {} {} prepared file(s) for set {} (blake3 {})",
color::bold("Would write"),
entries.len(),
color::cyan(set),
short_pin(pin)
);
continue;
}
let base = directory.join(root);
for (rel, bytes, executable) in entries {
let dest = base.join(rel);
if let Some(dir) = dest.parent() {
fs::create_dir_all(dir)?;
}
if dest.is_symlink() {
let _ = fs::remove_file(&dest);
}
ensure_writable(&dest);
fs::write(&dest, bytes)?;
outcome.wrote(&root.join(rel));
let mode = if *executable { 0o755 } else { 0o644 };
let _ = fs::set_permissions(
&dest,
std::os::unix::fs::PermissionsExt::from_mode(mode),
);
normalize_mtime(&dest);
}
run_staged_hooks(hooks, &base, dry_run);
eprintln!(
" {} {} prepared file(s) for set {} (blake3 {})",
color::bold_green("Prepared"),
entries.len(),
color::cyan(set),
short_pin(pin)
);
}
WalkAction::MigrateFlat { flat, new_rel } => {
migrate_flat_plugin(flat, new_rel, &lib_dir, lib_dest, dry_run);
}
}
}
Ok(())
}
fn patch_carried_alsa_conf(directory: &Path, dry_run: bool) {
if dry_run {
return;
}
match patch_alsa_conf_hooks(&directory.join("share/alsa/alsa.conf")) {
Ok(true) => eprintln!(
" {} share/alsa/alsa.conf hooks (carried snippets load after the host's)",
color::bold_green("Patched")
),
Ok(false) => {}
Err(e) => eprintln!(
" {} could not patch share/alsa/alsa.conf hooks: {e}",
color::bold_red("warning:")
),
}
}
type StagedHook = fn(&Path, bool);
fn staged_hook(id: BuildHookId) -> StagedHook {
match id {
BuildHookId::AlsaConfPatch => patch_carried_alsa_conf,
}
}
fn run_staged_hooks(hooks: &[BuildHookId], directory: &Path, dry_run: bool) {
for id in hooks {
staged_hook(*id)(directory, dry_run);
}
}
fn migrate_flat_plugin(
flat: &Path,
new_rel: &Path,
lib_dir: &Path,
lib_dest: &Path,
dry_run: bool,
) {
let links = alias_links_to(lib_dest, flat);
if dry_run {
eprintln!(
" {} {} (a flat copy in lib/ would move there)",
color::bold("Would migrate"),
new_rel.display()
);
return;
}
for link in links {
let from = link
.strip_prefix(lib_dest)
.unwrap_or(&link)
.parent()
.map(|p| lib_dir.join(p))
.unwrap_or_else(|| lib_dir.to_path_buf());
let target = link_target(&from, new_rel);
if let Err(e) = fs::remove_file(&link) {
eprintln!(
" {} could not retarget {}: {e}",
color::bold_red("warning:"),
link.display()
);
continue;
}
if let Err(e) = std::os::unix::fs::symlink(&target, &link) {
eprintln!(
" {} failed to retarget {} -> {}: {e}",
color::bold_red("warning:"),
link.display(),
target
);
continue;
}
}
match fs::remove_file(flat) {
Ok(()) => eprintln!(
" {} {} (moved out of lib/)",
color::bold_green("Moved"),
new_rel.display()
),
Err(e) => eprintln!(
" {} could not remove the flat copy {}: {e}",
color::bold_red("warning:"),
flat.display()
),
}
}
fn alias_links_to(root: &Path, target: &Path) -> Vec<PathBuf> {
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in path.components() {
match comp {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
comp => out.push(comp.as_os_str()),
}
}
out
}
let wanted = normalize(target);
let mut found = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = dir.join(entry.file_name());
let Ok(kind) = entry.file_type() else {
continue;
};
if kind.is_dir() {
stack.push(path);
} else if kind.is_symlink()
&& let Ok(link) = fs::read_link(&path)
{
let resolved = normalize(&dir.join(link));
if resolved == wanted {
found.push(path);
}
}
}
}
found
}
#[derive(Clone, Default)]
pub struct BundleOptions {
pub directory: PathBuf,
pub target: Option<PathBuf>,
pub primary: Option<PathBuf>,
pub lib_dir: PathBuf,
pub lib_dirs: Vec<String>,
pub recipe_pairs: Vec<PathPair>,
pub exclude: Vec<String>,
pub include: Vec<String>,
pub search_path: Vec<PathBuf>,
pub dry_run: bool,
pub recursive: bool,
pub gl: bool,
pub dri: bool,
pub vulkan: bool,
pub wayland: bool,
pub gtk: bool,
pub no_gl: bool,
pub no_dri: bool,
pub no_vulkan: bool,
pub no_wayland: bool,
pub no_gtk: bool,
pub strip: bool,
pub strict_libc: bool,
pub scan_dlopen: bool,
pub trace_run: bool,
pub trace_seconds: u64,
pub trace_args: Vec<String>,
pub trim_inferred: bool,
pub dlopen_extra: Vec<String>,
pub sysroot: Option<SysrootOptions>,
pub sets: Vec<sysroot::SharedSet>,
pub entrypoint_selections: Vec<AuditedEntrypoint>,
pub platform_pin: Option<onelf_sysroot::platform::Pin>,
pub rules: crate::rules::RuleCatalog,
pub strict: bool,
}
fn strip_debug(path: &Path) {
if fs::read(path)
.map(|d| has_embedded_payload(&d))
.unwrap_or(false)
{
return;
}
match Command::new("strip")
.arg("--strip-unneeded")
.arg(path)
.output()
{
Ok(out) if !out.status.success() => {
eprintln!(
" {} strip failed for {}: {}",
color::bold_red("warning:"),
path.display(),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Err(e) => {
eprintln!(
" {} strip failed for {}: {e}",
color::bold_red("warning:"),
path.display()
);
}
_ => {}
}
}
fn attest_staged_outputs(opts: &BundleOptions) -> io::Result<()> {
if opts.directory.join(sysroot::GENERATED_FILE).is_file() {
sysroot::attest_generated(&opts.directory)?;
}
Ok(())
}
fn report_activations(activations: &[RuleActivation]) {
if activations.is_empty() {
return;
}
const SHOWN_RULES: usize = 8;
let shown: Vec<String> = activations
.iter()
.take(SHOWN_RULES)
.map(|a| format!("{} ({})", a.rule, a.reason))
.collect();
let hidden = activations.len().saturating_sub(SHOWN_RULES);
let more = if hidden > 0 {
format!(" and {hidden} more")
} else {
String::new()
};
let groups: usize = activations.iter().map(|a| a.groups.len()).sum();
eprintln!(
" {} {} activated by soname prefix: {}{}; {} group(s) queued",
color::bold("Rules:"),
activations.len(),
shown.join(", "),
more,
groups
);
}
fn mirror_new_groups(
discovery: &Discovery,
known: &mut HashSet<String>,
mirror: &mut VecDeque<(String, String)>,
) {
for activation in discovery.activations() {
if !known.insert(activation.rule.clone()) {
continue;
}
for group in activation.groups {
mirror.push_back((activation.rule.clone(), group));
}
}
}
fn report_collection(collection: &collect::GroupCollection, destination: &str) {
let label = format!("{}/{}", collection.rule, collection.group);
if collection.plugins.is_empty() {
let mut reason = format!(
"group {}: no usable plugin group ({} location(s) unavailable, {} candidate(s) \
skipped)",
collection.group,
collection.misses.len(),
collection.skipped.len()
);
for miss in collection.misses.iter().take(3) {
reason.push_str("; ");
reason.push_str(miss);
}
completeness::record(
Finding::new(destination, Outcome::OptionalOmission, reason).of_rule(&collection.rule),
);
} else {
let dirs: Vec<String> = collection
.search_dirs
.iter()
.map(|d| d.display().to_string())
.collect();
completeness::record(
Finding::new(
destination,
Outcome::Selected,
format!(
"group {}: {} plugin(s) from {}",
collection.group,
collection.plugins.len(),
dirs.join(", ")
),
)
.of_rule(&collection.rule),
);
eprintln!(
" {} {} plugin(s) for {} from {} director{}",
color::bold_green("Collected"),
collection.plugins.len(),
label,
collection.search_dirs.len(),
if collection.search_dirs.len() == 1 {
"y"
} else {
"ies"
},
);
for reason in collection.misses.iter().take(3) {
eprintln!(" {reason}");
}
}
const SHOWN_SKIPS: usize = 4;
for (name, reason) in collection.skipped.iter().take(SHOWN_SKIPS) {
eprintln!(" {} skipped: {reason}", color::dim(name));
}
let hidden = collection.skipped.len().saturating_sub(SHOWN_SKIPS);
if hidden > 0 {
eprintln!(" and {hidden} more skipped");
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SourceVerdict {
Carried,
Pruned {
owner: String,
},
HostProvider {
owner: String,
},
UnselectedProvider {
owner: String,
},
Absent,
}
impl SourceVerdict {
fn omittable(&self) -> bool {
matches!(
self,
SourceVerdict::Absent
| SourceVerdict::UnselectedProvider { .. }
| SourceVerdict::HostProvider { .. }
)
}
fn reason(&self) -> String {
match self {
SourceVerdict::Carried => {
"the selected source holds it off every loader path".to_string()
}
SourceVerdict::Pruned { owner } => {
format!("{owner} holds it, but the policy or a trace removed it")
}
SourceVerdict::HostProvider { owner } => format!("left to the host package {owner}"),
SourceVerdict::UnselectedProvider { owner } => {
format!("provided only by the unselected optional package {owner}")
}
SourceVerdict::Absent => "no selected source provides it".to_string(),
}
}
}
struct SourceClassifier<'a> {
providers: HashMap<String, Vec<(String, String)>>,
universe: Option<&'a sysroot::SysrootUniverse>,
}
impl<'a> SourceClassifier<'a> {
fn from_db(db: Database, universe: &'a sysroot::SysrootUniverse) -> Self {
let mut providers: HashMap<String, Vec<(String, String)>> = HashMap::new();
for package in db.packages() {
for file in &package.files {
if file.ends_with('/') {
continue;
}
if let Some(name) = Path::new(file).file_name().and_then(|n| n.to_str()) {
providers
.entry(name.to_string())
.or_default()
.push((package.name.clone(), file.clone()));
}
}
}
Self {
providers,
universe: Some(universe),
}
}
fn for_sysroot(root: &Path, universe: &'a sysroot::SysrootUniverse) -> io::Result<Self> {
Ok(Self::from_db(Database::read(root)?, universe))
}
fn host_only() -> Self {
Self {
providers: HashMap::new(),
universe: None,
}
}
fn classify(&self, soname: &str) -> SourceVerdict {
let Some(universe) = self.universe else {
return SourceVerdict::Absent;
};
let mut verdict = SourceVerdict::Absent;
for (_owner, file) in self.providers.get(soname).into_iter().flatten() {
let candidate = if universe.admits(file) {
SourceVerdict::Carried
} else {
match universe.refusal(file) {
sysroot::Refusal::Pruned { owner } => SourceVerdict::Pruned { owner },
sysroot::Refusal::UnselectedPackage { owner } => {
SourceVerdict::UnselectedProvider { owner }
}
sysroot::Refusal::HostPackage { owner } => {
SourceVerdict::HostProvider { owner }
}
sysroot::Refusal::Absent => SourceVerdict::Absent,
}
};
if matches!(candidate, SourceVerdict::Pruned { .. }) {
return candidate;
}
verdict = rank_verdicts(verdict, candidate);
}
verdict
}
}
fn rank_verdicts(a: SourceVerdict, b: SourceVerdict) -> SourceVerdict {
fn rank(verdict: &SourceVerdict) -> u8 {
match verdict {
SourceVerdict::Carried => 3,
SourceVerdict::UnselectedProvider { .. } => 2,
SourceVerdict::HostProvider { .. } => 1,
SourceVerdict::Pruned { .. } | SourceVerdict::Absent => 0,
}
}
if rank(&a) >= rank(&b) { a } else { b }
}
fn explicit_include_paths(opts: &BundleOptions) -> HashSet<PathBuf> {
opts.include
.iter()
.map(|soname| opts.lib_dir.join(soname))
.collect()
}
#[cfg(test)]
mod source_verdict_tests {
use super::*;
use sysroot::fixture::{package, populate_universe};
fn scratch(tag: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("onelf-verdict-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
root
}
fn classified(
root: &Path,
packages: &[onelf_sysroot::Package],
owner: &str,
policy_lines: &[&str],
) -> (Database, sysroot::SysrootUniverse) {
let mut db = Database::default();
for package in packages {
db.insert(package.clone());
}
let policy = if policy_lines.is_empty() {
None
} else {
let path = root.join("policy.txt");
fs::write(&path, policy_lines.join("\n")).unwrap();
Some(Policy::load(&path).unwrap())
};
let u = populate_universe(root, packages, owner, &[], policy.as_ref(), None);
(db, u)
}
fn pkg(name: &str, depends: &[&str], files: &[&str]) -> onelf_sysroot::Package {
onelf_sysroot::Package {
name: name.to_string(),
version: "1.0-1".to_string(),
depends: depends.iter().map(|s| s.to_string()).collect(),
files: files.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
#[test]
fn an_absent_soname_classifies_absent() {
let root = scratch("absent");
let packages = [package("app", &["usr/bin/app"])];
let (db, u) = classified(&root, &packages, "app", &[]);
let classifier = SourceClassifier::from_db(db, &u);
assert_eq!(
classifier.classify("libnothing.so.9"),
SourceVerdict::Absent,
"no package owns a file of that name"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn an_unselected_provider_names_its_package() {
let root = scratch("unselected");
let packages = [
package("app", &["usr/bin/app"]),
package("extra", &["usr/lib/extra/libextra.so.1"]),
];
let (db, u) = classified(&root, &packages, "app", &[]);
let classifier = SourceClassifier::from_db(db, &u);
assert_eq!(
classifier.classify("libextra.so.1"),
SourceVerdict::UnselectedProvider {
owner: "extra".to_string()
},
"the provider is real but the recipe never selected it"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_pruned_provider_names_the_policy_conflict() {
let root = scratch("pruned");
let packages = [
pkg("app", &["core"], &["usr/bin/app"]),
pkg("core", &[], &["usr/lib/libpinned.so.1"]),
];
let (db, u) = classified(&root, &packages, "app", &["usr/lib/libpinned*"]);
let classifier = SourceClassifier::from_db(db, &u);
assert_eq!(
classifier.classify("libpinned.so.1"),
SourceVerdict::Pruned {
owner: "core".to_string()
},
"a selected package held it and the policy removed it"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_kept_soname_classifies_carried() {
let root = scratch("kept");
let packages = [
pkg("app", &["core"], &["usr/bin/app"]),
pkg("core", &[], &["usr/lib/libcore.so.1"]),
];
let (db, u) = classified(&root, &packages, "app", &[]);
let classifier = SourceClassifier::from_db(db, &u);
assert_eq!(
classifier.classify("libcore.so.1"),
SourceVerdict::Carried,
"the build copied it"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_host_provider_names_its_package() {
let root = scratch("host");
let mut db = Database::default();
db.insert(package("app", &["usr/bin/app"]));
db.insert(package("mesa", &["usr/lib/libGL.so.1"]));
let mut left_out = HashSet::new();
left_out.insert("usr/lib/libGL.so.1".to_string());
let mut for_universe = Database::default();
for_universe.insert(package("app", &["usr/bin/app"]));
for_universe.insert(package("mesa", &["usr/lib/libGL.so.1"]));
let u = sysroot::SysrootUniverse::admit(
&root,
for_universe,
&["usr/bin/app".to_string()],
&left_out,
&["app".to_string()],
);
let classifier = SourceClassifier::from_db(db, &u);
assert_eq!(
classifier.classify("libGL.so.1"),
SourceVerdict::HostProvider {
owner: "mesa".to_string()
},
"the package was handed to the host whole"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_trace_pruned_provider_names_the_conflict_too() {
let root = scratch("trace-pruned");
let packages = [
pkg("app", &["core"], &["usr/bin/app"]),
pkg("core", &[], &["usr/lib/libpinned.so.1"]),
];
let mut db = Database::default();
for package in &packages {
db.insert(package.clone());
}
let trace = onelf_sysroot::Trace::from_paths(vec!["usr/bin/app".to_string()]);
let u = populate_universe(&root, &packages, "app", &[], None, Some(&trace));
let classifier = SourceClassifier::from_db(db, &u);
assert_eq!(
classifier.classify("libpinned.so.1"),
SourceVerdict::Pruned {
owner: "core".to_string()
},
"a trace removal is the conflict class, like a policy removal"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_pruned_provider_outranks_an_unselected_one() {
let root = scratch("outranks");
let packages = [
pkg("app", &["core"], &["usr/bin/app"]),
pkg("core", &[], &["usr/lib/libdual.so.1"]),
pkg("extra", &[], &["opt/extra/lib/libdual.so.1"]),
];
let (db, u) = classified(&root, &packages, "app", &["usr/lib/libdual*"]);
let classifier = SourceClassifier::from_db(db, &u);
assert_eq!(
classifier.classify("libdual.so.1"),
SourceVerdict::Pruned {
owner: "core".to_string()
},
"the removal is the conflict class, ahead of the unselected provider"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_host_scan_classifies_everything_absent() {
let classifier = SourceClassifier::host_only();
assert_eq!(
classifier.classify("libanything.so.1"),
SourceVerdict::Absent,
"without a selected source there is nothing to consult"
);
}
}
#[cfg(test)]
mod carry_tests {
use super::*;
use sysroot::fixture::{package, populate_universe};
#[test]
fn a_refused_sysroot_member_leaves_one_sibling_and_a_finding() {
let root = std::env::temp_dir().join(format!("onelf-carry-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let sysroot = root.join("sysroot");
let appdir = root.join("app");
fs::create_dir_all(sysroot.join("usr/share/examplefw")).unwrap();
fs::create_dir_all(appdir.join("share/examplefw")).unwrap();
fs::write(sysroot.join("usr/share/examplefw/a.conf"), "conf\n").unwrap();
fs::write(sysroot.join("usr/share/examplefw/b.conf"), "more\n").unwrap();
fs::write(appdir.join("share/examplefw/a.conf"), "authored\n").unwrap();
let packages = [package(
"examplefw",
&[
"usr/lib/libexamplefw.so.1",
"usr/share/examplefw/a.conf",
"usr/share/examplefw/b.conf",
],
)];
let universe = populate_universe(&sysroot, &packages, "examplefw", &[], None, None);
let hit = CandidateHit {
candidate: 0,
base: SourceBase::InstallationPrefix,
kind: ResourceKind::Directory,
source: sysroot.join("usr/share/examplefw"),
resolved: sysroot.join("usr/share/examplefw"),
root: sysroot.clone(),
destination: PathBuf::from("share/examplefw"),
};
let mut plan = DestinationPlan::load(&appdir).unwrap();
let members = admitted_members(
&universe,
&hit,
&mut plan,
"inferred from libexamplefw.so.1",
)
.unwrap();
assert_eq!(
members,
vec![(
sysroot.join("usr/share/examplefw/b.conf"),
PathBuf::from("share/examplefw/b.conf"),
)],
"the refused member is not handed out, its sibling is"
);
assert!(plan.has_findings(), "the refusal leaves a finding");
let _ = fs::remove_dir_all(&root);
}
}
pub fn bundle_libs(opts: &BundleOptions) -> io::Result<()> {
publish_activations(Vec::new());
publish_pairs(Vec::new());
let _no_fetch = opts.dry_run.then(sysroot::forbid_fetch);
let result = if opts.dry_run && opts.sysroot.is_some() && opts.directory.is_dir() {
ScratchTree::copy_of(&opts.directory).and_then(|scratch| {
bundle_tree(&BundleOptions {
directory: scratch.path().to_path_buf(),
..opts.clone()
})
})
} else {
bundle_tree(opts)
};
completeness::report(&activate::published_activations());
result
}
pub(crate) struct ScratchTree(PathBuf);
impl ScratchTree {
pub(crate) fn copy_of(root: &Path) -> io::Result<Self> {
let scratch = ScratchTree(private_temp_dir("dry-run")?);
for entry in jwalk::WalkDir::new(root).skip_hidden(false).sort(true) {
let entry = entry.map_err(|e| io::Error::other(e.to_string()))?;
let path = entry.path();
let Ok(rel) = path.strip_prefix(root) else {
continue;
};
if rel.as_os_str().is_empty() {
continue;
}
let dest = scratch.0.join(rel);
let kind = entry.file_type();
if kind.is_dir() {
fs::create_dir_all(&dest)?;
} else if kind.is_symlink() {
std::os::unix::fs::symlink(fs::read_link(&path)?, &dest)?;
} else if kind.is_file() {
clone_file(&path, &dest)?;
}
}
Ok(scratch)
}
pub(crate) fn path(&self) -> &Path {
&self.0
}
}
impl Drop for ScratchTree {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
pub(crate) fn clone_file(source: &Path, dest: &Path) -> io::Result<()> {
let from = fs::File::open(source)?;
let mut to = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(dest)?;
if rustix::fs::ioctl_ficlone(&to, &from).is_err() {
io::copy(&mut &from, &mut to)?;
}
to.set_permissions(from.metadata()?.permissions())
}
fn bundle_tree(opts: &BundleOptions) -> io::Result<()> {
publish_prepared(Vec::new());
if !opts.directory.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
format!("{}: not a directory", opts.directory.display()),
));
}
let declared_sets: Vec<sysroot::SharedSet> = opts
.sets
.iter()
.chain(opts.sysroot.iter().flat_map(|sr| sr.sets.iter()))
.cloned()
.collect();
let set_audit = if !declared_sets.is_empty() {
let (providers, unchecked) = sysroot::audit_providers(&declared_sets);
for provider in &providers {
completeness::record(Finding::new(
format!("set {}", provider.set),
Outcome::Selected,
format!(
"verified pinned build {}, providing {} soname(s)",
short_pin(&provider.pin),
provider.provides.len()
),
));
}
for (set, why) in &unchecked {
completeness::record(Finding::new(
format!("set {set}"),
Outcome::Unverified,
format!("{why}; it satisfies no dependency here"),
));
}
audit_declared_sets(opts, &providers)?;
Some((providers, unchecked))
} else {
None
};
let mut host_files: HashSet<String> = HashSet::new();
let mut set_sonames: HashSet<String> = HashSet::new();
let mut sysroot_universe: Option<sysroot::SysrootUniverse> = None;
let sysroot_platform = match &opts.sysroot {
Some(sr) => {
let (report, platform) = sysroot::populate(&opts.directory, sr)?;
sysroot::print_report(sr, &report);
host_files = report.host_files;
set_sonames = report.set_sonames;
sysroot_universe = Some(report.universe);
Some(platform)
}
None => {
if !opts.dry_run {
let _ = fs::remove_file(opts.directory.join(sysroot::PROVENANCE_FILE));
sysroot::record_sets(&opts.directory, &opts.sets, opts.platform_pin.as_ref())?;
}
None
}
};
let frameworks = opts.sysroot.is_none();
let classifier = match (&opts.sysroot, sysroot_universe.as_ref()) {
(Some(sr), Some(universe)) => SourceClassifier::for_sysroot(&sr.root, universe)?,
_ => SourceClassifier::host_only(),
};
let target_elfs = resolve_target_elfs(opts)?;
let primary = resolve_primary(opts);
let target_class = primary
.as_deref()
.and_then(read_elf_class)
.or_else(|| target_elfs.iter().find_map(|f| read_elf_class(f)));
let target_machine = primary
.as_deref()
.and_then(read_elf_machine)
.or_else(|| target_elfs.iter().find_map(|f| read_elf_machine(f)));
let detected = detect_frameworks(&target_elfs);
let want_gl = frameworks && opts.gl && !opts.no_gl;
let want_dri = frameworks && opts.dri && !opts.no_dri;
let want_vulkan = frameworks && opts.vulkan && !opts.no_vulkan;
let want_wayland = frameworks && (opts.wayland || detected.wayland) && !opts.no_wayland;
let want_gtk = frameworks && (opts.gtk || detected.gtk) && !opts.no_gtk;
let auto: Vec<&str> = [
(detected.wayland && !opts.wayland && want_wayland, "wayland"),
(detected.gtk && !opts.gtk && want_gtk, "gtk"),
]
.into_iter()
.filter_map(|(on, name)| on.then_some(name))
.collect();
if !auto.is_empty() {
eprintln!(
" {} auto-enabled: {}",
color::bold("Frameworks:"),
auto.join(", ")
);
}
let driver_stack: Vec<&str> = [
(detected.gl && !want_gl, "gl"),
(detected.dri && !want_dri, "dri"),
(detected.vulkan && !want_vulkan, "vulkan"),
]
.into_iter()
.filter_map(|(on, name)| on.then_some(name))
.collect();
if frameworks && !driver_stack.is_empty() {
eprintln!(
" {} {} detected; the host's drivers are used at launch. \
Pass --gl, --dri or --vulkan to bundle Mesa instead.",
color::bold("Frameworks:"),
driver_stack.join(", ")
);
}
let suppressed: Vec<&str> = [
(detected.gl && opts.no_gl, "gl"),
(detected.dri && opts.no_dri, "dri"),
(detected.vulkan && opts.no_vulkan, "vulkan"),
(detected.wayland && opts.no_wayland, "wayland"),
(detected.gtk && opts.no_gtk, "gtk"),
]
.into_iter()
.filter_map(|(on, name)| on.then_some(name))
.collect();
if !suppressed.is_empty() {
eprintln!(
" {} suppressed: {}",
color::bold("Frameworks:"),
suppressed.join(", ")
);
}
let excludes: Vec<&str> = DEFAULT_EXCLUDES
.iter()
.copied()
.chain(opts.exclude.iter().map(|s| s.as_str()))
.chain(
sysroot_platform
.iter()
.flat_map(|p| p.prefixes().iter().map(String::as_str)),
)
.collect();
if want_gl || want_dri || want_vulkan {
bundle_gpu(
&opts.directory,
&opts.lib_dir,
&opts.search_path,
&excludes,
target_class,
target_machine,
opts.dry_run,
opts.strip,
want_gl,
want_dri,
want_vulkan,
)?;
}
let mut plan = DestinationPlan::load(&opts.directory)?;
let mut copied_from: HashMap<String, PathBuf> = HashMap::new();
if want_wayland {
let (written, sources) = bundle_wayland(
&opts.directory,
&opts.lib_dir,
&opts.search_path,
&excludes,
target_class,
target_machine,
opts.dry_run,
opts.strip,
&mut plan,
)?;
if !opts.dry_run {
sysroot::record_generated(&opts.directory, written)?;
}
copied_from.extend(sources);
}
if want_gtk {
bundle_gtk_data(&opts.directory, opts.dry_run)?;
}
let elf_files = if opts.target.is_some() {
target_elfs
} else {
find_elf_files(&opts.directory)
};
if elf_files.is_empty() {
eprintln!(
"{} no ELF files found in {}",
color::bold_red("warning:"),
opts.directory.display()
);
return Ok(());
}
eprintln!(
"{} {} ELF file(s)...",
color::bold("Scanning"),
elf_files.len()
);
let mut needed_by: HashMap<String, String> = HashMap::new();
let mut rpath_dirs: Vec<PathBuf> = Vec::new();
for path in &elf_files {
let requirer = path
.strip_prefix(&opts.directory)
.unwrap_or(path)
.to_string_lossy()
.into_owned();
match parse_needed(path) {
Ok(libs) => {
for lib in libs {
needed_by.entry(lib).or_insert_with(|| requirer.clone());
}
}
Err(e) => {
eprintln!("warning: {}: {e}", path.display());
}
}
if let Some(interp) = parse_interp(path)
&& let Some(name) = Path::new(&interp).file_name().and_then(|n| n.to_str())
{
needed_by
.entry(name.to_string())
.or_insert_with(|| format!("{requirer} (PT_INTERP)"));
}
let sysroot_root = opts.sysroot.as_ref().map(|sr| sr.root.as_path());
for dir in parse_rpaths_under(path, sysroot_root)
.into_iter()
.chain(parse_needed_dirs_under(path, sysroot_root))
{
if !rpath_dirs.contains(&dir) {
rpath_dirs.push(dir);
}
}
}
for lib in &opts.include {
needed_by
.entry(lib.clone())
.or_insert_with(|| "--include".into());
}
let mut traced_opened: Vec<PathBuf> = Vec::new();
let mut traced_objects_only = false;
if opts.trace_run && opts.dry_run {
completeness::record(Finding::new(
opts.target
.as_deref()
.map(|t| t.display().to_string())
.unwrap_or_default(),
Outcome::Unchecked,
"a dry run does not run the target, so the libraries a traced run \
would load are not listed",
));
if opts.trim_inferred && opts.sysroot.is_none() {
completeness::record(Finding::new(
opts.target
.as_deref()
.map(|t| t.display().to_string())
.unwrap_or_default(),
Outcome::Unchecked,
"a dry run does not run the target, so trim-inferred trims \
nothing and the inferred directories stay whole",
));
}
} else if opts.trace_run {
if opts.sysroot.is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--trace-run runs the target against this machine, which a sysroot build does not use; record a trace with ONELF_TRACE instead",
));
}
let Some(target) = resolve_primary(opts) else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--trace-run needs --target to name what to run",
));
};
let family = program_interp(&target)
.as_deref()
.and_then(libc_family_from_interp);
eprintln!(
"{} {} for up to {}s to see what it loads...",
color::bold("Running"),
target
.strip_prefix(&opts.directory)
.unwrap_or(&target)
.display(),
opts.trace_seconds
);
let tree = opts.directory.canonicalize()?;
let mut library_path: Vec<PathBuf> = Vec::new();
let tree_dirs = std::iter::once(opts.directory.join(&opts.lib_dir))
.chain(elf_files.iter().flat_map(|f| parse_rpaths(f)))
.filter_map(|d| d.canonicalize().ok())
.filter(|d| d.starts_with(&tree));
let host_dirs = onelf_format::drivers::cache_dirs()
.into_iter()
.map(PathBuf::from)
.chain(STANDARD_LIB_PATHS.iter().map(PathBuf::from));
for dir in tree_dirs
.chain(opts.search_path.iter().cloned())
.chain(host_dirs)
{
if dir.is_dir() && !library_path.contains(&dir) {
library_path.push(dir);
}
}
let loader = trace_loader(&target, &library_path, target_class, target_machine)?;
let (loaded, opened) = discover::loaded_libraries(
&target,
&opts.trace_args,
opts.trace_seconds,
family,
&opts.directory,
&library_path,
loader.as_deref(),
)?;
traced_opened = opened;
traced_objects_only = family != Some(LibcFamily::Musl);
let (loaded, gpu_stack) = split_gpu_stack(loaded);
if !gpu_stack.is_empty() {
let names: Vec<&str> = gpu_stack
.iter()
.filter_map(|p| p.file_name().and_then(|n| n.to_str()))
.collect();
eprintln!(
" {} the host's GPU stack: {}. Pass --gl, --dri or --vulkan \
to bundle Mesa instead.",
color::bold("Left to"),
names.join(", ")
);
}
let mut traced = 0usize;
for path in loaded {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if needed_by.contains_key(name) {
continue;
}
needed_by.insert(name.to_string(), "trace run".into());
if let Some(dir) = path.parent()
&& !rpath_dirs.contains(&dir.to_path_buf())
{
rpath_dirs.push(dir.to_path_buf());
}
eprintln!(" {} {}", color::bold_green("Loaded"), name);
traced += 1;
}
if traced == 0 {
eprintln!(" nothing beyond what DT_NEEDED already names");
}
}
let trim = if opts.trim_inferred {
match &opts.sysroot {
Some(sr) if sr.trace.is_some() => {
Some(Trim::load(sr.trace.as_deref(), sr.keep.as_deref())?)
}
Some(_) => None,
None if opts.trace_run && !opts.dry_run => {
let tree = opts.directory.canonicalize().ok();
Some(Trim::from_paths(
traced_opened,
tree.as_deref(),
traced_objects_only,
))
}
None => None,
}
} else {
None
};
if opts.scan_dlopen {
let mut scanned: HashSet<String> = HashSet::new();
for path in &elf_files {
if let Ok(hits) = scan_dlopen(path, &opts.dlopen_extra) {
for soname in hits {
if scanned.insert(soname.clone()) {
let requirer = path
.strip_prefix(&opts.directory)
.unwrap_or(path)
.to_string_lossy()
.into_owned();
let label = format!("--scan-dlopen in {requirer}");
needed_by.entry(soname).or_insert(label);
}
}
}
}
if !scanned.is_empty() {
eprintln!(
" {} {} dlopen candidate(s): {}",
color::bold("Scanned:"),
scanned.len(),
scanned.iter().cloned().collect::<Vec<_>>().join(", ")
);
}
}
for lib in &opts.include {
if let Some(exclude) = excludes.iter().find(|pat| lib.starts_with(**pat)) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"explicitly included {lib} matches the exclusion {exclude}; drop the \
include or the exclusion, the build will not do both"
),
));
}
}
needed_by.retain(|soname, _| !is_excluded(soname, &excludes));
let (_, stubs) = find_existing_libs(&opts.directory);
if !opts.dry_run {
for stub in &stubs {
fs::remove_file(stub).map_err(|e| {
io::Error::new(
e.kind(),
format!("removing stub loader {}: {e}", stub.display()),
)
})?;
}
}
let reachable: HashSet<String> = fs::read_dir(opts.directory.join(&opts.lib_dir))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| e.file_name().to_str().map(String::from))
.filter(|name| name.contains(".so"))
.collect();
let mut discovery = Discovery::new(
onelf_format::catalog::builtins(),
opts.rules.local(),
opts.rules.disabled(),
)?;
let mut present: Vec<String> = reachable.iter().cloned().collect();
present.extend(set_sonames.iter().cloned());
present.sort();
present.dedup();
for soname in &present {
discovery.observe(soname)?;
}
if needed_by.is_empty() {
eprintln!("All dependencies satisfied, nothing to bundle.");
let activations = discovery.activations();
report_activations(&activations);
publish_activations(activations);
if !opts.dry_run {
let lib_dest = opts.directory.join(&opts.lib_dir);
let (rewritten, _scrubbed, unguaranteed, self_extract) =
finalize_tree(&opts.directory, &opts.lib_dir);
if rewritten > 0 {
eprintln!(
"{} RUNPATH to reach {} in {} binaries",
color::bold("Rewrote"),
opts.lib_dir.display(),
rewritten
);
}
report_unguaranteed_runpath(&unguaranteed, &self_extract);
match inject_bootstraps(&opts.directory, &lib_dest) {
Ok(n) if n > 0 => eprintln!(
"{} AT_EXECFN bootstrap into {} binaries",
color::bold("Injected"),
n
),
Ok(_) => {}
Err(e) => eprintln!(
"{} bootstrap injection failed: {e}",
color::bold_red("warning:"),
),
}
attest_staged_outputs(opts)?;
verify_needs(
opts,
sysroot_platform.as_ref(),
&set_sonames,
&classifier,
&HashSet::new(),
&[],
)?;
}
return Ok(());
}
let target_libc = primary
.as_deref()
.and_then(program_interp)
.as_deref()
.and_then(libc_family_from_interp)
.or_else(|| {
elf_files.iter().find_map(|f| {
program_interp(f)
.as_deref()
.and_then(libc_family_from_interp)
})
});
if let Some(chosen) = target_libc {
let others: Vec<&PathBuf> = elf_files
.iter()
.filter(|f| {
program_interp(f)
.as_deref()
.and_then(libc_family_from_interp)
.is_some_and(|fam| fam != chosen)
})
.collect();
if !others.is_empty() {
eprintln!(
" {} tree mixes libc families; bundling for {:?}{}",
color::bold("Note:"),
chosen,
primary
.as_deref()
.and_then(|p| p.strip_prefix(&opts.directory).ok())
.map(|p| format!(" (from {})", p.display()))
.unwrap_or_default()
);
for f in others.iter().take(5) {
eprintln!(
" other family: {}",
f.strip_prefix(&opts.directory).unwrap_or(f).display()
);
}
}
}
if let Some(target) = target_libc {
needed_by.retain(|soname, _| libc_family_of_soname(soname).is_none_or(|fam| fam == target));
}
let mut ldconfig_cache = if opts.sysroot.is_some() {
HashMap::new()
} else {
build_lib_cache()
};
let mut search_paths: Vec<PathBuf> = opts
.sysroot
.iter()
.flat_map(|sr| {
["usr/lib", "usr/lib64", "lib", "lib64"]
.into_iter()
.filter_map(move |d| onelf_sysroot::path::in_root(&sr.root, Path::new(d)).ok())
.filter(|d| d.is_dir())
.chain(onelf_sysroot::ldconf::search_dirs(&sr.root))
})
.collect();
search_paths.extend(opts.search_path.iter().cloned());
search_paths.extend(rpath_dirs);
let lib_dest = opts.directory.join(&opts.lib_dir);
let tree_root = fs::canonicalize(&opts.directory).unwrap_or_else(|_| opts.directory.clone());
let mut repair_extra: HashSet<PathBuf> = HashSet::new();
let mut not_found: Vec<(String, String)> = Vec::new();
let policy = opts
.sysroot
.as_ref()
.and_then(|sr| sr.policy.as_deref())
.map(Policy::load)
.transpose()?;
let pruned_by_policy = |src: &Path| -> bool {
let (Some(sr), Some(policy)) = (opts.sysroot.as_ref(), policy.as_ref()) else {
return false;
};
src.strip_prefix(&sr.root)
.ok()
.is_some_and(|rel| policy.matches(&rel.to_string_lossy()))
};
let pruned_from_source = |src: &Path| -> bool {
pruned_by_policy(src)
|| opts.sysroot.as_ref().is_some_and(|sr| {
let Some(universe) = sysroot_universe.as_ref() else {
return false;
};
src.strip_prefix(&sr.root).ok().is_some_and(|rel| {
let rel = rel.to_string_lossy();
!universe.admits(&rel)
&& matches!(universe.refusal(&rel), sysroot::Refusal::Pruned { .. })
})
})
};
let with_host_package = |src: &Path| -> bool {
opts.sysroot.as_ref().is_some_and(|sr| {
src.strip_prefix(&sr.root)
.ok()
.is_some_and(|rel| host_files.contains(rel.to_string_lossy().as_ref()))
})
};
let sysroot_root = opts.sysroot.as_ref().map(|sr| sr.root.as_path());
let real_path = |src: &Path| -> PathBuf {
match sysroot_root.and_then(|root| Some((root, src.strip_prefix(root).ok()?))) {
Some((root, rel)) => {
onelf_sysroot::path::in_root(root, rel).unwrap_or_else(|_| src.to_path_buf())
}
None => fs::canonicalize(src).unwrap_or_else(|_| src.to_path_buf()),
}
};
let mut left_out: Vec<(String, String)> = Vec::new();
let mut left_with_host: Vec<(String, String)> = Vec::new();
let mut already_processed: HashSet<String> = HashSet::new();
let mut expanded_nix: HashSet<PathBuf> = HashSet::new();
let mut admitted: Vec<AdmittedLibrary> = Vec::new();
let mut admitted_names: HashSet<String> = HashSet::new();
let mut group_dests: HashMap<String, PathBuf> = HashMap::new();
let mut group_sources: HashMap<String, PathBuf> = HashMap::new();
let mut helper_dests: HashSet<String> = HashSet::new();
let mut bundled_by_hash: HashMap<[u8; 32], PathBuf> = HashMap::new();
let mut actions: Vec<WalkAction> = Vec::new();
let selected = |sets: &[sysroot::SharedSet]| -> Vec<sysroot::SharedSet> {
sets.iter()
.filter(|set| selected_by_an_entrypoint(opts, &set.name))
.cloned()
.collect()
};
let tree_sets = selected(&opts.sets);
let sysroot_sets = selected(
opts.sysroot
.as_ref()
.map(|sr| sr.sets.as_slice())
.unwrap_or(&[]),
);
let mut preparation =
SetPreparation::new(&tree_sets, &sysroot_sets, &opts.lib_dir, opts.dry_run);
let mut required_dests: HashSet<PathBuf> = HashSet::new();
let mut seeds: Vec<String> = needed_by.keys().cloned().collect();
seeds.sort();
for soname in &seeds {
discovery.observe(soname)?;
}
discovery.seed(&seeds);
let mut known_groups: HashSet<String> = HashSet::new();
let mut group_queue: VecDeque<(String, String)> = VecDeque::new();
mirror_new_groups(&discovery, &mut known_groups, &mut group_queue);
let mut nss_allow: HashSet<String> = HashSet::new();
if Path::new("/nix/store").is_dir() && opts.sysroot.is_none() {
let (bundled, stubs) = find_existing_libs(&lib_dest);
if !opts.dry_run {
for stub in &stubs {
fs::remove_file(stub).map_err(|e| {
io::Error::new(
e.kind(),
format!("removing stub loader {}: {e}", stub.display()),
)
})?;
}
}
for lib_name in bundled {
if let Some(src) = locate_lib(
&lib_name,
&ldconfig_cache,
&search_paths,
target_class,
target_machine,
None,
LookupScope::Host,
) {
let resolved = fs::canonicalize(&src).unwrap_or(src);
expand_nix_cache(&resolved, &mut ldconfig_cache, &mut expanded_nix);
}
}
}
let mut inference = infer::Inference::default();
let mut inferred_read: HashSet<PathBuf> = HashSet::new();
let mut inferred_carried: Vec<(PathBuf, PathBuf, Carry)> = Vec::new();
let mut described_dirs: Vec<(String, String)> = Vec::new();
let mut member_scan: Vec<(String, PathBuf)> = Vec::new();
let described: Vec<&Descriptor> = onelf_format::catalog::builtins()
.iter()
.chain(
opts.rules
.local()
.iter()
.filter(|rule| !opts.rules.disabled().contains(&rule.id)),
)
.collect();
let tree_files: Vec<(String, PathBuf)> = elf_files
.iter()
.filter_map(|file| Some((file.file_name()?.to_str()?.to_string(), real_path(file))))
.collect();
loop {
let Some(item) = discovery.pop() else {
let tree_roots: [&Path; 2] = [&tree_root, &opts.directory];
let prefix_trees: Vec<&Path> = tree_roots
.iter()
.copied()
.filter(|root| root.is_absolute())
.collect();
let twin_dirs = host_dirs(&search_paths, &tree_roots);
let twin_of = |name: &str, file: &Path, admitted_rule_copy: bool| {
let library = name.ends_with(".so") || name.contains(".so.");
let in_tree = tree_roots.iter().any(|root| file.starts_with(root));
if opts.sysroot.is_some() || !in_tree || !library || admitted_rule_copy {
return None;
}
host_twin(
name,
&copied_from,
&tree_roots,
&twin_dirs,
&ldconfig_cache,
(target_class, target_machine, None),
LookupScope::WithoutStore,
)
};
let mut objects: Vec<infer::Object> = Vec::new();
for lib in &admitted {
if !inferred_read.insert(lib.path().to_path_buf()) {
continue;
}
let twin = match lib.provenance() {
Some(host) => Some(host.to_path_buf()),
None => twin_of(lib.soname(), lib.path(), discovery.triggers(lib.soname())),
};
objects.push(infer::Object {
name: lib.soname(),
file: lib.path(),
twin,
});
}
for (name, path) in &tree_files {
if inferred_read.insert(path.clone()) {
objects.push(infer::Object {
name,
file: path,
twin: twin_of(name, path, false),
});
}
}
let scanned = std::mem::take(&mut member_scan);
for (name, path) in &scanned {
if inferred_read.insert(path.clone()) {
objects.push(infer::Object {
name,
file: path,
twin: twin_of(name, path, false),
});
}
}
let fresh = inference.read(
&objects,
&infer::Scope {
sysroot: sysroot_universe.as_ref(),
trees: &prefix_trees,
exclude: &opts.exclude,
descriptors: &described,
lib_dir: &opts.lib_dir,
},
)?;
if fresh.is_empty() {
break;
}
for source in fresh {
let Some(dir) = inference.get(&source).cloned() else {
continue;
};
collect_inferred(
&dir,
&mut inferred_carried,
opts,
sysroot_universe.as_ref(),
trim.as_ref(),
(target_class, target_machine, target_libc),
&excludes,
&reachable,
&mut WalkState {
already_processed: &mut already_processed,
search_paths: &mut search_paths,
repair_extra: &mut repair_extra,
group_dests: &mut group_dests,
group_sources: &mut group_sources,
needed_by: &mut needed_by,
discovery: &mut discovery,
plan: &mut plan,
actions: &mut actions,
scan: &mut member_scan,
},
)?;
}
continue;
};
mirror_new_groups(&discovery, &mut known_groups, &mut group_queue);
let soname = match item {
WorkItem::Object(soname) => soname,
WorkItem::Group(_) => {
let Some((rule, group)) = group_queue.pop_front() else {
continue;
};
let Some(descriptor) = opts.rules.find(&rule) else {
continue;
};
if let Some(declared) = descriptor
.resources
.plugins
.iter()
.find(|g| g.id.as_str() == group)
{
let owners: Vec<AdmittedLibrary> = admitted
.iter()
.filter(|lib| {
descriptor
.triggers
.iter()
.any(|t| t.matches_soname(lib.soname()))
})
.map(AdmittedLibrary::without_provenance)
.collect();
let mut collection = collect::collect_group(
&rule,
declared,
&owners,
sysroot_universe.as_ref(),
target_class,
target_machine,
)?;
let origin = format!("rule {rule} group {group}");
exclude_recipe_members(&mut collection, &opts.exclude);
if described_group_names_record(&collection.plugins, &collection.counterpart) {
described_dirs.push((
declared.destination.as_str().to_owned(),
collection.counterpart.clone(),
));
}
report_collection(&collection, declared.destination.as_str());
let firsts = plan_collection(
&mut collection,
&origin,
&opts.directory,
&mut WalkState {
already_processed: &mut already_processed,
search_paths: &mut search_paths,
repair_extra: &mut repair_extra,
group_dests: &mut group_dests,
group_sources: &mut group_sources,
needed_by: &mut needed_by,
discovery: &mut discovery,
plan: &mut plan,
actions: &mut actions,
scan: &mut member_scan,
},
)?;
queue_collection(
&collection,
&origin,
&excludes,
&reachable,
&firsts,
&mut WalkState {
already_processed: &mut already_processed,
search_paths: &mut search_paths,
repair_extra: &mut repair_extra,
group_dests: &mut group_dests,
group_sources: &mut group_sources,
needed_by: &mut needed_by,
discovery: &mut discovery,
plan: &mut plan,
actions: &mut actions,
scan: &mut member_scan,
},
sysroot_universe.as_ref(),
)?;
if declared.requirement == Requirement::Required {
required_dests
.extend(collection.plugins.iter().map(|p| p.destination.clone()));
}
continue;
}
if let Some(declared) = descriptor
.resources
.data
.iter()
.find(|g| g.id.as_str() == group)
{
carry_data_group(
&opts.directory,
descriptor,
declared,
&admitted,
sysroot_universe.as_ref(),
&mut plan,
&mut actions,
&mut preparation,
&mut required_dests,
)?;
}
if let Some(declared) = descriptor
.resources
.helpers
.iter()
.find(|h| h.id.as_str() == group)
{
collect_helper_group(
&opts.directory,
descriptor,
declared,
&admitted,
sysroot_universe.as_ref(),
&mut search_paths,
&mut repair_extra,
&mut group_dests,
&mut helper_dests,
&mut needed_by,
&mut already_processed,
&mut discovery,
&mut preparation,
&mut plan,
&mut required_dests,
)?;
}
continue;
}
};
if already_processed.contains(&soname)
|| (is_excluded(&soname, &excludes) && !nss_allow.contains(&soname))
{
continue;
}
already_processed.insert(soname.clone());
let already_flat = if helper_dests.contains(&soname) {
group_dests
.get(&soname)
.is_some_and(|dest| opts.directory.join(dest).exists())
} else {
lib_dest.join(&soname).exists()
};
let requirer = needed_by
.get(&soname)
.cloned()
.unwrap_or_else(|| "?".into());
let located = if let Some(found) = group_sources.get(&soname) {
Some(found.clone())
} else if already_flat {
let sources: Vec<PathBuf> = search_paths
.iter()
.filter(|dir| {
repair_extra.contains(*dir)
|| repair_admits(
dir,
&tree_root,
&opts.directory,
&opts.search_path,
sysroot_root,
)
})
.cloned()
.collect();
locate_lib(
&soname,
&ldconfig_cache,
&sources,
target_class,
target_machine,
sysroot_root,
LookupScope::Declared,
)
} else {
locate_lib(
&soname,
&ldconfig_cache,
&search_paths,
target_class,
target_machine,
sysroot_root,
LookupScope::Host,
)
};
match located.map(|src| {
let resolved = real_path(&src);
(src, resolved)
}) {
Some((src, resolved)) if pruned_from_source(&src) || pruned_from_source(&resolved) => {
if already_flat {
continue;
}
left_out.push((soname, requirer));
}
Some((src, resolved)) if with_host_package(&src) || with_host_package(&resolved) => {
if already_flat {
continue;
}
left_with_host.push((soname, requirer));
}
Some((src, resolved)) => {
let flat = lib_dest.join(&soname);
let resolved = if already_flat && is_same_file(&real_path(&src), &flat) {
let outside: Vec<PathBuf> = search_paths
.iter()
.filter(|dir| {
(repair_extra.contains(*dir)
|| repair_admits(
dir,
&tree_root,
&opts.directory,
&opts.search_path,
sysroot_root,
))
&& !dir.starts_with(&tree_root)
&& !dir.starts_with(&opts.directory)
})
.cloned()
.collect();
match locate_lib(
&soname,
&ldconfig_cache,
&outside,
target_class,
target_machine,
sysroot_root,
LookupScope::Declared,
) {
Some(host) if !is_same_file(&host, &flat) => real_path(&host),
_ => resolved,
}
} else {
resolved
};
if admitted_names.insert(soname.clone()) {
let host_copy = opts.sysroot.is_none()
&& (resolved.starts_with(&tree_root)
|| resolved.starts_with(&opts.directory))
&& discovery.triggers(&soname);
admitted.push(if host_copy {
admit_tree_copy(
&soname,
&resolved,
&copied_from,
&[&tree_root, &opts.directory],
&search_paths,
&ldconfig_cache,
(target_class, target_machine, target_libc),
)
} else {
AdmittedLibrary::new(soname.clone(), resolved.clone())
});
}
let size = fs::metadata(&resolved).map(|m| m.len()).unwrap_or(0);
let rel = match group_dests.get(&soname) {
Some(dest) => dest.clone(),
None => opts.lib_dir.join(&soname),
};
let dest = opts.directory.join(&rel);
let stale_flat = !helper_dests.contains(&soname)
&& already_flat
&& rel != opts.lib_dir.join(&soname);
if dest.exists() || is_same_file(&resolved, &dest) {
if stale_flat {
actions.push(WalkAction::MigrateFlat {
flat: lib_dest.join(&soname),
new_rel: rel.clone(),
});
}
continue;
}
let lib_needed = parse_needed(&resolved).unwrap_or_default();
let lib_family = lib_needed.iter().find_map(|d| libc_family_of_soname(d));
let mismatch = matches!(
(target_libc, lib_family),
(Some(t), Some(f)) if t != f
);
if mismatch {
let msg = format!(
"{} links against {:?} libc but target is {:?}",
soname,
lib_family.unwrap(),
target_libc.unwrap()
);
if opts.strict_libc {
eprintln!(
" {} skipping {} ({})",
color::bold_red("skip:"),
color::cyan(&soname),
msg,
);
not_found.push((soname.clone(), format!("{requirer} ({msg})")));
continue;
}
eprintln!(
" {} {}; this bundle may not work at runtime",
color::bold_red("warning:"),
msg,
);
}
expand_nix_cache(&resolved, &mut ldconfig_cache, &mut expanded_nix);
let bytes = fs::read(&resolved).ok();
let content_hash: Option<[u8; 32]> =
bytes.as_deref().map(|b| blake3::hash(b).into());
if let Some(b) = bytes.as_deref() {
plan.note_source(&resolved, b)?;
}
if soname == "libc.so.6" {
let old = bytes
.as_deref()
.is_some_and(|b| !b.windows(10).any(|w| w == b"GLIBC_2.34"));
let modules: &[&str] = if old {
&["libnss_compat.so.2", "libnss_files.so.2", "libnss_dns.so.2"]
} else {
&["libnss_compat.so.2"]
};
for module in modules {
nss_allow.insert(module.to_string());
needed_by
.entry(module.to_string())
.or_insert_with(|| "libc.so.6 (nsswitch)".into());
discovery.enqueue(module)?;
}
for compat in GLIBC_COMPAT_LIBS {
needed_by
.entry(compat.to_string())
.or_insert_with(|| "libc.so.6 (compatibility)".into());
discovery.enqueue(compat)?;
}
}
if let Some(hash) = content_hash
&& let Some(existing) = bundled_by_hash.get(&hash).cloned()
{
let target = link_target(rel.parent().unwrap_or(Path::new("")), &existing);
if plan.admit_link(
&rel,
&target,
&format!("{soname} (needed by {requirer})"),
Existing::Overwrite,
)? == Admission::Write
{
actions.push(WalkAction::LinkAlias {
soname: soname.clone(),
resolved: resolved.clone(),
rel: rel.clone(),
existing: existing.clone(),
requirer: requirer.clone(),
size,
stale_flat,
});
}
if stale_flat {
actions.push(WalkAction::MigrateFlat {
flat: lib_dest.join(&soname),
new_rel: rel.clone(),
});
}
continue;
}
let write = match bytes.as_deref() {
Some(b) => {
plan.admit_file(
&rel,
plan::file_hash(b),
&format!("{soname} (needed by {requirer})"),
Existing::SkipOnAny,
)? == Admission::Write
}
None => true,
};
if write {
actions.push(WalkAction::CopyLib {
soname: soname.clone(),
resolved: resolved.clone(),
rel: rel.clone(),
size,
requirer: requirer.clone(),
stale_flat,
libc_data: soname == "libc.so.6",
});
}
if let Some(hash) = content_hash {
bundled_by_hash.insert(hash, rel.clone());
}
let sysroot_root = opts.sysroot.as_ref().map(|sr| sr.root.as_path());
for dir in parse_rpaths_under(&resolved, sysroot_root) {
if !search_paths.contains(&dir) {
search_paths.push(dir);
}
}
if opts.recursive {
for dep in lib_needed {
if already_processed.contains(&dep)
|| is_excluded(&dep, &excludes)
|| reachable.contains(&dep)
{
continue;
}
if libc_family_of_soname(&dep).is_some() {
continue;
}
needed_by
.entry(dep.clone())
.or_insert_with(|| soname.clone());
discovery.enqueue(&dep)?;
}
}
}
None => {
if already_flat {
if opts.sysroot.is_none()
&& discovery.triggers(&soname)
&& admitted_names.insert(soname.clone())
{
let copy = match group_dests.get(&soname) {
Some(dest) => opts.directory.join(dest),
None => lib_dest.join(&soname),
};
admitted.push(admit_tree_copy(
&soname,
&real_path(©),
&copied_from,
&[&tree_root, &opts.directory],
&search_paths,
&ldconfig_cache,
(target_class, target_machine, target_libc),
));
}
continue;
}
not_found.push((soname, requirer));
}
}
}
for lib in &opts.include {
let listed = |list: &[(String, String)]| list.iter().any(|(s, _)| s == lib);
let verdict = classifier.classify(lib);
let outcome = if listed(&left_out) {
Some(match verdict {
SourceVerdict::Pruned { owner } => {
format!("{owner} holds it, but the policy or a trace removed it")
}
_ => "the policy or a trace removes it from the selected source".to_string(),
})
} else if listed(&left_with_host) {
Some(match verdict {
SourceVerdict::HostProvider { owner } => {
format!("left to the host package {owner}")
}
_ => "the selected source hands it to a host package whole".to_string(),
})
} else if (opts.sysroot.is_some() || opts.strict)
&& listed(¬_found)
&& !matches!(verdict, SourceVerdict::Carried)
{
Some(verdict.reason())
} else {
None
};
if let Some(reason) = outcome {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"explicitly included {lib} does not enter the bundle: {reason}; an \
explicit include cannot be dropped, and the build neither selects a \
package nor restores a file to carry it"
),
));
}
}
let activations = discovery.activations();
report_activations(&activations);
publish_activations(activations);
publish_prepared(preparation.queue_actions(&mut actions));
let mut interp_links: Vec<(String, String)> = Vec::new();
{
let mut interp_names: Vec<String> = elf_files
.iter()
.filter_map(|p| {
parse_interp(p).and_then(|i| {
Path::new(&i)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
})
})
.collect();
interp_names.sort();
interp_names.dedup();
for interp_name in interp_names {
let target = lib_dest.join(&interp_name);
if target.exists()
|| target.is_symlink()
|| plan.is_planned(&opts.lib_dir.join(&interp_name))
{
continue;
}
let Some(libc_name) = libc_alias_for(&interp_name) else {
continue;
};
let app_rel = opts.lib_dir.join(&interp_name);
let origin = format!("{interp_name} (loader alias of {libc_name})");
if plan.admit_link(&app_rel, &libc_name, &origin, Existing::SkipOnAny)?
== Admission::Write
{
interp_links.push((interp_name, libc_name));
}
}
}
if plan.has_findings() {
return Err(plan.error());
}
let mut outcome = WalkOutcome::default();
let executed = execute_walk(
&opts.directory,
opts.dry_run,
opts.strip,
&lib_dest,
&actions,
&plan,
&mut outcome,
);
if !opts.dry_run {
sysroot::record_generated(&opts.directory, std::mem::take(&mut outcome.written))?;
}
executed?;
let mut copied = outcome.copied;
copied.sort_by(|a, b| a.0.cmp(&b.0));
not_found.sort();
left_out.sort();
left_with_host.sort();
let total_size: u64 = copied.iter().map(|(_, _, s, _)| s).sum();
if opts.dry_run {
eprintln!(
"\n{} would copy {} libraries ({})",
color::bold("Dry run:"),
color::bold_green(&copied.len().to_string()),
color::bold(&format_size(total_size))
);
} else if !copied.is_empty() {
eprintln!(
"\n{} {} libraries ({}) to {}",
color::bold_green("Copied"),
copied.len(),
color::bold(&format_size(total_size)),
lib_dest.display()
);
}
for (lib, requirer) in &left_with_host {
completeness::record(Finding::new(
lib,
Outcome::Selected,
format!(
"{} (needed by {requirer})",
classifier.classify(lib).reason()
),
));
}
for (lib, requirer) in &left_out {
completeness::record(Finding::new(
lib,
Outcome::Removed,
format!(
"{} (needed by {requirer})",
classifier.classify(lib).reason()
),
));
}
for (lib, requirer) in ¬_found {
let reason = if opts.sysroot.is_some() {
format!(
"{} (needed by {requirer})",
classifier.classify(lib).reason()
)
} else {
format!("the host scan did not find it (needed by {requirer})")
};
completeness::record(Finding::new(lib, Outcome::Unresolved, reason));
}
if !opts.dry_run {
let mut linked: Vec<String> = Vec::new();
for (interp_name, libc_name) in &interp_links {
let target = lib_dest.join(interp_name);
if target.exists() || target.is_symlink() {
continue;
}
let libc_path = lib_dest.join(libc_name);
if !libc_path.exists() {
continue;
}
if let Err(e) = std::os::unix::fs::symlink(libc_name, &target) {
eprintln!(
" {} failed to create {} -> {}: {e}",
color::bold_red("warning:"),
interp_name,
libc_name
);
} else {
eprintln!(
" {} {} -> {}",
color::bold_green("Linked"),
interp_name,
libc_name
);
linked.push(
opts.lib_dir
.join(interp_name)
.to_string_lossy()
.into_owned(),
);
}
}
sysroot::record_generated(&opts.directory, linked)?;
}
if !opts.dry_run {
let (rewritten, scrubbed, unguaranteed, self_extract) =
finalize_tree(&opts.directory, &opts.lib_dir);
if rewritten > 0 {
eprintln!(
"{} RUNPATH to reach {} in {} binaries",
color::bold("Rewrote"),
opts.lib_dir.display(),
rewritten
);
}
if scrubbed > 0 {
eprintln!(
"{} /nix/store paths in {} binaries",
color::bold("Scrubbed"),
scrubbed
);
}
report_unguaranteed_runpath(&unguaranteed, &self_extract);
}
let carried_dirs: Vec<&infer::InferredDir> = inferred_carried
.iter()
.filter(|(.., carry)| matches!(*carry, Carry::Planned | Carry::Held))
.filter_map(|(_, source, _)| inference.get(source))
.filter(|dir| held_in_tree(&opts.directory, &dir.destination))
.collect();
let plugin_candidates: Vec<&infer::InferredDir> = carried_dirs
.iter()
.copied()
.chain(
inference
.all()
.filter(|dir| rides_carried(dir, &inferred_carried)),
)
.collect();
let (redirections, carried_dirs) = if opts.dry_run {
(Vec::new(), Vec::new())
} else {
let tree_elfs = find_elf_files(&opts.directory);
let tails = redir::anchor_tails(&tree_elfs)?;
let reaches =
|tail: &String, key: &str| tail == key || onelf_format::anchors::key_below(tail, key);
let persisted: Vec<onelf_format::anchors::Anchor> = redir::persisted(&opts.directory)?
.into_iter()
.filter(|anchor| {
carried_dirs
.iter()
.any(|dir| Path::new(&anchor.relative).starts_with(&dir.destination))
|| tails.iter().any(|tail| reaches(tail, &anchor.key))
})
.collect();
let lib_dirs = crate::pack::resolve_lib_dirs(&opts.lib_dirs, &opts.directory);
let enabled_rules = opts.rules.enabled();
let unexplained: Vec<String> = tails
.iter()
.filter(|tail| {
!persisted.iter().any(|anchor| {
opts.directory.join(&anchor.relative).is_dir() && reaches(tail, &anchor.key)
})
})
.cloned()
.collect();
let redirections = redir::choose(
&carried_dirs,
&enabled_rules,
&lib_dirs,
&opts.directory,
&persisted,
&opts.recipe_pairs,
);
report_stale_anchor_paths(&unexplained, &redirections);
let survived: Vec<redir::Redirection> = redirections
.iter()
.filter(|r| r.resolves(&opts.directory))
.cloned()
.collect();
redir::write_record(&survived, &opts.directory)?;
let plugin_dirs = merge_plugin_dirs(
plugin_dir_records(
&opts.directory,
&plugin_candidates,
&described_dirs,
&opts.lib_dir,
),
persisted_plugin_dirs(&opts.directory)?,
&opts.directory,
);
write_plugin_dirs_record(&opts.directory, &plugin_dirs)?;
let answers: Vec<(&str, &Path)> = carried_dirs
.iter()
.flat_map(|dir| {
dir.compiled
.iter()
.map(|spelling| (spelling.as_str(), dir.destination.as_path()))
})
.chain(
opts.recipe_pairs
.iter()
.map(|pair| (pair.absolute.as_str(), Path::new(&pair.relative))),
)
.collect();
let kept = redir::kept_spellings(&survived, &answers);
let (files, occurrences) = redir::rewrite_anchors(&survived, &kept, &tree_elfs)?;
for pair in &opts.recipe_pairs {
let stale = redir::recorded_reach(&persisted, &pair.absolute)
.is_some_and(|reached| reached != Path::new(&pair.relative));
if stale && redir::rewritten_in(&tree_elfs, &pair.absolute)? {
record_finding(Finding::new(
pair.relative.clone(),
Outcome::Unchecked,
format!(
"the recipe pair for {} has no effect in this tree, whose earlier \
build anchored that path; build into a fresh tree for it to apply",
pair.absolute
),
));
}
}
if occurrences > 0 {
eprintln!(
"{} {} compiled-in paths to anchor paths in {} binaries",
color::bold("Rewrote"),
occurrences,
files
);
}
(redirections, carried_dirs)
};
if !opts.dry_run {
match inject_bootstraps(&opts.directory, &lib_dest) {
Ok(n) if n > 0 => eprintln!(
"{} AT_EXECFN bootstrap into {} binaries",
color::bold("Injected"),
n
),
Ok(_) => {}
Err(e) => eprintln!(
"{} bootstrap injection failed: {e}",
color::bold_red("warning:"),
),
}
attest_staged_outputs(opts)?;
datadirs::record(&datadirs::unanswered(
&opts.directory,
&find_elf_files(&opts.directory),
));
let inferred_dests = inferred_carried
.iter()
.filter(|(_, _, carry)| *carry == Carry::Planned)
.map(|(dest, _, _)| dest.clone())
.collect::<Vec<_>>();
verify_needs(
opts,
sysroot_platform.as_ref(),
&set_sonames,
&classifier,
&required_dests,
&inferred_dests,
)?;
if let Some((providers, unchecked)) = set_audit.as_ref() {
audit_entrypoint_selections(opts, sysroot_platform.as_ref(), providers, unchecked)?;
}
let survived: Vec<redir::Redirection> = redirections
.into_iter()
.filter(|r| r.resolves(&opts.directory))
.collect();
redir::write_record(&survived, &opts.directory)?;
let plugin_dirs = merge_plugin_dirs(
plugin_dir_records(
&opts.directory,
&plugin_candidates,
&described_dirs,
&opts.lib_dir,
),
persisted_plugin_dirs(&opts.directory)?,
&opts.directory,
);
write_plugin_dirs_record(&opts.directory, &plugin_dirs)?;
let pairs = redir::record_pairs(&survived, carried_dirs, &opts.recipe_pairs);
write_inferred_paths(&opts.directory, &pairs)?;
publish_pairs(pairs);
redir::report(&survived);
for (dest, how) in redir::descriptions(&survived) {
completeness::settle(Some(&dest), infer::PENDING_REDIRECTION, &how);
}
completeness::settle(
None,
infer::PENDING_REDIRECTION,
"not redirected, since nothing of it survived the build",
);
} else {
completeness::settle(
None,
infer::PENDING_REDIRECTION,
"not redirected, since a dry run decides no redirection",
);
}
Ok(())
}
fn report_stale_anchor_paths(unexplained: &[String], redirections: &[redir::Redirection]) {
use onelf_format::anchors::{ANCHOR_PREFIX, key_below};
for tail in unexplained {
if unexplained.iter().any(|other| key_below(tail, other)) {
continue;
}
let reached = redirections
.iter()
.filter(|r| r.mechanism == redir::Mechanism::Anchor)
.find_map(|r| {
r.keys.iter().find_map(|key| {
if tail == key {
Some(r.dest.clone())
} else if key_below(tail, key) {
Some(r.dest.join(&tail[key.len() + 1..]))
} else {
None
}
})
});
let what = match &reached {
Some(dest) => format!("which now resolves to {}", dest.display()),
None => "which no anchor serves now".to_string(),
};
record_finding(Finding::new(
format!("{ANCHOR_PREFIX}{tail}"),
Outcome::Unchecked,
format!(
"objects in the tree carry this path from an earlier build, {what}; build \
into a fresh tree for them to name only what this build chose"
),
));
}
}
struct Unloadable {
names: HashSet<String>,
members: Vec<(PathBuf, Vec<String>)>,
}
fn unloadable_closure(
opts: &BundleOptions,
failing: &[(PathBuf, Vec<String>)],
inferred: &[PathBuf],
) -> Unloadable {
let names_of = |object: &Path| -> Vec<String> {
object
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.into_iter()
.chain(crate::bundle::elf::parse_soname(object))
.collect()
};
let mut names: HashSet<String> = failing
.iter()
.flat_map(|(object, _)| names_of(object))
.collect();
let failing_objects: HashSet<&Path> = failing.iter().map(|(o, _)| o.as_path()).collect();
let mut members: Vec<(PathBuf, Vec<String>)> = Vec::new();
let candidates: Vec<(PathBuf, Vec<String>)> = inferred
.iter()
.flat_map(|dest| find_elf_files(&opts.directory.join(dest)))
.filter(|object| !failing_objects.contains(object.as_path()))
.map(|object| {
let needed = parse_needed(&object).unwrap_or_default();
(object, needed)
})
.collect();
loop {
let mut grew = false;
for (object, needed) in &candidates {
if members.iter().any(|(m, _)| m == object) {
continue;
}
let reached: Vec<String> = needed
.iter()
.filter(|n| names.contains(n.as_str()))
.cloned()
.collect();
if reached.is_empty() {
continue;
}
names.extend(names_of(object));
members.push((object.clone(), reached));
grew = true;
}
if !grew {
break;
}
}
Unloadable { names, members }
}
struct Dropped {
object: PathBuf,
names: Vec<String>,
need: String,
verdict: SourceVerdict,
}
fn described_group_names_record(plugins: &[collect::CollectedPlugin], counterpart: &str) -> bool {
!plugins.is_empty() || !counterpart.is_empty()
}
fn rides_carried(dir: &infer::InferredDir, carried: &[(PathBuf, PathBuf, Carry)]) -> bool {
carried.iter().any(|(dest, source, carry)| {
matches!(*carry, Carry::Planned | Carry::Held)
&& match dir.destination.strip_prefix(dest) {
Ok(rel) if !rel.as_os_str().is_empty() => dir.source == source.join(rel),
_ => false,
}
})
}
fn plugin_dir_records(
directory: &Path,
inferred: &[&infer::InferredDir],
described: &[(String, String)],
lib_dir: &Path,
) -> Vec<PluginDir> {
let mut out: Vec<PluginDir> = inferred
.iter()
.filter(|dir| dir.kind == infer::InferredKind::Plugins)
.filter(|dir| directory.join(&dir.destination).is_dir())
.map(|dir| PluginDir {
relative: dir.destination.to_string_lossy().into_owned(),
lib_relative: dir
.destination
.strip_prefix(lib_dir)
.map(|below| below.to_string_lossy().into_owned())
.unwrap_or_default(),
absolutes: dir.compiled.iter().cloned().collect(),
})
.chain(
described
.iter()
.filter(|(dest, _)| directory.join(dest).is_dir())
.map(|(dest, spelling)| PluginDir {
relative: dest.clone(),
lib_relative: spelling.clone(),
absolutes: Vec::new(),
}),
)
.collect();
let mut seen = HashSet::new();
out.retain(|dir| seen.insert(dir.relative.clone()));
out.sort_by(|a, b| a.relative.cmp(&b.relative));
out
}
fn persisted_plugin_dirs(directory: &Path) -> io::Result<Vec<PluginDir>> {
let path = directory.join(plugindirs::PLUGIN_DIRS_FILE);
let file = match fs::File::open(&path) {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut bytes = Vec::new();
(&file)
.take(plugindirs::MAX_RECORD_BYTES as u64 + 1)
.read_to_end(&mut bytes)?;
plugindirs::decode(&bytes).map_err(io::Error::other)
}
fn merge_plugin_dirs(
fresh: Vec<PluginDir>,
persisted: Vec<PluginDir>,
directory: &Path,
) -> Vec<PluginDir> {
if persisted.is_empty() {
return fresh;
}
let named: HashSet<String> = fresh
.iter()
.flat_map(|dir| dir.absolutes.iter().cloned())
.collect();
let mut out = fresh;
for old in persisted {
match out.iter().position(|dir| dir.relative == old.relative) {
Some(at) => out[at]
.absolutes
.extend(old.absolutes.into_iter().filter(|a| !named.contains(a))),
None => {
if old.absolutes.iter().any(|a| named.contains(a))
|| !directory.join(Path::new(&old.relative)).is_dir()
{
continue;
}
out.push(old);
}
}
}
out.sort_by(|a, b| a.relative.cmp(&b.relative));
out
}
fn write_plugin_dirs_record(directory: &Path, dirs: &[PluginDir]) -> io::Result<()> {
if dirs.is_empty() {
let stale = directory.join(plugindirs::PLUGIN_DIRS_FILE);
match fs::remove_file(&stale) {
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
Ok(()) => {}
}
sysroot::remove_generated(directory, &[plugindirs::PLUGIN_DIRS_FILE.to_string()])?;
return Ok(());
}
let bytes = plugindirs::encode(dirs).map_err(io::Error::other)?;
let record = directory.join(plugindirs::PLUGIN_DIRS_FILE);
if let Some(parent) = record.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&record, bytes)?;
normalize_mtime(&record);
sysroot::record_generated(directory, vec![plugindirs::PLUGIN_DIRS_FILE.to_string()])?;
Ok(())
}
#[cfg(test)]
mod plugin_dirs_record_tests {
use super::*;
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("onelf-plugdirs-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
fn inferred(
destination: &str,
kind: infer::InferredKind,
compiled: &[&str],
) -> infer::InferredDir {
infer::InferredDir {
source: PathBuf::from("/usr").join(destination),
prefix: PathBuf::from("/usr"),
rel: PathBuf::from(destination),
destination: PathBuf::from(destination),
kind,
size: 0,
compiled: compiled.iter().map(|s| s.to_string()).collect(),
objects: ["libdemo.so.1".to_string()].into_iter().collect(),
}
}
#[test]
fn the_record_names_what_the_tree_holds_sorted() {
let dir = tmp("records");
fs::create_dir_all(dir.join("lib/demo")).unwrap();
fs::create_dir_all(dir.join("share/examplefw")).unwrap();
fs::create_dir_all(dir.join("share/data")).unwrap();
let plugin = inferred("lib/demo", infer::InferredKind::Plugins, &["/usr/lib/demo"]);
let data = inferred(
"share/data",
infer::InferredKind::Data,
&["/usr/share/data"],
);
let gone = inferred("lib/gone", infer::InferredKind::Plugins, &["/usr/lib/gone"]);
let records = plugin_dir_records(
&dir,
&[&plugin, &data, &gone],
&[
("lib/gone2".to_string(), "gone2".to_string()),
("share/examplefw".to_string(), String::new()),
],
Path::new("lib"),
);
let relatives: Vec<&str> = records.iter().map(|d| d.relative.as_str()).collect();
assert_eq!(relatives, ["lib/demo", "share/examplefw"]);
assert_eq!(records[0].lib_relative, "demo");
assert_eq!(records[0].absolutes, ["/usr/lib/demo".to_string()]);
assert_eq!(records[1].lib_relative, "");
assert!(records[1].absolutes.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn the_record_is_written_and_dropped_with_its_ledger_entry() {
let dir = tmp("write");
fs::create_dir_all(dir.join("lib/demo")).unwrap();
let plugin = inferred("lib/demo", infer::InferredKind::Plugins, &["/usr/lib/demo"]);
write_plugin_dirs_record(
&dir,
&plugin_dir_records(&dir, &[&plugin], &[], Path::new("lib")),
)
.unwrap();
let record = dir.join(plugindirs::PLUGIN_DIRS_FILE);
assert_eq!(
fs::read(&record).unwrap(),
plugindirs::encode(&[PluginDir {
relative: "lib/demo".to_string(),
lib_relative: "demo".to_string(),
absolutes: ["/usr/lib/demo".to_string()].to_vec(),
}])
.unwrap()
);
assert!(
sysroot::read_generated(&dir)
.unwrap()
.iter()
.any(|entry| entry.path == plugindirs::PLUGIN_DIRS_FILE),
"the record joins the generated ledger"
);
write_plugin_dirs_record(&dir, &[]).unwrap();
assert!(!record.exists());
assert!(
!sysroot::read_generated(&dir)
.unwrap()
.iter()
.any(|entry| entry.path == plugindirs::PLUGIN_DIRS_FILE),
"the ledger entry drops with the record"
);
write_plugin_dirs_record(&dir, &[]).unwrap();
assert!(!record.exists(), "dropping without a record stays quiet");
sysroot::record_generated(&dir, vec![plugindirs::PLUGIN_DIRS_FILE.to_string()]).unwrap();
write_plugin_dirs_record(&dir, &[]).unwrap();
assert!(
!sysroot::read_generated(&dir)
.unwrap()
.iter()
.any(|entry| entry.path == plugindirs::PLUGIN_DIRS_FILE),
"a ledger entry attesting a missing record drops too"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_rebuild_keeps_the_held_directories_the_tree_holds() {
let dir = tmp("merge");
fs::create_dir_all(dir.join("lib/demo")).unwrap();
fs::create_dir_all(dir.join("lib/held")).unwrap();
fs::create_dir_all(dir.join(".onelf")).unwrap();
std::fs::write(
dir.join(plugindirs::PLUGIN_DIRS_FILE),
plugindirs::encode(&[
PluginDir {
relative: "lib/demo".to_string(),
lib_relative: "demo".to_string(),
absolutes: ["/usr/lib/demo".to_string()].to_vec(),
},
PluginDir {
relative: "lib/gone".to_string(),
lib_relative: "gone".to_string(),
absolutes: ["/usr/lib/gone".to_string()].to_vec(),
},
PluginDir {
relative: "lib/held".to_string(),
lib_relative: "held".to_string(),
absolutes: ["/usr/lib/held".to_string()].to_vec(),
},
])
.unwrap(),
)
.unwrap();
let merged = merge_plugin_dirs(
vec![PluginDir {
relative: "lib/demo".to_string(),
lib_relative: String::new(),
absolutes: Vec::new(),
}],
persisted_plugin_dirs(&dir).unwrap(),
&dir,
);
let relatives: Vec<&str> = merged.iter().map(|d| d.relative.as_str()).collect();
assert_eq!(relatives, ["lib/demo", "lib/held"]);
assert_eq!(merged[0].lib_relative, "", "the fresh naming wins");
assert_eq!(
merged[0].absolutes,
["/usr/lib/demo".to_string()],
"the recorded alias spelling rides along"
);
assert_eq!(
merged[1].absolutes,
["/usr/lib/held".to_string()],
"the held directory keeps its recorded naming"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn one_destination_is_named_once_and_owns_its_spellings() {
let dir = tmp("dedupe");
fs::create_dir_all(dir.join("lib/demo")).unwrap();
fs::create_dir_all(dir.join("lib/shared")).unwrap();
let plugin = inferred("lib/demo", infer::InferredKind::Plugins, &["/usr/lib/demo"]);
let records = plugin_dir_records(
&dir,
&[&plugin],
&[
("lib/shared".to_string(), "shared".to_string()),
("lib/shared".to_string(), String::new()),
],
Path::new("lib"),
);
let relatives: Vec<&str> = records.iter().map(|d| d.relative.as_str()).collect();
assert_eq!(relatives, ["lib/demo", "lib/shared"]);
assert_eq!(records[1].lib_relative, "shared", "the first group wins");
let merged = merge_plugin_dirs(
records,
vec![PluginDir {
relative: "lib/shared".to_string(),
lib_relative: "elsewhere".to_string(),
absolutes: ["/usr/lib/demo".to_string()].to_vec(),
}],
&dir,
);
let relatives: Vec<&str> = merged.iter().map(|d| d.relative.as_str()).collect();
assert_eq!(relatives, ["lib/demo", "lib/shared"]);
assert_eq!(
merged[1].lib_relative, "shared",
"the fresh naming owns the spelling"
);
assert_eq!(
merged
.iter()
.find(|d| d.relative == "lib/demo")
.unwrap()
.absolutes,
["/usr/lib/demo".to_string()],
);
assert!(
merged
.iter()
.find(|d| d.relative == "lib/shared")
.unwrap()
.absolutes
.is_empty(),
"the claimed spelling is not named twice"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_rediscovered_directory_keeps_its_unclaimed_aliases() {
let dir = tmp("aliases");
fs::create_dir_all(dir.join("lib/demo")).unwrap();
let merged = merge_plugin_dirs(
vec![PluginDir {
relative: "lib/demo".to_string(),
lib_relative: "demo".to_string(),
absolutes: ["/usr/lib/demo-direct".to_string()].to_vec(),
}],
vec![PluginDir {
relative: "lib/demo".to_string(),
lib_relative: "demo".to_string(),
absolutes: [
"/usr/lib/demo-direct".to_string(),
"/usr/lib/demo-link".to_string(),
]
.to_vec(),
}],
&dir,
);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].lib_relative, "demo");
assert_eq!(
merged[0].absolutes,
[
"/usr/lib/demo-direct".to_string(),
"/usr/lib/demo-link".to_string(),
]
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_damaged_record_stops_the_read() {
let dir = tmp("damaged");
fs::create_dir_all(dir.join(".onelf")).unwrap();
std::fs::write(dir.join(plugindirs::PLUGIN_DIRS_FILE), b"not a record").unwrap();
assert!(persisted_plugin_dirs(&dir).is_err());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_found_nothing_group_names_the_record_by_a_member_or_a_spelling() {
assert!(!described_group_names_record(&[], ""));
assert!(described_group_names_record(
&[collect::CollectedPlugin {
soname: "libfw.so".to_string(),
destination: PathBuf::from("lib/fw"),
resolved: PathBuf::from("/usr/lib/fw/libfw.so"),
}],
""
));
assert!(described_group_names_record(&[], "fw"));
}
}
fn drop_unloadable(
opts: &BundleOptions,
findings: &mut Vec<(PathBuf, Vec<String>)>,
audit: impl Fn() -> Vec<(PathBuf, Vec<String>)>,
classifier: &SourceClassifier,
required: &HashSet<PathBuf>,
droppable: &[PathBuf],
unloadable: &HashSet<String>,
) -> io::Result<Vec<Dropped>> {
let generated: HashSet<PathBuf> = sysroot::read_generated(&opts.directory)?
.into_iter()
.map(|entry| PathBuf::from(entry.path))
.collect();
let mut kept: HashSet<PathBuf> = explicit_include_paths(opts);
kept.extend(
opts.primary
.as_deref()
.or(opts.target.as_deref())
.map(|t| t.strip_prefix(&opts.directory).unwrap_or(t).to_path_buf()),
);
kept.extend(
opts.entrypoint_selections
.iter()
.filter_map(|e| e.path.clone()),
);
let mut gone: HashSet<String> = unloadable.clone();
let mut dropped: Vec<Dropped> = Vec::new();
loop {
let mut progressed = false;
for (object, libs) in findings.iter() {
let rel = object.strip_prefix(&opts.directory).unwrap_or(object);
if kept.contains(rel)
|| !generated.contains(rel)
|| required.iter().any(|dest| rel.starts_with(dest))
|| (!droppable.is_empty() && !droppable.iter().any(|dest| rel.starts_with(dest)))
{
continue;
}
let condemned = |s: &str| {
gone.contains(s)
|| (!recipe_excludes(s, &opts.exclude) && classifier.classify(s).omittable())
};
if !libs.iter().all(|s| condemned(s)) {
continue;
}
let Some(soname) = libs.iter().find(|s| condemned(s)) else {
continue;
};
let mut names: Vec<String> = object
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.into_iter()
.chain(crate::bundle::elf::parse_soname(object))
.collect();
names.dedup();
if let Err(e) = fs::symlink_metadata(object) {
if e.kind() == std::io::ErrorKind::NotFound {
continue;
}
return Err(e);
}
fs::remove_file(object)?;
if droppable.is_empty() {
remove_dangling_links_beside(object);
}
gone.extend(names.iter().cloned());
dropped.push(Dropped {
object: rel.to_path_buf(),
names,
need: soname.clone(),
verdict: classifier.classify(soname),
});
progressed = true;
}
if !progressed {
break;
}
*findings = audit();
}
if !droppable.is_empty() {
loop {
let before = dropped.len();
sweep_dangling_aliases(opts, droppable, &kept, required, &generated, &mut dropped)?;
if dropped.len() == before {
break;
}
}
}
dropped.sort_by(|a, b| (&a.object, &a.need).cmp(&(&b.object, &b.need)));
Ok(dropped)
}
fn sweep_dangling_aliases(
opts: &BundleOptions,
droppable: &[PathBuf],
kept: &HashSet<PathBuf>,
required: &HashSet<PathBuf>,
generated: &HashSet<PathBuf>,
dropped: &mut Vec<Dropped>,
) -> io::Result<()> {
let root = fs::canonicalize(&opts.directory).unwrap_or_else(|_| opts.directory.clone());
for dest in droppable {
for entry in jwalk::WalkDir::new(opts.directory.join(dest))
.skip_hidden(false)
.sort(true)
{
let Ok(entry) = entry else { continue };
let link = entry.file_type().is_symlink();
let path = entry.path().to_path_buf();
if !link || path.exists() {
continue;
}
let rel = path
.strip_prefix(&opts.directory)
.unwrap_or(&path)
.to_path_buf();
if kept.contains(&rel)
|| required.iter().any(|dest| rel.starts_with(dest))
|| !generated.contains(&rel)
{
continue;
}
let Ok(target) = fs::read_link(&path) else {
continue;
};
let Ok(base) = fs::canonicalize(path.parent().unwrap_or(Path::new("."))) else {
continue;
};
let joined = base.join(target);
let mut resolved = PathBuf::new();
for comp in joined.components() {
match comp {
std::path::Component::ParentDir => {
resolved.pop();
}
std::path::Component::CurDir => {}
comp => resolved.push(comp.as_os_str()),
}
}
let Ok(target_rel) = resolved.strip_prefix(&root) else {
continue;
};
let Some(prior) = dropped.iter().find(|d| d.object == target_rel) else {
continue;
};
let Some(alias) = path.file_name().and_then(|n| n.to_str().map(String::from)) else {
continue;
};
let need = prior.need.clone();
let verdict = prior.verdict.clone();
fs::remove_file(&path)?;
dropped.push(Dropped {
object: rel,
names: vec![alias],
need,
verdict,
});
}
}
Ok(())
}
pub(crate) fn remove_dangling_links_beside(object: &Path) {
let (Some(dir), Some(name)) = (object.parent(), object.file_name()) else {
return;
};
let mut gone: HashSet<std::ffi::OsString> = HashSet::from([name.to_os_string()]);
loop {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let mut removed = false;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_symlink() || path.exists() {
continue;
}
let Ok(target) = fs::read_link(&path) else {
continue;
};
let led_here = target.parent().is_none_or(|p| p.as_os_str().is_empty())
&& target.file_name().is_some_and(|t| gone.contains(t));
if led_here && fs::remove_file(&path).is_ok() {
gone.insert(entry.file_name());
removed = true;
}
}
if !removed {
return;
}
}
}
fn host_platform_provider(soname: &str) -> bool {
onelf_format::resolve::is_libc_family(soname)
|| libc_family_of_soname(soname).is_some()
|| onelf_format::drivers::DRIVER_FAMILIES
.iter()
.any(|prefix| soname.starts_with(prefix))
|| DEFAULT_EXCLUDES.iter().any(|p| soname.starts_with(p))
}
fn recipe_excludes(soname: &str, exclude: &[String]) -> bool {
!host_platform_provider(soname) && exclude.iter().any(|p| soname.starts_with(p.as_str()))
}
fn inferred_aliases(opts: &BundleOptions, inferred: &[PathBuf]) -> Vec<PathBuf> {
let mut aliases = Vec::new();
for dest in inferred {
for entry in jwalk::WalkDir::new(opts.directory.join(dest))
.skip_hidden(false)
.sort(true)
{
let Ok(entry) = entry else { continue };
let link = entry.file_type().is_symlink();
let path = entry.path();
if link && path.exists() {
aliases.push(path.to_path_buf());
}
}
}
aliases
}
fn verify_needs(
opts: &BundleOptions,
platform: Option<&PlatformLine>,
set_sonames: &HashSet<String>,
classifier: &SourceClassifier,
required: &HashSet<PathBuf>,
inferred: &[PathBuf],
) -> io::Result<()> {
let strict = opts.sysroot.is_some() || opts.strict;
let follow = if strict {
Vec::new()
} else {
inferred_aliases(opts, inferred)
};
let mut findings = audit_unbundled_needs(&opts.directory, &opts.lib_dir, &follow);
let mut set_report = None;
let mut set_provided: HashSet<String> = HashSet::new();
if opts.sysroot.is_none() && !opts.sets.is_empty() {
let (provides, unchecked) = sysroot::take_set_provides(&opts.sets, &mut findings);
set_provided.extend(provides.iter().flat_map(|p| p.sonames.iter().cloned()));
set_report = Some((provides, unchecked));
}
let open = |s: &str| {
!platform.is_some_and(|p| p.matches_soname(s))
&& !set_sonames.contains(s)
&& !set_provided.contains(s)
};
let host_allowed = |s: &str| opts.sysroot.is_none() && host_platform_provider(s);
let condemnable = |findings: &mut Vec<(PathBuf, Vec<String>)>| {
for (_, libs) in findings.iter_mut() {
libs.retain(|s| open(s) && !host_allowed(s));
}
findings.retain(|(_, libs)| !libs.is_empty());
};
let audit = || {
let mut findings = audit_unbundled_needs(&opts.directory, &opts.lib_dir, &follow);
condemnable(&mut findings);
findings
};
for (_, libs) in &mut findings {
libs.retain(|s| open(s));
}
findings.retain(|(_, libs)| !libs.is_empty());
let mut dropped = Vec::new();
if strict {
let mut failing = findings.clone();
condemnable(&mut failing);
dropped = drop_unloadable(
opts,
&mut failing,
audit,
classifier,
required,
&[],
&HashSet::new(),
)?;
if !dropped.is_empty() {
findings = audit_unbundled_needs(&opts.directory, &opts.lib_dir, &follow);
for (_, libs) in &mut findings {
libs.retain(|s| open(s));
}
findings.retain(|(_, libs)| !libs.is_empty());
report_dropped(&dropped);
}
} else if !inferred.is_empty() {
let mut failing = findings.clone();
condemnable(&mut failing);
let broken = unloadable_closure(opts, &failing, inferred);
failing.extend(broken.members);
let omitted = drop_unloadable(
opts,
&mut failing,
audit,
classifier,
required,
inferred,
&broken.names,
)?;
if !omitted.is_empty() {
findings = audit_unbundled_needs(&opts.directory, &opts.lib_dir, &follow);
for (_, libs) in &mut findings {
libs.retain(|s| open(s));
}
findings.retain(|(_, libs)| !libs.is_empty());
report_dropped(&omitted);
}
}
if let Some((provides, unchecked)) = &set_report {
sysroot::report_set_provides(provides, unchecked);
}
report_unbundled_needs(&findings);
if !strict {
return Ok(());
}
let mut failing = findings;
condemnable(&mut failing);
let Some((object, libs)) = first_failure(opts, &failing) else {
return Ok(());
};
let rel = object.strip_prefix(&opts.directory).unwrap_or(object);
let shown = rel.display();
let soname = &libs[0];
let verdict = classifier.classify(soname);
let explicit = explicit_include_paths(opts).contains(rel);
let omitted = dropped.iter().find(|d| d.names.iter().any(|n| n == soname));
let why = if let Some(d) = omitted {
format!(
"{shown} needs {soname}, which the build omitted because {} needs {}: {}",
d.object.display(),
d.need,
d.verdict.reason()
)
} else if explicit {
format!(
"{shown} is explicitly included and needs {soname}: {}; an explicit include \
cannot be dropped",
verdict.reason()
)
} else if let SourceVerdict::Pruned { owner } = &verdict {
format!(
"{shown} needs {soname}, which {owner} holds but the policy or a trace \
removed; the conflict stops the build rather than dropping the object or \
restoring the file"
)
} else if recipe_excludes(soname, &opts.exclude) {
format!(
"{shown} needs {soname}, which [bundle] exclude leaves out; the runtime takes \
only drivers and the libc family from the host, so the object could not load. \
Remove the exclusion, or exclude the object that needs it"
)
} else if opts.sysroot.is_none() {
format!(
"{shown} needs {soname}, which neither the tree, the host libraries the scan \
searched, a host-platform provider, nor a selected set supplies; add it with \
--search-path or [bundle] search-paths"
)
} else if matches!(verdict, SourceVerdict::Absent) {
format!(
"{shown} needs {soname}, which is neither in the sysroot closure, on the \
platform line, nor in a shared set"
)
} else {
format!("{shown} needs {soname}: {}", verdict.reason())
};
let reached = entrypoints_reaching(opts, rel, soname);
let by = if reached.is_empty() {
String::new()
} else {
format!(" (entrypoint {})", reached.join(", "))
};
let more = match failing.len() {
1 => String::new(),
n => format!(" ({} more listed above)", n - 1),
};
Err(io::Error::new(
io::ErrorKind::NotFound,
format!("{why}{by}{more}"),
))
}
fn report_dropped(dropped: &[Dropped]) {
for d in dropped {
completeness::record(Finding::new(
d.object.display().to_string(),
Outcome::OptionalOmission,
format!("omitted, needs {} ({})", d.need, d.verdict.reason()),
));
}
}
fn entrypoints_reaching(opts: &BundleOptions, object: &Path, soname: &str) -> Vec<String> {
let lib_root = opts.directory.join(&opts.lib_dir);
let mut names: Vec<String> = opts
.entrypoint_selections
.iter()
.filter(|e| {
e.path.as_ref().is_some_and(|path| {
entrypoint_requirements(&opts.directory, &lib_root, &opts.directory.join(path))
.iter()
.any(|(o, s)| Path::new(o) == object && s == soname)
})
})
.map(|e| format!("'{}'", e.name))
.collect();
names.sort();
names.dedup();
names
}
fn first_failure<'a>(
opts: &BundleOptions,
findings: &'a [(PathBuf, Vec<String>)],
) -> Option<&'a (PathBuf, Vec<String>)> {
let explicit = explicit_include_paths(opts);
let mut ranked: Vec<&(PathBuf, Vec<String>)> = findings.iter().collect();
ranked.sort_by_key(|(object, _)| {
let rel = object.strip_prefix(&opts.directory).unwrap_or(object);
!explicit.contains(rel)
});
ranked.into_iter().next()
}
const SHOWN_PROVIDER_FINDINGS: usize = 8;
const SHOWN_COLLIDING_PROVIDERS: usize = 3;
#[derive(Debug, Clone)]
pub struct AuditedEntrypoint {
pub name: String,
pub path: Option<PathBuf>,
pub selection: recipe::SetSelection,
}
pub fn audit_entrypoint_providers(
directory: &Path,
lib_dir: &Path,
entrypoints: &[AuditedEntrypoint],
providers: &[sysroot::SetProvider],
host: &dyn Fn(&str) -> bool,
) -> io::Result<()> {
let mut ordered: Vec<&AuditedEntrypoint> = entrypoints.iter().collect();
ordered.sort_by(|a, b| a.name.cmp(&b.name));
let mut findings: Vec<String> = Vec::new();
for entrypoint in ordered {
match selected_providers(&entrypoint.selection, providers) {
Ok(selected) => {
findings.extend(provider_collisions(&entrypoint.name, &selected));
if let Some(path) = &entrypoint.path {
findings.extend(unsatisfied_requirements(
directory,
lib_dir,
path,
&entrypoint.name,
&selected,
providers,
host,
));
}
}
Err(name) => findings.push(format!(
"entrypoint '{}': the selection names set {name}, which the recipe \
does not declare",
entrypoint.name
)),
}
}
findings.sort();
findings.dedup();
if findings.is_empty() {
return Ok(());
}
let mut message = String::from("provider audit failed:");
for finding in findings.iter().take(SHOWN_PROVIDER_FINDINGS) {
message.push_str("\n ");
message.push_str(finding);
}
let hidden = findings.len().saturating_sub(SHOWN_PROVIDER_FINDINGS);
if hidden > 0 {
message.push_str(&format!("\n and {hidden} more findings"));
}
Err(io::Error::new(io::ErrorKind::InvalidData, message))
}
fn selected_by_an_entrypoint(opts: &BundleOptions, name: &str) -> bool {
opts.entrypoint_selections.is_empty()
|| opts
.entrypoint_selections
.iter()
.any(|e| match &e.selection {
recipe::SetSelection::All => true,
recipe::SetSelection::Names(names) => names.iter().any(|n| n == name),
})
}
fn selected_providers<'a>(
selection: &recipe::SetSelection,
providers: &'a [sysroot::SetProvider],
) -> Result<Vec<&'a sysroot::SetProvider>, String> {
match selection {
recipe::SetSelection::All => Ok(providers.iter().collect()),
recipe::SetSelection::Names(names) => {
let mut selected = Vec::with_capacity(names.len());
for name in names {
match providers.iter().find(|p| &p.set == name) {
Some(provider) => selected.push(provider),
None => return Err(name.clone()),
}
}
selected.sort_by(|a, b| a.set.cmp(&b.set));
Ok(selected)
}
}
}
fn provider_collisions(entrypoint: &str, selected: &[&sysroot::SetProvider]) -> Vec<String> {
let mut by_export: std::collections::BTreeMap<
&str,
Vec<(&sysroot::SetProvider, &sysroot::SetExport)>,
> = std::collections::BTreeMap::new();
for provider in selected {
for export in &provider.exports {
by_export
.entry(export.soname.as_str())
.or_default()
.push((provider, export));
}
}
let mut findings = Vec::new();
for (soname, mut exports) in by_export {
exports.sort_by(|a, b| (&a.1.blake3, &a.0.set).cmp(&(&b.1.blake3, &b.0.set)));
let mut groups: Vec<(&[u8; 32], &sysroot::SetProvider)> = Vec::new();
for (provider, export) in &exports {
match groups.last() {
Some((hash, _)) if *hash == &export.blake3 => {}
_ => groups.push((&export.blake3, provider)),
}
}
if groups.len() < 2 {
continue;
}
let listed: Vec<String> = groups
.iter()
.take(SHOWN_COLLIDING_PROVIDERS)
.map(|(_, p)| format!("{} (blake3 {})", p.set, short_pin(&p.pin)))
.collect();
let more = groups.len().saturating_sub(SHOWN_COLLIDING_PROVIDERS);
let extra = if more > 0 {
format!(" and {more} more")
} else {
String::new()
};
findings.push(format!(
"entrypoint '{entrypoint}': {soname} is exported with different bytes by \
sets {}{extra}, so they cannot be selected together",
listed.join(" and ")
));
}
findings
}
fn short_pin(pin: &str) -> String {
let head: String = pin.chars().take(12).collect();
format!("{head}..({} chars)", pin.chars().count())
}
fn entrypoint_requirements(
directory: &Path,
lib_root: &Path,
entry: &Path,
) -> Vec<(String, String)> {
let mut listings: HashMap<PathBuf, std::rc::Rc<HashSet<String>>> = HashMap::new();
let mut listed = |dir: &Path| -> std::rc::Rc<HashSet<String>> {
listings
.entry(dir.to_path_buf())
.or_insert_with(|| std::rc::Rc::new(names_in(dir)))
.clone()
};
let lib_names = listed(lib_root);
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut queue: std::collections::VecDeque<PathBuf> =
std::collections::VecDeque::from([entry.to_path_buf()]);
let mut requirements: Vec<(String, String)> = Vec::new();
while let Some(object) = queue.pop_front() {
let Ok(real) = fs::canonicalize(&object) else {
continue;
};
if !seen.insert(real.clone()) {
continue;
}
let Ok(needed) = parse_needed(&real) else {
continue;
};
let parent = real.parent().unwrap_or(directory).to_path_buf();
let parent_names = if parent.starts_with(lib_root) {
listed(&parent)
} else {
std::rc::Rc::default()
};
let origin_names = listed(&parent.join("../lib"));
let mut run_dirs: Vec<(PathBuf, std::rc::Rc<HashSet<String>>)> =
parse_rpaths_under(&real, Some(directory))
.into_iter()
.filter(|dir| dir.starts_with(directory))
.map(|dir| {
let names = listed(&dir);
(dir, names)
})
.collect();
run_dirs.sort_by(|a, b| a.0.cmp(&b.0));
for soname in needed {
let bare = Path::new(&soname)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(soname.as_str());
if is_dynamic_loader(bare) {
continue;
}
let found = if parent_names.contains(bare) {
Some(parent.join(bare))
} else if lib_names.contains(bare) {
Some(lib_root.join(bare))
} else if origin_names.contains(bare) {
Some(parent.join("../lib").join(bare))
} else {
run_dirs
.iter()
.find(|(_, names)| names.contains(bare))
.map(|(dir, _)| dir.join(bare))
};
match found {
Some(path) => queue.push_back(path),
None => {
let rel = object
.strip_prefix(directory)
.unwrap_or(&object)
.to_string_lossy()
.into_owned();
requirements.push((rel, soname));
}
}
}
}
requirements.sort();
requirements.dedup();
requirements
}
fn names_in(dir: &Path) -> HashSet<String> {
fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().exists())
.filter_map(|e| e.file_name().to_str().map(String::from))
.collect()
}
fn unsatisfied_requirements(
directory: &Path,
lib_dir: &Path,
entry: &Path,
entrypoint: &str,
selected: &[&sysroot::SetProvider],
providers: &[sysroot::SetProvider],
host: &dyn Fn(&str) -> bool,
) -> Vec<String> {
let resolved = directory.join(entry);
if !resolved.is_file() {
return vec![format!(
"entrypoint '{entrypoint}': {} is not in the tree",
entry.display()
)];
}
let lib_root = directory.join(lib_dir);
let requirements = entrypoint_requirements(directory, &lib_root, &resolved);
let ships = |p: &sysroot::SetProvider, soname: &str| p.provides.iter().any(|s| s == soname);
let mut findings = Vec::new();
for (object, soname) in requirements {
if selected.iter().any(|p| ships(p, &soname)) || host(&soname) {
continue;
}
let mut owners: Vec<&sysroot::SetProvider> =
providers.iter().filter(|p| ships(p, &soname)).collect();
owners.sort_by(|a, b| a.set.cmp(&b.set));
let why = if owners.is_empty() {
"which nothing in the application, its selection, or the host line \
provides"
.to_string()
} else {
let names: Vec<&str> = owners.iter().map(|p| p.set.as_str()).collect();
format!(
"which {} exports and the entrypoint does not select",
names.join(" and ")
)
};
findings.push(format!(
"entrypoint '{entrypoint}': {object} needs {soname}: {why}"
));
}
findings
}
fn audit_declared_sets(opts: &BundleOptions, providers: &[sysroot::SetProvider]) -> io::Result<()> {
let name = opts
.primary
.as_deref()
.or(opts.target.as_deref())
.and_then(|path| Path::new(path).file_name())
.and_then(|n| n.to_str())
.unwrap_or("main")
.to_string();
audit_entrypoint_providers(
&opts.directory,
&opts.lib_dir,
&[AuditedEntrypoint {
name,
path: None,
selection: recipe::SetSelection::All,
}],
providers,
&|_| false,
)
}
fn audit_entrypoint_selections(
opts: &BundleOptions,
platform: Option<&PlatformLine>,
providers: &[sysroot::SetProvider],
unchecked: &[(String, String)],
) -> io::Result<()> {
if opts.entrypoint_selections.is_empty() {
return Ok(());
}
let mut audited = providers.to_vec();
for (name, _) in unchecked {
if audited.iter().any(|p| p.set == *name) {
continue;
}
audited.push(sysroot::SetProvider {
set: name.clone(),
pin: String::new(),
provides: Vec::new(),
exports: Vec::new(),
});
}
let host = |soname: &str| {
platform.is_some_and(|p| p.matches_soname(soname))
|| onelf_format::resolve::is_libc_family(soname)
|| onelf_format::drivers::DRIVER_FAMILIES
.iter()
.any(|prefix| soname.starts_with(prefix))
};
audit_entrypoint_providers(
&opts.directory,
&opts.lib_dir,
&opts.entrypoint_selections,
&audited,
&host,
)
}
pub(crate) fn normalize_mtime(path: &Path) {
let secs = crate::pack::source_date_epoch()
.and_then(|s| i64::try_from(s).ok())
.unwrap_or(0);
let t = rustix::fs::Timespec {
tv_sec: secs,
tv_nsec: 0,
};
let _ = rustix::fs::utimensat(
rustix::fs::CWD,
path,
&rustix::fs::Timestamps {
last_access: t,
last_modification: t,
},
rustix::fs::AtFlags::empty(),
);
}
fn resolve_target_elfs(opts: &BundleOptions) -> io::Result<Vec<PathBuf>> {
if let Some(ref target) = opts.target {
let path = if target.is_absolute() {
target.clone()
} else {
opts.directory.join(target)
};
if !path.is_file() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("{}: not a file", path.display()),
));
}
Ok(vec![path])
} else {
Ok(find_elf_files(&opts.directory))
}
}
fn split_gpu_stack(libs: Vec<PathBuf>) -> (Vec<PathBuf>, Vec<PathBuf>) {
const IMPLEMENTATIONS: &[&str] = &[
"libGLX_",
"libEGL_",
"libglapi.so",
"libgallium",
"libLLVM",
"libvulkan_",
"libVkLayer_",
"libxatracker",
"libvdpau",
];
const DRIVER_SUFFIXES: &[&str] = &["_dri.so", "_drv_video.so"];
let name = |p: &Path| {
p.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string()
};
let mut gpu: HashSet<String> = libs
.iter()
.map(|p| name(p))
.filter(|n| {
onelf_format::drivers::DRIVER_FAMILIES
.iter()
.chain(IMPLEMENTATIONS)
.any(|f| n.starts_with(f))
|| DRIVER_SUFFIXES.iter().any(|s| n.ends_with(s))
})
.collect();
let needs: Vec<(String, Vec<String>)> = libs
.iter()
.map(|p| (name(p), parse_needed(p).unwrap_or_default()))
.collect();
loop {
let more: Vec<String> = needs
.iter()
.map(|(n, _)| n)
.filter(|n| !gpu.contains(*n))
.filter(|n| {
let mut needers = needs.iter().filter(|(_, deps)| deps.contains(n)).peekable();
needers.peek().is_some() && needers.all(|(m, _)| gpu.contains(m))
})
.cloned()
.collect();
if more.is_empty() {
break;
}
gpu.extend(more);
}
libs.into_iter().partition(|p| !gpu.contains(&name(p)))
}
fn trace_loader(
target: &Path,
dirs: &[PathBuf],
class: Option<u8>,
machine: Option<u16>,
) -> io::Result<Option<PathBuf>> {
let Some(interp) = parse_interp(target) else {
return Ok(None);
};
if Path::new(&interp).exists() {
return Ok(None);
}
let Some(name) = Path::new(&interp).file_name().and_then(|n| n.to_str()) else {
return Ok(None);
};
let mut names = vec![name.to_string()];
names.extend(libc_alias_for(name));
if libc_family_from_interp(name) == Some(LibcFamily::Musl) {
names.push("libc.so".to_string());
}
let found = dirs
.iter()
.flat_map(|d| names.iter().map(move |n| d.join(n)))
.find(|c| {
c.is_file()
&& read_elf_class(c).is_some_and(|k| class.is_none_or(|t| k == t))
&& read_elf_machine(c).is_some_and(|m| machine.is_none_or(|t| m == t))
});
match found {
Some(loader) => Ok(Some(loader)),
None => Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"--trace-run cannot start {}: its interpreter {interp} is not on this \
machine; pass --search-path with a directory that holds it",
target.display()
),
)),
}
}
fn resolve_primary(opts: &BundleOptions) -> Option<PathBuf> {
let named = opts.primary.as_ref().or(opts.target.as_ref())?;
let path = if named.is_absolute() {
named.clone()
} else {
opts.directory.join(named)
};
path.is_file().then_some(path)
}
pub struct WalkExclusion {
installed: usize,
}
impl Drop for WalkExclusion {
fn drop(&mut self) {
WALK_EXCLUSIONS.with(|list| {
let mut list = list.borrow_mut();
let keep = list.len().saturating_sub(self.installed);
list.truncate(keep);
});
}
}
thread_local! {
static WALK_EXCLUSIONS: RefCell<Vec<PathBuf>> = const { RefCell::new(Vec::new()) };
}
pub fn exclude_from_walk(paths: Vec<PathBuf>) -> WalkExclusion {
let resolved: Vec<PathBuf> = paths
.into_iter()
.filter_map(|path| fs::canonicalize(&path).ok())
.collect();
let installed = resolved.len();
WALK_EXCLUSIONS.with(|list| list.borrow_mut().extend(resolved));
WalkExclusion { installed }
}
fn find_elf_files(dir: &Path) -> Vec<PathBuf> {
let excluded = WALK_EXCLUSIONS.with(|list| list.borrow().clone());
let root = if excluded.is_empty() {
None
} else {
Some(fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()))
};
let mut result = Vec::new();
for entry in jwalk::WalkDir::new(dir).skip_hidden(false).sort(true) {
let Ok(entry) = entry else { continue };
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if let Some(root) = &root {
let resolved = path
.strip_prefix(dir)
.map(|rel| root.join(rel))
.unwrap_or_else(|_| path.to_path_buf());
if excluded.contains(&resolved) {
continue;
}
}
if is_elf(&path) {
result.push(path);
}
}
result
}
fn is_elf(path: &Path) -> bool {
fs::File::open(path)
.and_then(|mut f| {
let mut magic = [0u8; 4];
io::Read::read_exact(&mut f, &mut magic)?;
Ok(magic == *b"\x7fELF")
})
.unwrap_or(false)
}
fn read_elf_class(path: &Path) -> Option<u8> {
let mut f = fs::File::open(path).ok()?;
let mut header = [0u8; 5];
io::Read::read_exact(&mut f, &mut header).ok()?;
if header[0..4] == *b"\x7fELF" {
Some(header[4])
} else {
None
}
}
fn read_elf_machine(path: &Path) -> Option<u16> {
let mut f = fs::File::open(path).ok()?;
let mut header = [0u8; 20];
io::Read::read_exact(&mut f, &mut header).ok()?;
if header[0..4] != *b"\x7fELF" {
return None;
}
Some(u16::from_le_bytes([header[18], header[19]]))
}
const EM_X86_64: u16 = 62;
const EM_386: u16 = 3;
const EM_AARCH64: u16 = 183;
const EM_ARM: u16 = 40;
const VULKAN_DRIVERS_X86: &[&str] = &[
"libvulkan_intel.so",
"libvulkan_radeon.so",
"libvulkan_nouveau.so",
"libvulkan_lvp.so",
"libvulkan_virtio.so",
];
const VULKAN_DRIVERS_ARM: &[&str] = &[
"libvulkan_panfrost.so",
"libvulkan_asahi.so",
"libvulkan_freedreno.so",
"libvulkan_broadcom.so",
"libvulkan_powervr_mesa.so",
"libvulkan_lvp.so",
"libvulkan_virtio.so",
];
const DRI_DRIVERS_X86: &[&str] = &[
"iris_dri.so",
"i915_dri.so",
"i965_dri.so",
"radeonsi_dri.so",
"r600_dri.so",
"r300_dri.so",
"nouveau_dri.so",
"swrast_dri.so",
"kms_swrast_dri.so",
"vmwgfx_dri.so",
"virtio_gpu_dri.so",
"zink_dri.so",
];
const DRI_DRIVERS_ARM: &[&str] = &[
"panfrost_dri.so",
"asahi_dri.so",
"freedreno_dri.so",
"v3d_dri.so",
"vc4_dri.so",
"etnaviv_dri.so",
"lima_dri.so",
"tegra_dri.so",
"swrast_dri.so",
"kms_swrast_dri.so",
"virtio_gpu_dri.so",
"zink_dri.so",
];
fn driver_filter(
machine: Option<u16>,
x86_list: &'static [&'static str],
arm_list: &'static [&'static str],
) -> Option<&'static [&'static str]> {
match machine {
Some(EM_X86_64) | Some(EM_386) => Some(x86_list),
Some(EM_AARCH64) | Some(EM_ARM) => Some(arm_list),
_ => None,
}
}
#[derive(Default, Debug, Clone, Copy)]
struct FrameworkFlags {
gl: bool,
dri: bool,
vulkan: bool,
wayland: bool,
gtk: bool,
}
fn detect_frameworks(files: &[PathBuf]) -> FrameworkFlags {
let mut flags = FrameworkFlags::default();
for path in files {
let Ok(bytes) = fs::read(path) else {
continue;
};
if let Ok(needed) = parse_needed_bytes(&bytes) {
for soname in needed {
inspect_soname_for_frameworks(&soname, &mut flags);
}
}
scan_framework_strings(&bytes, &mut flags);
}
flags
}
fn inspect_soname_for_frameworks(soname: &str, flags: &mut FrameworkFlags) {
if soname.starts_with("libGL.so")
|| soname.starts_with("libEGL.so")
|| soname.starts_with("libGLESv")
|| soname.starts_with("libOpenGL.so")
{
flags.gl = true;
flags.dri = true;
}
if soname.starts_with("libgbm.so") {
flags.dri = true;
}
if soname.starts_with("libvulkan.so") {
flags.vulkan = true;
}
if soname.starts_with("libwayland-client.so")
|| soname.starts_with("libwayland-egl.so")
|| soname.starts_with("libwayland-cursor.so")
|| soname.starts_with("libwayland-server.so")
|| soname.starts_with("libdecor-0.so")
{
flags.wayland = true;
}
if soname.starts_with("libgtk-") {
flags.gtk = true;
}
}
fn scan_framework_strings(bytes: &[u8], flags: &mut FrameworkFlags) {
const STEMS: &[&[u8]] = &[
b"libGL",
b"libEGL",
b"libGLESv",
b"libOpenGL",
b"libgbm",
b"libvulkan",
b"libwayland-client",
b"libwayland-egl",
b"libwayland-cursor",
b"libwayland-server",
b"libdecor-0",
b"libgtk-",
];
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'l' {
i += 1;
continue;
}
if STEMS.iter().any(|stem| bytes[i..].starts_with(stem))
&& let Some(soname) = versioned_soname_at(bytes, i)
{
inspect_soname_for_frameworks(&soname, flags);
}
i += 1;
}
}
fn versioned_soname_at(bytes: &[u8], start: usize) -> Option<String> {
let mut j = start;
while j < bytes.len()
&& matches!(bytes[j], b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
{
j += 1;
}
if !bytes[j..].starts_with(b".so") {
return None;
}
j += 3;
if j + 1 >= bytes.len() || bytes[j] != b'.' || !bytes[j + 1].is_ascii_digit() {
return None;
}
while j < bytes.len() && (bytes[j] == b'.' || bytes[j].is_ascii_digit()) {
j += 1;
}
std::str::from_utf8(&bytes[start..j])
.ok()
.map(str::to_string)
}
const DLOPEN_CANDIDATES: &[&str] = &[
"libwayland-client.so.0",
"libwayland-cursor.so.0",
"libwayland-egl.so.1",
"libdecor-0.so.0",
"libX11.so.6",
"libX11-xcb.so.1",
"libxcb.so.1",
"libXcursor.so.1",
"libXext.so.6",
"libXfixes.so.3",
"libXi.so.6",
"libXinerama.so.1",
"libXrandr.so.2",
"libXrender.so.1",
"libXss.so.1",
"libxkbcommon.so.0",
"libxkbcommon-x11.so.0",
"libpulse.so.0",
"libasound.so.2",
"libjack.so.0",
"libdbus-1.so.3",
"libfontconfig.so.1",
"libfreetype.so.6",
"libharfbuzz.so.0",
];
fn scan_dlopen(path: &Path, extra: &[String]) -> io::Result<Vec<String>> {
let data = fs::read(path)?;
Ok(dlopen_hits(&data, extra))
}
fn dlopen_hits(data: &[u8], extra: &[String]) -> Vec<String> {
let candidates: Vec<&str> = DLOPEN_CANDIDATES
.iter()
.copied()
.chain(extra.iter().map(String::as_str))
.filter(|name| !name.is_empty())
.collect();
let mut firsts: Vec<u8> = candidates.iter().map(|name| name.as_bytes()[0]).collect();
firsts.sort_unstable();
firsts.dedup();
let mut found: Vec<String> = Vec::new();
for i in 0..data.len() {
if !firsts.contains(&data[i]) {
continue;
}
for name in &candidates {
let end = i + name.len();
if data[i..].starts_with(name.as_bytes())
&& !matches!(data.get(end), Some(b'.' | b'0'..=b'9'))
&& !found.iter().any(|x| x == name)
{
found.push(name.to_string());
}
}
}
found
}
#[cfg(test)]
mod plugin_dir_tests {
use super::*;
#[test]
fn a_sysroot_carry_copies_only_admitted_files() {
let dir = std::env::temp_dir().join(format!("onelf-carry-admit-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let root = dir.join("sysroot");
let tree = dir.join("tree");
fs::create_dir_all(&tree).unwrap();
let files = [
"usr/lib/libtcl8.6.so",
"usr/share/tcltk/tcl8.6/init.tcl",
"usr/share/tcltk/tcl8.6/encoding/ascii.enc",
];
for file in files {
let path = root.join(file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, file.as_bytes()).unwrap();
}
let packages = [sysroot::fixture::package("app", &files)];
let policy_path = dir.join("policy.txt");
fs::write(&policy_path, "usr/share/tcltk/tcl8.6/encoding/*\n").unwrap();
let policy = Policy::load(&policy_path).unwrap();
let universe =
sysroot::fixture::populate_universe(&root, &packages, "app", &[], Some(&policy), None);
let descriptor = onelf_format::catalog::builtins()
.iter()
.find(|d| d.id.as_str() == "tcl-8.6")
.unwrap();
let admitted = [AdmittedLibrary::new(
"libtcl8.6.so",
root.join("usr/lib/libtcl8.6.so"),
)];
let mut plan = DestinationPlan::load(&tree).unwrap();
let mut actions: Vec<WalkAction> = Vec::new();
let mut preparation = SetPreparation::new(&[], &[], Path::new("lib"), false);
for declared in &descriptor.resources.data {
carry_data_group(
&tree,
descriptor,
declared,
&admitted,
Some(&universe),
&mut plan,
&mut actions,
&mut preparation,
&mut HashSet::new(),
)
.unwrap();
}
assert!(!plan.has_findings(), "{:?}", plan.findings());
let mut outcome = WalkOutcome::default();
execute_walk(
&tree,
false,
false,
&tree.join("lib"),
&actions,
&plan,
&mut outcome,
)
.unwrap();
assert!(tree.join("lib/tcl8.6/init.tcl").is_file());
assert!(
!tree.join("lib/tcl8.6/encoding").exists(),
"the pruned file is not resurrected"
);
assert_eq!(outcome.written, vec!["lib/tcl8.6/init.tcl".to_string()]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn link_targets_stay_inside_the_library_directory() {
assert_eq!(
link_target(Path::new("alsa-lib"), Path::new("alsa-lib/b.so")),
"b.so"
);
assert_eq!(
link_target(Path::new(""), Path::new("alsa-lib/b.so")),
"alsa-lib/b.so"
);
assert_eq!(
link_target(Path::new("alsa-lib"), Path::new("b.so")),
"../b.so"
);
assert_eq!(link_target(Path::new(""), Path::new("b.so")), "b.so");
}
fn dist_tree(tag: &str) -> (PathBuf, PathBuf) {
let root = std::env::temp_dir().join(format!("onelf-libdata-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("usr/lib")).unwrap();
fs::create_dir_all(root.join("usr/share/alsa/cards")).unwrap();
fs::write(root.join("usr/share/alsa/alsa.conf"), b"conf").unwrap();
fs::write(root.join("usr/share/alsa/cards/aliases.conf"), b"cards").unwrap();
(root.clone(), root.join("usr/lib/libasound.so.2"))
}
fn carry_soname_data(directory: &Path, soname: &str, resolved: &Path, dry_run: bool) {
let descriptor = onelf_format::catalog::builtins()
.iter()
.find(|d| d.triggers.iter().any(|t| t.matches_soname(soname)));
let Some(descriptor) = descriptor else {
return;
};
let admitted = [AdmittedLibrary::new(soname, resolved)];
let mut plan = DestinationPlan::load(directory).unwrap();
let mut actions: Vec<WalkAction> = Vec::new();
let mut preparation = SetPreparation::new(&[], &[], Path::new("lib"), false);
for declared in &descriptor.resources.data {
carry_data_group(
directory,
descriptor,
declared,
&admitted,
None,
&mut plan,
&mut actions,
&mut preparation,
&mut HashSet::new(),
)
.unwrap();
}
assert!(!plan.has_findings(), "{:?}", plan.findings());
execute_walk(
directory,
dry_run,
false,
&directory.join("lib"),
&actions,
&plan,
&mut WalkOutcome::default(),
)
.unwrap();
}
#[test]
fn a_carried_library_brings_its_data() {
let (root, lib) = dist_tree("carry");
let tree = root.join("tree");
fs::create_dir_all(tree.join("lib")).unwrap();
carry_soname_data(&tree, "libasound.so.2", &lib, false);
assert_eq!(
fs::read_to_string(tree.join("share/alsa/alsa.conf")).unwrap(),
"conf"
);
assert!(tree.join("share/alsa/cards/aliases.conf").is_file());
let before = fs::read_to_string(tree.join("share/alsa/alsa.conf")).unwrap();
carry_soname_data(&tree, "libasound.so.2", &lib, false);
assert_eq!(
fs::read_to_string(tree.join("share/alsa/alsa.conf")).unwrap(),
before
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_carried_alsa_conf_loads_its_snippets_between_host_and_user() {
let work = std::env::temp_dir().join(format!("onelf-alsa-hooks-{}", std::process::id()));
let _ = fs::remove_dir_all(&work);
fs::create_dir_all(&work).unwrap();
let conf = work.join("alsa.conf");
fs::write(
&conf,
r##"@hooks [
{
func load
files [
"/var/lib/alsa/conf.d"
"/usr/etc/alsa/conf.d"
"/etc/alsa/conf.d"
"/etc/asound.conf|||/usr/etc/asound.conf"
"~/.asoundrc"
]
errors false
}
]
cards.@hooks [
{
func load
files [
"/etc/alsa/conf.d"
]
}
]
pcm.default cards.pcm.default
"##,
)
.unwrap();
assert!(patch_alsa_conf_hooks(&conf).unwrap());
let out = fs::read_to_string(&conf).unwrap();
let host = out.find("\"/etc/alsa/conf.d\"").unwrap();
let carried = out
.find("{ @func datadir } \"/alsa.conf.d\"")
.expect("carried entry injected");
let user = out.find("\"/etc/asound.conf").unwrap();
assert!(host < carried && carried < user);
assert!(out.contains("cards.@hooks"));
assert_eq!(out.matches("\"/etc/alsa/conf.d\"").count(), 2);
assert!(!patch_alsa_conf_hooks(&conf).unwrap());
assert_eq!(fs::read_to_string(&conf).unwrap(), out);
let plain = work.join("plain.conf");
fs::write(&plain, "pcm.default cards.pcm.default\n").unwrap();
assert!(!patch_alsa_conf_hooks(&plain).unwrap());
let _ = fs::remove_dir_all(&work);
}
#[test]
fn multiarch_and_store_layouts_are_both_served() {
let root = std::env::temp_dir().join(format!("onelf-libdata-arch-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let multiarch = root.join("usr/lib/x86_64-linux-gnu");
fs::create_dir_all(&multiarch).unwrap();
fs::create_dir_all(root.join("usr/share/alsa/cards")).unwrap();
fs::write(root.join("usr/share/alsa/alsa.conf"), b"conf").unwrap();
fs::write(root.join("usr/share/alsa/cards/aliases.conf"), b"cards").unwrap();
let tree = root.join("tree");
fs::create_dir_all(&tree).unwrap();
carry_soname_data(
&tree,
"libasound.so.2",
&multiarch.join("libasound.so.2"),
false,
);
assert_eq!(
fs::read_to_string(tree.join("share/alsa/alsa.conf")).unwrap(),
"conf"
);
assert!(tree.join("share/alsa/cards/aliases.conf").is_file());
let store = root.join("nix/store/x-xkb-data");
fs::create_dir_all(store.join("lib")).unwrap();
fs::create_dir_all(store.join("share/X11/xkb/symbols")).unwrap();
fs::write(store.join("share/X11/xkb/symbols/us"), b"us").unwrap();
let tree = root.join("store-tree");
fs::create_dir_all(&tree).unwrap();
carry_soname_data(
&tree,
"libxkbcommon.so.0",
&store.join("lib/libxkbcommon.so.0"),
false,
);
assert_eq!(
fs::read_to_string(tree.join("share/X11/xkb/symbols/us")).unwrap(),
"us"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn required_data_the_source_lacks_fails_the_assembly() {
let (root, lib) = dist_tree("required-miss");
let tree = root.join("tree");
fs::create_dir_all(&tree).unwrap();
let descriptor = onelf_format::catalog::builtins()
.iter()
.find(|d| d.triggers.iter().any(|t| t.matches_soname("libX11.so.6")))
.unwrap();
let admitted = [AdmittedLibrary::new("libX11.so.6", &lib)];
let mut plan = DestinationPlan::load(&tree).unwrap();
let mut actions: Vec<WalkAction> = Vec::new();
let declared = descriptor.resources.data.first().unwrap();
let mut preparation = SetPreparation::new(&[], &[], Path::new("lib"), false);
let err = carry_data_group(
&tree,
descriptor,
declared,
&admitted,
None,
&mut plan,
&mut actions,
&mut preparation,
&mut HashSet::new(),
)
.expect_err("a required data miss must fail the assembly");
let text = err.to_string();
assert!(text.contains("rule x11"), "{text}");
assert!(text.contains("requires directory data"), "{text}");
assert!(text.contains("share/X11/locale"), "{text}");
assert!(text.contains("absent"), "{text}");
assert!(actions.is_empty(), "{actions:?}");
assert!(!tree.join("share").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn optional_data_the_source_lacks_warns_and_continues() {
let root = std::env::temp_dir().join(format!("onelf-libdata-opt-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("usr/lib")).unwrap();
let lib = root.join("usr/lib/libgirepository-1.0.so.1");
fs::write(&lib, b"elf").unwrap();
let tree = root.join("tree");
fs::create_dir_all(&tree).unwrap();
let descriptor = onelf_format::catalog::builtins()
.iter()
.find(|d| {
d.triggers
.iter()
.any(|t| t.matches_soname("libgirepository-1.0.so.1"))
})
.unwrap();
let admitted = [AdmittedLibrary::new("libgirepository-1.0.so.1", &lib)];
let mut plan = DestinationPlan::load(&tree).unwrap();
let mut actions: Vec<WalkAction> = Vec::new();
let declared = descriptor.resources.data.first().unwrap();
let mut preparation = SetPreparation::new(&[], &[], Path::new("lib"), false);
carry_data_group(
&tree,
descriptor,
declared,
&admitted,
None,
&mut plan,
&mut actions,
&mut preparation,
&mut HashSet::new(),
)
.unwrap();
assert!(actions.is_empty(), "{actions:?}");
assert!(!plan.has_findings());
assert!(!tree.join("lib/girepository-1.0").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn only_the_owning_library_triggers_the_carry() {
let (root, lib) = dist_tree("owner");
let tree = root.join("tree");
fs::create_dir_all(&tree).unwrap();
carry_soname_data(&tree, "libxkbcommon-x11.so.0", &lib, false);
assert!(!tree.join("share/X11").exists());
carry_soname_data(&tree, "libpulse.so.0", &lib, false);
assert!(!tree.join("share/alsa").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn an_existing_carried_alsa_conf_still_gets_its_hooks_patched() {
let (root, lib) = dist_tree("existing-patch");
let tree = root.join("tree");
let alsa = tree.join("share/alsa");
fs::create_dir_all(alsa.join("alsa.conf.d")).unwrap();
fs::write(
alsa.join("alsa.conf"),
"@hooks [\n\t{ func load files [ \"/etc/alsa/conf.d\" ] }\n]\n",
)
.unwrap();
carry_soname_data(&tree, "libasound.so.2", &lib, false);
let out = fs::read_to_string(alsa.join("alsa.conf")).unwrap();
assert!(out.contains("{ @func datadir } \"/alsa.conf.d\""));
let before = out;
carry_soname_data(&tree, "libasound.so.2", &lib, false);
assert_eq!(fs::read_to_string(alsa.join("alsa.conf")).unwrap(), before);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_carried_magic_database_is_the_file_the_runtime_names() {
let root = std::env::temp_dir().join(format!("onelf-libdata-mg-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("usr/lib")).unwrap();
fs::create_dir_all(root.join("usr/share/file/misc")).unwrap();
fs::write(root.join("usr/share/file/misc/magic.mgc"), b"compiled").unwrap();
let lib = root.join("usr/lib/libmagic.so.1");
let tree = root.join("tree");
fs::create_dir_all(&tree).unwrap();
carry_soname_data(&tree, "libmagic.so.1", &lib, false);
assert_eq!(
fs::read(tree.join("share/misc/magic.mgc")).unwrap(),
b"compiled"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn the_first_existing_data_alternative_wins() {
let root = std::env::temp_dir().join(format!("onelf-libdata-alt-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("usr/lib")).unwrap();
fs::create_dir_all(root.join("usr/share/file/misc")).unwrap();
fs::create_dir_all(root.join("usr/share/misc")).unwrap();
fs::write(root.join("usr/share/file/misc/magic.mgc"), b"first").unwrap();
fs::write(root.join("usr/share/misc/magic.mgc"), b"second").unwrap();
let lib = root.join("usr/lib/libmagic.so.1");
let tree = root.join("tree");
fs::create_dir_all(&tree).unwrap();
carry_soname_data(&tree, "libmagic.so.1", &lib, false);
assert_eq!(
fs::read(tree.join("share/misc/magic.mgc")).unwrap(),
b"first"
);
assert!(!tree.join("share/file").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_later_data_alternative_serves_when_the_first_is_absent() {
let root = std::env::temp_dir().join(format!("onelf-libdata-alt2-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("usr/lib")).unwrap();
fs::create_dir_all(root.join("usr/share/misc")).unwrap();
fs::write(root.join("usr/share/misc/magic.mgc"), b"second").unwrap();
let lib = root.join("usr/lib/libmagic.so.1");
let tree = root.join("tree");
fs::create_dir_all(&tree).unwrap();
carry_soname_data(&tree, "libmagic.so.1", &lib, false);
assert_eq!(
fs::read(tree.join("share/misc/magic.mgc")).unwrap(),
b"second"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_stale_flat_plugin_migrates_with_its_aliases() {
let work = std::env::temp_dir().join(format!("onelf-migrate-{}", std::process::id()));
let _ = fs::remove_dir_all(&work);
let lib = work.join("lib");
fs::create_dir_all(&lib).unwrap();
let flat = lib.join("libasound_module_pcm_pulse.so");
fs::write(&flat, b"plugin").unwrap();
let alias = lib.join("libasound_module_pcm_pulse_alt.so");
std::os::unix::fs::symlink("libasound_module_pcm_pulse.so", &alias).unwrap();
fs::create_dir_all(lib.join("alsa-lib")).unwrap();
let deep = lib.join("alsa-lib/linked.so");
std::os::unix::fs::symlink("../libasound_module_pcm_pulse.so", &deep).unwrap();
fs::copy(&flat, lib.join("alsa-lib/libasound_module_pcm_pulse.so")).unwrap();
migrate_flat_plugin(
&flat,
Path::new("lib/alsa-lib/libasound_module_pcm_pulse.so"),
Path::new("lib"),
&lib,
false,
);
assert!(!flat.exists());
let moved = lib.join("alsa-lib/libasound_module_pcm_pulse.so");
assert_eq!(fs::read(&moved).unwrap(), b"plugin");
assert_eq!(
fs::read_link(&alias).unwrap(),
Path::new("../lib/alsa-lib/libasound_module_pcm_pulse.so")
);
assert_eq!(
fs::read_link(&deep).unwrap(),
Path::new("libasound_module_pcm_pulse.so")
);
let _ = fs::remove_dir_all(&work);
}
}
#[cfg(test)]
mod helper_tests {
use super::*;
use onelf_format::catalog;
fn fake_elf() -> Vec<u8> {
let mut v = vec![0u8; 64];
v[0..4].copy_from_slice(b"\x7fELF");
v[4] = 2;
v[18..20].copy_from_slice(&62u16.to_le_bytes());
v
}
fn scanner_declaration() -> HelperGroup {
catalog::builtins()
.iter()
.find(|d| d.id.as_str() == "gstreamer")
.unwrap()
.resources
.helpers
.first()
.unwrap()
.clone()
}
fn dist_tree(tag: &str, files: &[&str]) -> (PathBuf, PathBuf) {
let root = std::env::temp_dir().join(format!("onelf-helper-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let lib = root.join("usr/lib");
fs::create_dir_all(&lib).unwrap();
let gstreamer = lib.join("libgstreamer-1.0.so.0");
fs::write(&gstreamer, fake_elf()).unwrap();
for file in files {
let path = root.join(file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, fake_elf()).unwrap();
}
(root, gstreamer)
}
struct Collected {
destinations: HashMap<String, PathBuf>,
helpers: HashSet<String>,
discovery: Discovery,
}
fn collect(
tag: &str,
declared: &HelperGroup,
tree: &Path,
lib: &Path,
) -> io::Result<Collected> {
let admitted = [AdmittedLibrary::new("libgstreamer-1.0.so.0", lib)];
let mut discovery = Discovery::new(catalog::builtins(), &[], &[]).unwrap();
discovery.observe("libgstreamer-1.0.so.0").unwrap();
let mut destinations = HashMap::new();
let mut helpers = HashSet::new();
let mut needed_by = HashMap::new();
let mut processed = HashSet::new();
let mut search_paths = Vec::new();
let mut repair_extra = HashSet::new();
let mut preparation = SetPreparation::new(&[], &[], Path::new("lib"), false);
let mut plan = DestinationPlan::load(tree)?;
let descriptor = catalog::builtins()
.iter()
.find(|d| d.id.as_str() == "gstreamer")
.unwrap();
collect_helper_group(
tree,
descriptor,
declared,
&admitted,
None,
&mut search_paths,
&mut repair_extra,
&mut destinations,
&mut helpers,
&mut needed_by,
&mut processed,
&mut discovery,
&mut preparation,
&mut plan,
&mut HashSet::new(),
)?;
let _ = tag;
Ok(Collected {
destinations,
helpers,
discovery,
})
}
fn queued_objects(collected: &mut Collected) -> Vec<String> {
std::iter::from_fn(|| collected.discovery.pop())
.filter_map(|item| match item {
WorkItem::Object(soname) => Some(soname),
WorkItem::Group(_) => None,
})
.collect()
}
#[test]
fn a_collected_helper_enters_the_object_queue_at_its_destination() {
let (root, lib) = dist_tree("queue", &["usr/lib/gstreamer-1.0/gst-plugin-scanner"]);
let mut collected = collect("queue", &scanner_declaration(), &root, &lib).unwrap();
assert_eq!(
collected.destinations.get("gst-plugin-scanner"),
Some(&PathBuf::from("libexec/gstreamer-1.0/gst-plugin-scanner"))
);
assert!(collected.helpers.contains("gst-plugin-scanner"));
assert_eq!(queued_objects(&mut collected), ["gst-plugin-scanner"]);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn the_helper_source_directory_joins_the_search_path() {
let (root, lib) = dist_tree("searchdir", &["usr/lib/gstreamer-1.0/gst-plugin-scanner"]);
let admitted = [AdmittedLibrary::new("libgstreamer-1.0.so.0", &lib)];
let mut discovery = Discovery::new(catalog::builtins(), &[], &[]).unwrap();
let mut destinations = HashMap::new();
let mut helpers = HashSet::new();
let mut needed_by = HashMap::new();
let mut processed = HashSet::new();
let mut search_paths = Vec::new();
let mut repair_extra = HashSet::new();
let mut preparation = SetPreparation::new(&[], &[], Path::new("lib"), false);
let mut plan = DestinationPlan::load(&root).unwrap();
let descriptor = catalog::builtins()
.iter()
.find(|d| d.id.as_str() == "gstreamer")
.unwrap();
collect_helper_group(
&root,
descriptor,
&scanner_declaration(),
&admitted,
None,
&mut search_paths,
&mut repair_extra,
&mut destinations,
&mut helpers,
&mut needed_by,
&mut processed,
&mut discovery,
&mut preparation,
&mut plan,
&mut HashSet::new(),
)
.unwrap();
assert!(
search_paths.contains(&root.join("usr/lib/gstreamer-1.0")),
"{search_paths:?}"
);
assert_eq!(
needed_by.get("gst-plugin-scanner"),
Some(&"rule gstreamer helper scanner".to_string())
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_required_helper_the_source_lacks_fails_naming_the_locations() {
let (root, lib) = dist_tree("required-miss", &[]);
let err = match collect("required-miss", &scanner_declaration(), &root, &lib) {
Ok(_) => panic!("a required helper with no candidate must fail the assembly"),
Err(err) => err,
};
let text = err.to_string();
assert!(text.contains("rule gstreamer"), "{text}");
assert!(text.contains("helper scanner"), "{text}");
assert!(
text.contains("libexec/gstreamer-1.0/gst-plugin-scanner"),
"{text}"
);
assert!(text.contains("absent"), "{text}");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn an_optional_helper_the_source_lacks_continues_the_build() {
let (root, lib) = dist_tree("optional-miss", &[]);
let mut declared = scanner_declaration();
declared.requirement = Requirement::Optional;
let mut collected = collect("optional-miss", &declared, &root, &lib).unwrap();
assert!(collected.destinations.is_empty());
assert!(collected.helpers.is_empty());
assert!(
queued_objects(&mut collected).is_empty(),
"nothing is queued"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn the_first_declared_alternative_serves_the_helper() {
let (root, lib) = dist_tree(
"first-alt",
&[
"usr/lib/gstreamer-1.0/gst-plugin-scanner",
"usr/libexec/gstreamer-1.0/gst-plugin-scanner",
],
);
let mut collected = collect("first-alt", &scanner_declaration(), &root, &lib).unwrap();
assert_eq!(collected.destinations.len(), 1);
assert_eq!(
collected.helpers,
HashSet::from(["gst-plugin-scanner".to_string()])
);
assert_eq!(queued_objects(&mut collected).len(), 1);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_destination_the_tree_already_holds_satisfies_the_helper() {
let (root, lib) = dist_tree("tree-held", &[]);
let tree = root.join("pkg");
fs::create_dir_all(tree.join("libexec/gstreamer-1.0")).unwrap();
fs::write(
tree.join("libexec/gstreamer-1.0/gst-plugin-scanner"),
fake_elf(),
)
.unwrap();
let mut collected = collect("tree-held", &scanner_declaration(), &tree, &lib)
.expect("a held helper satisfies the group");
assert!(collected.destinations.is_empty());
assert!(collected.helpers.is_empty());
assert!(
queued_objects(&mut collected).is_empty(),
"the held helper is not requeued"
);
let _ = fs::remove_dir_all(&root);
}
}
#[cfg(test)]
mod dlopen_scan_tests {
use super::*;
#[test]
fn finds_sonames_merged_without_nul_separators() {
let blob = b"XcFileSavelibX11.so.6libX11.soXAc/usr/lib64libXi.so.6libXrender.so.1";
assert_eq!(
dlopen_hits(blob, &[]),
["libX11.so.6", "libXi.so.6", "libXrender.so.1"]
);
}
#[test]
fn the_gpu_stack_is_left_to_the_host() {
assert!(dlopen_hits(b"eglWaitSynclibEGL.so.1libEGL.solibGL.so.1", &[]).is_empty());
for name in DLOPEN_CANDIDATES {
assert!(
!onelf_format::drivers::DRIVER_FAMILIES
.iter()
.any(|f| name.starts_with(f)),
"{name} is a driver family"
);
}
}
#[test]
fn a_longer_version_is_not_the_candidate() {
assert!(dlopen_hits(b"\0libX11.so.60\0libX11.so.6.4.0\0", &[]).is_empty());
}
#[test]
fn an_unversioned_extra_still_matches() {
let extra = ["libvendor.so".to_string()];
assert_eq!(dlopen_hits(b"\0libvendor.so\0", &extra), ["libvendor.so"]);
}
#[test]
fn repeats_and_empty_extras_are_ignored() {
let extra = [String::new(), "libX11.so.6".to_string()];
assert_eq!(
dlopen_hits(b"libX11.so.6\0libX11.so.6", &extra),
["libX11.so.6"]
);
}
#[test]
fn an_extra_without_the_lib_prefix_matches() {
let extra = ["steamclient.so".to_string()];
assert_eq!(
dlopen_hits(b"\0steamclient.so\0", &extra),
["steamclient.so"]
);
}
}
#[cfg(test)]
mod framework_scan_tests {
use super::*;
fn scan(bytes: &[u8]) -> FrameworkFlags {
let mut flags = FrameworkFlags::default();
scan_framework_strings(bytes, &mut flags);
flags
}
#[test]
fn detects_versioned_soname_merged_without_nul_separators() {
let blob = b"eglWaitSynclibEGL.so.1libEGL.so";
let flags = scan(blob);
assert!(
flags.gl,
"versioned libEGL.so.1 inside a merged blob should flag gl"
);
}
#[test]
fn detects_versioned_wayland_in_merged_blob() {
let blob = b"some junklibwayland-client.so.0libwayland-client.so";
let flags = scan(blob);
assert!(flags.wayland);
}
#[test]
fn ignores_unversioned_soname_in_prose() {
let blob = b"Library libwayland-client.so could not be loaded.";
let flags = scan(blob);
assert!(
!flags.wayland,
"unversioned soname in prose must not flag wayland"
);
}
#[test]
fn ignores_bare_soname_without_version() {
let blob = b"\0libvulkan.so\0";
let flags = scan(blob);
assert!(
!flags.vulkan,
"bare libvulkan.so without a version must not flag"
);
}
#[test]
fn detects_nul_terminated_versioned_soname() {
let blob = b"\0libvulkan.so.1\0";
let flags = scan(blob);
assert!(flags.vulkan);
}
#[test]
fn classifies_glesv_stem_with_inner_version() {
let blob = b"\0libGLESv2.so.2\0";
let flags = scan(blob);
assert!(flags.gl, "libGLESv2.so.2 should flag gl");
}
#[test]
fn gtk_versioned_soname() {
let blob = b"prefixlibgtk-3.so.0suffix";
let flags = scan(blob);
assert!(flags.gtk);
}
}
#[cfg(test)]
mod correctness_tests {
use super::*;
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("onelf-bundle-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
fn fake_elf(class: u8, machine: u16) -> Vec<u8> {
let mut v = vec![0u8; 64];
v[0..4].copy_from_slice(b"\x7fELF");
v[4] = class;
v[18..20].copy_from_slice(&machine.to_le_bytes());
v
}
#[test]
fn nix_stub_matcher_requires_loader_shape() {
let d = tmp("stub");
let mut stub = b"\x7fELF".to_vec();
stub.extend_from_slice(b" ... NixOS cannot run this binary ... ");
let stub_path = d.join("ld-linux-x86-64.so.2");
fs::write(&stub_path, &stub).unwrap();
assert!(is_nix_stub_ld(&stub_path));
let benign = d.join("notes.txt");
fs::write(&benign, b"the NixOS cannot run message appears here").unwrap();
assert!(!is_nix_stub_ld(&benign));
let noelf = d.join("ld64.so.2");
fs::write(&noelf, b"NixOS cannot run").unwrap();
assert!(!is_nix_stub_ld(&noelf));
let musl = d.join("ld-musl-x86_64.so.1");
fs::write(&musl, &stub).unwrap();
assert!(!is_nix_stub_ld(&musl));
let _ = fs::remove_dir_all(&d);
}
#[test]
fn find_existing_libs_reports_stubs_without_deleting() {
let d = tmp("dryscan");
let mut stub = b"\x7fELF".to_vec();
stub.extend_from_slice(b" NixOS cannot run ");
let stub_path = d.join("ld-linux-x86-64.so.2");
fs::write(&stub_path, &stub).unwrap();
fs::write(d.join("libfoo.so.1"), fake_elf(2, 62)).unwrap();
let (libs, stubs) = find_existing_libs(&d);
assert!(stub_path.exists(), "the scan must not delete the stub");
assert!(stubs.iter().any(|p| p == &stub_path));
assert!(libs.contains("libfoo.so.1"));
assert!(!libs.contains("ld-linux-x86-64.so.2"));
let _ = fs::remove_dir_all(&d);
}
#[test]
fn runpath_entry_expands_origin() {
let origin = Path::new("/app/bin");
assert_eq!(
expand_runpath_entry("$ORIGIN/../lib", origin),
PathBuf::from("/app/bin/../lib")
);
assert_eq!(
expand_runpath_entry("${ORIGIN}/lib", origin),
PathBuf::from("/app/bin/lib")
);
assert_eq!(
expand_runpath_entry("/usr/lib", origin),
PathBuf::from("/usr/lib")
);
}
#[test]
fn runpath_splits_colons_and_resolves_origin() {
let d = tmp("rpath");
fs::create_dir_all(d.join("a/lib")).unwrap();
fs::create_dir_all(d.join("a/lib64")).unwrap();
fs::create_dir_all(d.join("a/bin")).unwrap();
let list = format!(
"{}:{}",
d.join("a/lib").display(),
d.join("a/lib64").display()
);
let dirs = resolve_runpath_dirs(std::iter::once(list.as_str()), Path::new("/"));
assert!(dirs.contains(&d.join("a/lib")));
assert!(dirs.contains(&d.join("a/lib64")));
let origin = d.join("a/bin");
let dirs = resolve_runpath_dirs(std::iter::once("$ORIGIN/../lib"), &origin);
assert_eq!(dirs.len(), 1);
assert_eq!(
fs::canonicalize(&dirs[0]).unwrap(),
fs::canonicalize(d.join("a/lib")).unwrap()
);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn copy_dedupes_after_filters_and_honors_arch_and_excludes() {
let d = tmp("copy");
let dir1 = d.join("dir1");
let dir2 = d.join("dir2");
let dest = d.join("dest");
fs::create_dir_all(&dir1).unwrap();
fs::create_dir_all(&dir2).unwrap();
std::os::unix::fs::symlink(d.join("nonexistent"), dir1.join("libGL.so.1")).unwrap();
fs::write(dir2.join("libGL.so.1"), fake_elf(2, 62)).unwrap();
fs::write(dir2.join("libwrong.so"), fake_elf(2, 183)).unwrap();
let (copied, _) = copy_prefixed_libs(
&[dir1.clone(), dir2.clone()],
&["libGL", "libwrong"],
&dest,
Some(2), Some(62), &[],
false,
false,
)
.unwrap();
assert!(
dest.join("libGL.so.1").is_file(),
"the valid libGL must not be shadowed by the earlier dangling one"
);
assert!(
!dest.join("libwrong.so").exists(),
"a wrong-arch library must be rejected"
);
assert_eq!(copied, 1);
let dest2 = d.join("dest2");
let (copied2, _) = copy_prefixed_libs(
std::slice::from_ref(&dir2),
&["libGL"],
&dest2,
Some(2),
Some(62),
&["libGL"],
false,
false,
)
.unwrap();
assert_eq!(copied2, 0, "an excluded soname must not be copied");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn bootstrap_alignment_is_16k_safe_on_aarch64() {
assert_eq!(bootstrap_page_align(true), 0x10000);
assert_eq!(bootstrap_page_align(false), 0x1000);
let align = bootstrap_page_align(true);
for (vend, len) in [(0x1234u64, 0x5678u64), (0, 1), (0xffff, 0x10001)] {
let new_vaddr = (vend + align - 1) & !(align - 1);
let file_off = (len + align - 1) & !(align - 1);
for page in [0x1000u64, 0x4000, 0x10000] {
assert_eq!(new_vaddr % page, file_off % page);
}
}
}
#[test]
fn copy_does_not_truncate_a_file_onto_itself() {
let d = tmp("selfcopy");
fs::write(d.join("libself.so.1"), fake_elf(2, 62)).unwrap();
let before = fs::read(d.join("libself.so.1")).unwrap();
assert!(!before.is_empty());
let (_copied, _) = copy_prefixed_libs(
std::slice::from_ref(&d),
&["libself"],
&d, Some(2),
Some(62),
&[],
false,
false,
)
.unwrap();
let after = fs::read(d.join("libself.so.1")).unwrap();
assert_eq!(before, after, "a self-copy must not truncate the file");
let _ = fs::remove_dir_all(&d);
}
}
#[cfg(test)]
mod mtime_tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn read_only_files_and_directories_are_pinned() {
let dir = std::env::temp_dir().join(format!("onelf-mtime-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("sub")).unwrap();
let file = dir.join("ro");
fs::write(&file, b"x").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o444)).unwrap();
let expected = std::env::var("SOURCE_DATE_EPOCH")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(0);
for path in [&file, &dir.join("sub")] {
normalize_mtime(path);
let mtime = fs::metadata(path)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
assert_eq!(mtime, expected, "{}", path.display());
}
let _ = fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod dangling_link_tests {
use super::*;
#[test]
fn only_links_that_led_to_the_dropped_object_go() {
let dir = std::env::temp_dir().join(format!("onelf-dangling-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("libfoo.so.1.2"), b"x").unwrap();
std::os::unix::fs::symlink("libfoo.so.1.2", dir.join("libfoo.so.1")).unwrap();
std::os::unix::fs::symlink("libfoo.so.1", dir.join("libfoo.so")).unwrap();
std::os::unix::fs::symlink("libbar.so.9", dir.join("libbar.so")).unwrap();
fs::remove_file(dir.join("libfoo.so.1.2")).unwrap();
remove_dangling_links_beside(&dir.join("libfoo.so.1.2"));
assert!(!dir.join("libfoo.so.1").is_symlink(), "the chain goes");
assert!(!dir.join("libfoo.so").is_symlink(), "the whole chain goes");
assert!(
dir.join("libbar.so").is_symlink(),
"an unrelated link stays"
);
let _ = fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod private_temp_dir_tests {
use super::*;
#[test]
fn each_scratch_directory_is_new_and_private() {
let a = private_temp_dir("probe").unwrap();
let b = private_temp_dir("probe").unwrap();
assert_ne!(a, b);
for dir in [&a, &b] {
let meta = fs::symlink_metadata(dir).unwrap();
assert!(meta.is_dir());
assert_eq!(meta.permissions().mode() & 0o777, 0o700);
}
let _ = fs::remove_dir(&a);
let _ = fs::remove_dir(&b);
}
}
#[cfg(test)]
mod gpu_stack_tests {
use super::*;
#[test]
fn a_traced_gpu_stack_is_left_to_the_host() {
let paths = [
"/usr/lib/libGLX_mesa.so.0",
"/usr/lib/libEGL_nvidia.so.0",
"/usr/lib/libgallium-25.2.so",
"/usr/lib/dri/radeonsi_dri.so",
"/usr/lib/libvulkan_radeon.so",
"/usr/lib/libSDL2-2.0.so.0",
];
let (app, host) = split_gpu_stack(paths.iter().map(PathBuf::from).collect());
assert_eq!(app, vec![PathBuf::from("/usr/lib/libSDL2-2.0.so.0")]);
assert_eq!(host.len(), 5);
}
}
#[cfg(test)]
mod locale_tests {
use super::*;
#[test]
fn c_utf8_is_carried_from_beside_a_multiarch_libc() {
let work = std::env::temp_dir().join(format!("onelf-cutf8-{}", std::process::id()));
let _ = fs::remove_dir_all(&work);
let libc_dir = work.join("usr/lib/x86_64-linux-gnu");
let locale = work.join("usr/lib/locale/C.utf8");
fs::create_dir_all(&libc_dir).unwrap();
fs::create_dir_all(locale.join("LC_MESSAGES")).unwrap();
fs::write(libc_dir.join("libc.so.6"), "").unwrap();
fs::write(locale.join("LC_CTYPE"), "ctype").unwrap();
fs::write(locale.join("LC_MESSAGES/SYS_LC_MESSAGES"), "msg").unwrap();
let dest = work.join("tree/lib");
let copied = bundle_c_utf8(&libc_dir.join("libc.so.6"), &dest).unwrap();
assert_eq!(
copied,
[
"locale/C.utf8/LC_CTYPE",
"locale/C.utf8/LC_MESSAGES/SYS_LC_MESSAGES"
]
);
assert_eq!(
fs::read_to_string(dest.join("locale/C.utf8/LC_CTYPE")).unwrap(),
"ctype"
);
let _ = fs::remove_dir_all(&work);
}
}
#[cfg(test)]
mod catalog_coverage_tests {
use std::collections::BTreeSet;
use onelf_format::catalog;
use onelf_format::rules::{Inheritance, PathSelection, RuntimeHookId, SCHEMA_VERSION};
use super::*;
struct Family {
name: &'static str,
descriptors: &'static [&'static str],
plugins: &'static [(&'static str, &'static str, &'static str, Requirement)],
data: &'static [(&'static str, &'static str, &'static str, Requirement)],
helpers: &'static [(&'static str, &'static str, &'static str, Requirement)],
paths: &'static [(&'static str, PathSelection, Inheritance)],
runtime_hooks: &'static [RuntimeHookId],
build_hooks: &'static [BuildHookId],
sonames: &'static [&'static str],
}
const FAMILIES: &[Family] = &[
Family {
name: "alsa",
descriptors: &["alsa"],
plugins: &[],
data: &[("alsa", "configuration", "share/alsa", Requirement::Required)],
helpers: &[],
paths: &[
(
"ALSA_PLUGIN_DIR",
PathSelection::First,
Inheritance::Replace,
),
(
"ALSA_CONFIG_DIR",
PathSelection::First,
Inheritance::Replace,
),
],
runtime_hooks: &[RuntimeHookId::AlsaConfigPathNaturalSort],
build_hooks: &[BuildHookId::AlsaConfPatch],
sonames: &["libasound.so.2"],
},
Family {
name: "gstreamer",
descriptors: &["gstreamer"],
plugins: &[(
"gstreamer",
"plugins",
"lib/gstreamer-1.0",
Requirement::Optional,
)],
data: &[],
helpers: &[(
"gstreamer",
"scanner",
"libexec/gstreamer-1.0/gst-plugin-scanner",
Requirement::Required,
)],
paths: &[
(
"GST_PLUGIN_SYSTEM_PATH_1_0",
PathSelection::All,
Inheritance::Replace,
),
(
"GST_PLUGIN_SYSTEM_PATH",
PathSelection::All,
Inheritance::Replace,
),
(
"GST_PLUGIN_SCANNER_1_0",
PathSelection::First,
Inheritance::Replace,
),
(
"GST_PLUGIN_SCANNER",
PathSelection::First,
Inheritance::Replace,
),
],
runtime_hooks: &[],
build_hooks: &[],
sonames: &["libgstreamer-1.0.so.0"],
},
Family {
name: "pipewire-spa",
descriptors: &["pipewire", "spa"],
plugins: &[
(
"pipewire",
"plugins",
"lib/pipewire-0.3",
Requirement::Optional,
),
("spa", "plugins", "lib/spa-0.2", Requirement::Optional),
],
data: &[
(
"pipewire",
"configuration",
"share/pipewire/client.conf",
Requirement::Optional,
),
(
"pipewire",
"configuration-rt",
"share/pipewire/client-rt.conf",
Requirement::Optional,
),
],
helpers: &[],
paths: &[
(
"PIPEWIRE_MODULE_DIR",
PathSelection::All,
Inheritance::Replace,
),
(
"PIPEWIRE_CONFIG_DIR",
PathSelection::First,
Inheritance::Preserve,
),
("SPA_PLUGIN_DIR", PathSelection::All, Inheritance::Replace),
],
runtime_hooks: &[],
build_hooks: &[],
sonames: &["libpipewire-0.3.so.0", "libspa-0.2.so.0"],
},
Family {
name: "gio-gi",
descriptors: &["gio", "gi"],
plugins: &[("gio", "modules", "lib/gio/modules", Requirement::Optional)],
data: &[(
"gi",
"typelibs",
"lib/girepository-1.0",
Requirement::Optional,
)],
helpers: &[],
paths: &[("GI_TYPELIB_PATH", PathSelection::All, Inheritance::Replace)],
runtime_hooks: &[RuntimeHookId::GioModulePaths],
build_hooks: &[],
sonames: &["libgio-2.0.so.0", "libgirepository-1.0.so.1"],
},
Family {
name: "xkb-x11",
descriptors: &["xkb", "x11"],
plugins: &[],
data: &[
("xkb", "data", "share/X11/xkb", Requirement::Required),
("x11", "data", "share/X11/locale", Requirement::Required),
],
helpers: &[],
paths: &[
(
"XKB_CONFIG_ROOT",
PathSelection::First,
Inheritance::Preserve,
),
("XLOCALEDIR", PathSelection::First, Inheritance::Preserve),
],
runtime_hooks: &[],
build_hooks: &[],
sonames: &["libxkbcommon.so.0", "libX11.so.6"],
},
Family {
name: "magic",
descriptors: &["magic"],
plugins: &[],
data: &[(
"magic",
"database",
"share/misc/magic.mgc",
Requirement::Required,
)],
helpers: &[],
paths: &[("MAGIC", PathSelection::First, Inheritance::Preserve)],
runtime_hooks: &[],
build_hooks: &[],
sonames: &["libmagic.so.1"],
},
Family {
name: "heif",
descriptors: &["heif"],
plugins: &[],
data: &[],
helpers: &[],
paths: &[(
"LIBHEIF_PLUGIN_PATH",
PathSelection::FirstPerLibraryRoot,
Inheritance::Replace,
)],
runtime_hooks: &[],
build_hooks: &[],
sonames: &["libheif.so.1"],
},
Family {
name: "babl-gegl",
descriptors: &["babl", "gegl"],
plugins: &[
("babl", "plugins", "lib/babl-0.1", Requirement::Optional),
("gegl", "plugins", "lib/gegl-0.4", Requirement::Optional),
],
data: &[],
helpers: &[],
paths: &[
("BABL_PATH", PathSelection::All, Inheritance::Replace),
("GEGL_PATH", PathSelection::All, Inheritance::Replace),
],
runtime_hooks: &[],
build_hooks: &[],
sonames: &["libbabl-0.1.so.0", "libgegl-0.4.so.0"],
},
Family {
name: "tcl-tk",
descriptors: &["tcl-8.6", "tcl-9.0", "tk-8.6", "tk-9.0", "tcl-tk"],
plugins: &[],
data: &[
("tcl-8.6", "scripts", "lib/tcl8.6", Requirement::Required),
("tcl-9.0", "scripts", "lib/tcl9.0", Requirement::Required),
("tk-8.6", "scripts", "lib/tk8.6", Requirement::Required),
("tk-9.0", "scripts", "lib/tk9.0", Requirement::Required),
],
helpers: &[],
paths: &[],
runtime_hooks: &[RuntimeHookId::TclTkLibraries],
build_hooks: &[],
sonames: &[
"libtcl8.6.so",
"libtcl9.0.so",
"libtk8.6.so",
"libtcl9tk9.0.so",
],
},
Family {
name: "translations",
descriptors: &["translations"],
plugins: &[],
data: &[],
helpers: &[],
paths: &[("TEXTDOMAINDIR", PathSelection::First, Inheritance::Preserve)],
runtime_hooks: &[],
build_hooks: &[],
sonames: &[],
},
Family {
name: "qt-gdk-gtk",
descriptors: &["qt", "qt5", "qt6", "gdk-pixbuf", "gtk3"],
plugins: &[
("qt5", "plugins", "lib/qt5/plugins", Requirement::Optional),
("qt6", "plugins", "lib/qt6/plugins", Requirement::Optional),
(
"gdk-pixbuf",
"loaders",
"lib/gdk-pixbuf-2.0",
Requirement::Optional,
),
("gtk3", "modules", "lib/gtk-3.0", Requirement::Optional),
],
data: &[],
helpers: &[],
paths: &[
("QT_PLUGIN_PATH", PathSelection::All, Inheritance::Replace),
("GTK_PATH", PathSelection::All, Inheritance::Replace),
],
runtime_hooks: &[RuntimeHookId::GdkCacheAbsolutise],
build_hooks: &[],
sonames: &[
"libQt5Core.so.5",
"libQt6Core.so.6",
"libgdk_pixbuf-2.0.so.0",
"libgtk-3.so.0",
],
},
Family {
name: "fontconfig",
descriptors: &["fontconfig"],
plugins: &[],
data: &[],
helpers: &[],
paths: &[],
runtime_hooks: &[RuntimeHookId::FontconfigGenerate],
build_hooks: &[],
sonames: &["libfontconfig.so.1"],
},
];
fn descriptor(id: &str) -> &'static Descriptor {
catalog::builtins()
.iter()
.find(|d| d.id.as_str() == id)
.unwrap_or_else(|| panic!("descriptor {id} is missing from the catalog"))
}
#[test]
fn the_catalog_is_exactly_the_families_descriptors() {
let expected: BTreeSet<&str> = FAMILIES
.iter()
.flat_map(|family| family.descriptors.iter().copied())
.collect();
let actual: BTreeSet<&str> = catalog::builtins().iter().map(|d| d.id.as_str()).collect();
assert_eq!(actual, expected, "catalog drift against the families");
for descriptor in catalog::builtins() {
assert_eq!(
descriptor.schema,
SCHEMA_VERSION,
"{}",
descriptor.id.as_str()
);
descriptor.validate().expect("built-ins validate");
}
}
#[test]
fn every_family_declares_its_groups_mappings_and_hooks() {
for family in FAMILIES {
let mut plugins: Vec<String> = Vec::new();
let mut data: Vec<String> = Vec::new();
let mut helpers: Vec<String> = Vec::new();
let mut paths: Vec<(String, PathSelection, Inheritance)> = Vec::new();
let mut runtime_hooks: Vec<RuntimeHookId> = Vec::new();
let mut build_hooks: Vec<BuildHookId> = Vec::new();
for id in family.descriptors {
let descriptor = descriptor(id);
for group in &descriptor.resources.plugins {
plugins.push(format!(
"{}/{}/{} {:?}",
id,
group.id.as_str(),
group.destination.as_str(),
group.requirement
));
}
for group in &descriptor.resources.data {
data.push(format!(
"{}/{}/{} {:?}",
id,
group.id.as_str(),
group.destination.as_str(),
group.requirement
));
}
for helper in &descriptor.resources.helpers {
helpers.push(format!(
"{}/{}/{} {:?}",
id,
helper.id.as_str(),
helper.destination.as_str(),
helper.requirement
));
}
for path in &descriptor.runtime.paths {
for variable in &path.variables {
paths.push((
variable.as_str().to_string(),
path.selection,
path.inheritance,
));
}
}
runtime_hooks.extend(descriptor.runtime.hooks.iter().copied());
build_hooks.extend(descriptor.build.hooks.iter().copied());
}
let want_plugins = family
.plugins
.iter()
.map(|(rule, group, dest, requirement)| {
format!("{rule}/{group}/{dest} {requirement:?}")
})
.collect::<Vec<_>>();
let want_data = family
.data
.iter()
.map(|(rule, group, dest, requirement)| {
format!("{rule}/{group}/{dest} {requirement:?}")
})
.collect::<Vec<_>>();
let want_helpers = family
.helpers
.iter()
.map(|(rule, group, dest, requirement)| {
format!("{rule}/{group}/{dest} {requirement:?}")
})
.collect::<Vec<_>>();
let want_paths = family
.paths
.iter()
.map(|(v, s, i)| ((*v).to_string(), *s, *i))
.collect::<Vec<_>>();
assert_eq!(plugins, want_plugins, "{} plugin groups", family.name);
assert_eq!(data, want_data, "{} data groups", family.name);
assert_eq!(helpers, want_helpers, "{} helper groups", family.name);
assert_eq!(paths, want_paths, "{} runtime mappings", family.name);
assert_eq!(runtime_hooks, family.runtime_hooks, "{}", family.name);
assert_eq!(build_hooks, family.build_hooks, "{}", family.name);
}
}
#[test]
fn every_family_activates_from_its_sonames_and_queues_its_groups() {
for family in FAMILIES {
let mut discovery = Discovery::new(catalog::builtins(), &[], &[]).unwrap();
for soname in family.sonames {
discovery.observe(soname).unwrap();
}
let activated: Vec<RuleActivation> = discovery
.activations()
.into_iter()
.filter(|a| family.descriptors.contains(&a.rule.as_str()))
.collect();
let mut want: Vec<&str> = family
.descriptors
.iter()
.copied()
.filter(|id| !descriptor(id).triggers.is_empty())
.collect();
let mut got: Vec<&str> = activated
.iter()
.map(|a| a.rule.as_str())
.collect::<Vec<_>>();
want.sort_unstable();
got.sort_unstable();
assert_eq!(got, want, "{} activation", family.name);
for activation in &activated {
let d = descriptor(&activation.rule);
let declared: Vec<String> = d
.resources
.plugins
.iter()
.map(|g| g.id.as_str().to_string())
.chain(d.resources.data.iter().map(|g| g.id.as_str().to_string()))
.chain(
d.resources
.helpers
.iter()
.map(|g| g.id.as_str().to_string()),
)
.collect();
assert_eq!(activation.groups, declared, "{}", activation.rule);
assert!(
d.triggers
.iter()
.any(|t| t.matches_soname(&activation.reason)),
"{} activated by {} which matches no trigger",
activation.rule,
activation.reason
);
}
}
}
#[test]
fn the_registry_holds_exactly_the_catalog_declared_build_hooks() {
let declared: BTreeSet<BuildHookId> = catalog::builtins()
.iter()
.flat_map(|d| d.build.hooks.iter().copied())
.collect();
assert_eq!(declared, BTreeSet::from([BuildHookId::AlsaConfPatch]));
let _ = staged_hook(BuildHookId::AlsaConfPatch);
}
#[test]
fn every_declared_build_hook_dispatches_and_honors_a_dry_run() {
let work = std::env::temp_dir().join(format!("onelf-hook-dispatch-{}", std::process::id()));
let _ = fs::remove_dir_all(&work);
let tree = work.join("tree");
let conf = tree.join("share/alsa/alsa.conf");
fs::create_dir_all(conf.parent().unwrap()).unwrap();
fs::write(
&conf,
"@hooks [\n\t{\n\t\tfiles [\n\t\t\t\"/etc/alsa/conf.d\"\n\t\t]\n\t}\n]\n",
)
.unwrap();
run_staged_hooks(&[BuildHookId::AlsaConfPatch], &tree, true);
assert_eq!(
fs::read_to_string(&conf).unwrap(),
"@hooks [\n\t{\n\t\tfiles [\n\t\t\t\"/etc/alsa/conf.d\"\n\t\t]\n\t}\n]\n",
"a dry run writes nothing"
);
run_staged_hooks(&[BuildHookId::AlsaConfPatch], &tree, false);
let patched = fs::read_to_string(&conf).unwrap();
let host = patched.find("\"/etc/alsa/conf.d\"").unwrap();
let carried = patched
.find("{ @func datadir } \"/alsa.conf.d\"")
.expect("the carried snippet directory is anchored");
assert!(host < carried, "{patched}");
let _ = fs::remove_dir_all(&work);
}
const RUNTIME_ASSEMBLED: &[&str] = &[
"gio",
"gtk3",
"gdk-pixbuf",
"gi",
"babl",
"gegl",
"qt5",
"qt6",
];
#[test]
fn every_remaining_resource_section_is_beyond_inference() {
for descriptor in catalog::builtins() {
let id = descriptor.id.as_str();
let assembled = RUNTIME_ASSEMBLED.contains(&id);
for (group, kind) in descriptor
.resources
.plugins
.iter()
.map(|g| (g, "plugins"))
.chain(descriptor.resources.data.iter().map(|g| (g, "data")))
{
let hosts_helper = descriptor
.resources
.helpers
.iter()
.any(|helper| helper.group == group.id);
let beyond = group.kind == ResourceKind::File
|| group.requirement == Requirement::Required
|| !group.members.is_empty()
|| assembled
|| hosts_helper;
assert!(
beyond,
"{id}/{} {}: inference reproduces this section, so the \
descriptor must not carry it",
kind,
group.id.as_str()
);
}
for helper in &descriptor.resources.helpers {
assert!(
helper.kind == ResourceKind::File,
"{id}/{}: a helper is a file, which inference never carries",
helper.id.as_str()
);
}
}
}
}
#[cfg(test)]
mod provider_audit_tests {
use super::*;
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("onelf-audit-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
fn dyn_object(needed: &[&str], soname: Option<&str>) -> Vec<u8> {
let mut strtab = vec![0u8];
let offset = |name: &str, strtab: &mut Vec<u8>| {
let at = strtab.len();
strtab.extend_from_slice(name.as_bytes());
strtab.push(0);
at as u64
};
let mut dyns: Vec<(u64, u64)> = Vec::new();
dyns.push((5, 0));
dyns.push((10, 0));
for name in needed {
let at = offset(name, &mut strtab);
dyns.push((1, at));
}
if let Some(name) = soname {
let at = offset(name, &mut strtab);
dyns.push((14, at));
}
dyns.push((0, 0));
let dyn_off = 64 + 2 * 56;
let dyn_size = dyns.len() * 16;
let str_off = dyn_off + dyn_size;
let total = str_off + strtab.len();
dyns[0].1 = str_off as u64;
dyns[1].1 = strtab.len() as u64;
let mut elf = Vec::new();
elf.extend_from_slice(b"\x7fELF");
elf.extend_from_slice(&[2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
elf.extend_from_slice(&3u16.to_le_bytes());
elf.extend_from_slice(&62u16.to_le_bytes());
elf.extend_from_slice(&1u32.to_le_bytes());
elf.extend_from_slice(&0u64.to_le_bytes());
elf.extend_from_slice(&64u64.to_le_bytes());
elf.extend_from_slice(&0u64.to_le_bytes());
elf.extend_from_slice(&0u32.to_le_bytes());
elf.extend_from_slice(&64u16.to_le_bytes());
elf.extend_from_slice(&56u16.to_le_bytes());
elf.extend_from_slice(&(2u16.to_le_bytes()));
elf.extend_from_slice(&[0u8; 6]);
elf.extend_from_slice(&1u32.to_le_bytes());
elf.extend_from_slice(&6u32.to_le_bytes());
elf.extend_from_slice(&0u64.to_le_bytes());
elf.extend_from_slice(&0u64.to_le_bytes());
elf.extend_from_slice(&0u64.to_le_bytes());
elf.extend_from_slice(&(total as u64).to_le_bytes());
elf.extend_from_slice(&(total as u64).to_le_bytes());
elf.extend_from_slice(&0x1000u64.to_le_bytes());
elf.extend_from_slice(&2u32.to_le_bytes());
elf.extend_from_slice(&6u32.to_le_bytes());
for _ in 0..3 {
elf.extend_from_slice(&(dyn_off as u64).to_le_bytes());
}
elf.extend_from_slice(&(dyn_size as u64).to_le_bytes());
elf.extend_from_slice(&(dyn_size as u64).to_le_bytes());
elf.extend_from_slice(&8u64.to_le_bytes());
for (tag, value) in &dyns {
elf.extend_from_slice(&tag.to_le_bytes());
elf.extend_from_slice(&value.to_le_bytes());
}
elf.extend_from_slice(&strtab);
assert_eq!(elf.len(), total);
assert_eq!(parse_needed_bytes(&elf).unwrap(), needed);
let parsed = goblin::elf::Elf::parse(&elf).unwrap();
assert_eq!(parsed.soname, soname);
elf
}
fn content(id: u8) -> [u8; 32] {
let mut hash = [0u8; 32];
hash[0] = id;
hash
}
fn provider(set: &str, pin: u64, exports: &[(&str, u8)]) -> sysroot::SetProvider {
sysroot::SetProvider {
set: set.to_string(),
pin: format!("{pin:064x}"),
provides: exports.iter().map(|(s, _)| s.to_string()).collect(),
exports: exports
.iter()
.map(|(soname, id)| sysroot::SetExport {
soname: soname.to_string(),
blake3: content(*id),
})
.collect(),
}
}
fn entrypoint(
name: &str,
path: Option<&str>,
selection: recipe::SetSelection,
) -> AuditedEntrypoint {
AuditedEntrypoint {
name: name.to_string(),
path: path.map(PathBuf::from),
selection,
}
}
fn names(list: &[&str]) -> recipe::SetSelection {
recipe::SetSelection::Names(list.iter().map(|s| s.to_string()).collect())
}
fn no_host(_soname: &str) -> bool {
false
}
fn tree_exports(root: &Path) -> Vec<sysroot::SetExport> {
let mut found: Vec<sysroot::SetExport> = jwalk::WalkDir::new(root)
.skip_hidden(false)
.sort(true)
.into_iter()
.flatten()
.filter(|e| e.file_type().is_file())
.filter_map(|e| {
let data = fs::read(e.path()).ok()?;
let elf = goblin::elf::Elf::parse(&data).ok()?;
let soname = elf.soname?;
Some(sysroot::SetExport {
soname: soname.to_string(),
blake3: *blake3::hash(&data).as_bytes(),
})
})
.collect();
found.sort_by(|a, b| (&a.soname, &a.blake3).cmp(&(&b.soname, &b.blake3)));
found.dedup();
found
}
#[test]
fn a_gui_only_set_cannot_satisfy_the_cli_needs() {
let d = tmp("gui-cli");
fs::create_dir_all(d.join("bin")).unwrap();
fs::write(d.join("bin/cli"), dyn_object(&["libshared.so.1"], None)).unwrap();
fs::write(d.join("bin/gui"), dyn_object(&["libshared.so.1"], None)).unwrap();
let providers = vec![
provider("cli", 1, &[("libcli.so.1", 1)]),
provider("gui", 2, &[("libshared.so.1", 1)]),
];
let entrypoints = vec![
entrypoint("cli", Some("bin/cli"), names(&["cli"])),
entrypoint("gui", Some("bin/gui"), names(&["gui"])),
];
let err =
audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &providers, &no_host)
.unwrap_err();
let text = err.to_string();
assert!(text.contains("entrypoint 'cli'"), "{text}");
assert!(text.contains("libshared.so.1"), "{text}");
assert!(text.contains("which gui exports"), "{text}");
assert!(!text.contains("entrypoint 'gui'"), "{text}");
let entrypoints = vec![
entrypoint("cli", Some("bin/cli"), names(&["cli", "gui"])),
entrypoint("gui", Some("bin/gui"), names(&["gui"])),
];
audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &providers, &no_host)
.unwrap();
let _ = fs::remove_dir_all(&d);
}
#[test]
fn an_exports_free_provider_answers_its_own_selection_alone() {
let d = tmp("exports-free");
fs::create_dir_all(d.join("bin")).unwrap();
fs::write(d.join("bin/cli"), dyn_object(&["libshared.so.1"], None)).unwrap();
let providers = vec![
sysroot::SetProvider {
set: "alpha".to_string(),
pin: format!("{:064x}", 1u64),
provides: vec!["libshared.so.1".to_string()],
exports: Vec::new(),
},
sysroot::SetProvider {
set: "beta".to_string(),
pin: format!("{:064x}", 2u64),
provides: vec!["libbeta.so.1".to_string()],
exports: Vec::new(),
},
];
let entrypoints = vec![entrypoint("cli", Some("bin/cli"), names(&["beta"]))];
let err =
audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &providers, &no_host)
.unwrap_err();
let text = err.to_string();
assert!(text.contains("entrypoint 'cli'"), "{text}");
assert!(text.contains("bin/cli needs libshared.so.1"), "{text}");
assert!(text.contains("which alpha exports"), "{text}");
assert!(!text.contains("entrypoint 'beta'"), "{text}");
let entrypoints = vec![entrypoint(
"cli",
Some("bin/cli"),
names(&["alpha", "beta"]),
)];
audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &providers, &no_host)
.unwrap();
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_selected_provider_that_claims_nothing_satisfies_nothing() {
let d = tmp("claims-nothing");
fs::create_dir_all(d.join("bin")).unwrap();
fs::write(d.join("bin/app"), dyn_object(&["libshared.so.1"], None)).unwrap();
let providers = vec![sysroot::SetProvider {
set: "opaque".to_string(),
pin: format!("{:064x}", 1u64),
provides: Vec::new(),
exports: Vec::new(),
}];
let entrypoints = vec![entrypoint("app", Some("bin/app"), names(&["opaque"]))];
let err =
audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &providers, &no_host)
.unwrap_err();
let text = err.to_string();
assert!(text.contains("bin/app needs libshared.so.1"), "{text}");
assert!(text.contains("nothing in the application"), "{text}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn identical_exports_deduplicate() {
let d = tmp("dedup");
fs::create_dir_all(d.join("bin")).unwrap();
fs::write(d.join("bin/app"), dyn_object(&["libshared.so.1"], None)).unwrap();
let providers = vec![
provider("alpha", 1, &[("libshared.so.1", 1)]),
provider("beta", 1, &[("libshared.so.1", 1)]),
];
let entrypoints = vec![entrypoint(
"app",
Some("bin/app"),
recipe::SetSelection::All,
)];
audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &providers, &no_host)
.unwrap();
let _ = fs::remove_dir_all(&d);
}
#[test]
fn differing_simultaneous_exports_fail_naming_both_sets() {
let d = tmp("diff");
fs::create_dir_all(d.join("bin")).unwrap();
fs::write(d.join("bin/app"), dyn_object(&["libshared.so.1"], None)).unwrap();
let providers = vec![
provider("beta", 2, &[("libshared.so.1", 2)]),
provider("alpha", 1, &[("libshared.so.1", 1)]),
];
let entrypoints = vec![entrypoint(
"app",
Some("bin/app"),
recipe::SetSelection::All,
)];
let err =
audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &providers, &no_host)
.unwrap_err();
let text = err.to_string();
assert!(text.contains("entrypoint 'app'"), "{text}");
assert!(text.contains("libshared.so.1"), "{text}");
assert!(text.contains("alpha"), "{text}");
assert!(text.contains("beta"), "{text}");
assert!(text.contains("exported with different bytes"), "{text}");
assert!(text.contains("cannot be selected together"), "{text}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn the_drop_pass_keeps_named_entrypoints_and_required_objects() {
let d = tmp("protect");
fs::create_dir_all(d.join("bin")).unwrap();
fs::create_dir_all(d.join("lib/plugins")).unwrap();
fs::create_dir_all(d.join("libexec")).unwrap();
let unmet = dyn_object(&["libgone.so.1"], None);
for rel in ["bin/gui", "lib/plugins/auto.so", "libexec/helper"] {
fs::write(d.join(rel), &unmet).unwrap();
}
sysroot::record_generated(
&d,
vec![
"bin/gui".to_string(),
"lib/plugins/auto.so".to_string(),
"libexec/helper".to_string(),
],
)
.unwrap();
let opts = BundleOptions {
directory: d.clone(),
lib_dir: PathBuf::from("lib"),
primary: Some(PathBuf::from("bin/cli")),
entrypoint_selections: vec![entrypoint(
"gui",
Some("bin/gui"),
recipe::SetSelection::All,
)],
strict: true,
..Default::default()
};
let required: HashSet<PathBuf> = HashSet::from([PathBuf::from("libexec/helper")]);
let audit = || audit_unbundled_needs(&opts.directory, &opts.lib_dir, &[]);
let mut findings = audit();
let dropped = drop_unloadable(
&opts,
&mut findings,
audit,
&SourceClassifier::host_only(),
&required,
&[],
&HashSet::new(),
)
.unwrap();
let gone: Vec<&Path> = dropped.iter().map(|x| x.object.as_path()).collect();
assert_eq!(gone, vec![Path::new("lib/plugins/auto.so")]);
assert!(d.join("bin/gui").is_file());
assert!(d.join("libexec/helper").is_file());
let left: Vec<PathBuf> = findings
.iter()
.map(|(o, _)| o.strip_prefix(&d).unwrap().to_path_buf())
.collect();
assert!(left.contains(&PathBuf::from("bin/gui")), "{left:?}");
assert!(left.contains(&PathBuf::from("libexec/helper")), "{left:?}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_publisher_sibling_link_beside_an_omitted_member_stays() {
let d = tmp("restricted-sibling");
fs::create_dir_all(d.join("lib/libfw/plugins")).unwrap();
fs::write(
d.join("lib/libfw/plugins/a.so"),
dyn_object(&["libgone.so.1"], None),
)
.unwrap();
std::os::unix::fs::symlink("a.so", d.join("lib/libfw/plugins/b.so")).unwrap();
std::os::unix::fs::symlink(
"../../../../escape/lib.so",
d.join("lib/libfw/plugins/out.so"),
)
.unwrap();
sysroot::record_generated(
&d,
vec![
"lib/libfw/plugins/a.so".to_string(),
"lib/libfw/plugins/out.so".to_string(),
],
)
.unwrap();
let opts = BundleOptions {
directory: d.clone(),
lib_dir: PathBuf::from("lib"),
..Default::default()
};
let follow = [d.join("lib/libfw/plugins/b.so")];
let audit = || audit_unbundled_needs(&opts.directory, &opts.lib_dir, &follow);
let mut findings = audit();
let dropped = drop_unloadable(
&opts,
&mut findings,
audit,
&SourceClassifier::host_only(),
&HashSet::new(),
&[PathBuf::from("lib/libfw/plugins")],
&HashSet::new(),
)
.unwrap();
let gone: Vec<&Path> = dropped.iter().map(|x| x.object.as_path()).collect();
assert_eq!(gone, vec![Path::new("lib/libfw/plugins/a.so")]);
assert!(
d.join("lib/libfw/plugins/b.so").is_symlink(),
"the publisher's link stays"
);
assert!(
d.join("lib/libfw/plugins/out.so").is_symlink(),
"the outside-tree link stays"
);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_collision_names_one_set_per_distinct_content() {
let providers = [
provider("alpha", 1, &[("libshared.so.1", 1)]),
provider("beta", 2, &[("libshared.so.1", 2)]),
provider("gamma", 3, &[("libshared.so.1", 2)]),
];
let selected: Vec<&sysroot::SetProvider> = providers.iter().collect();
let findings = provider_collisions("app", &selected);
assert_eq!(findings.len(), 1, "{findings:?}");
let text = &findings[0];
assert!(text.contains("alpha"), "{text}");
assert!(text.contains("beta"), "{text}");
assert!(!text.contains("gamma"), "{text}");
}
#[test]
fn disjoint_selections_use_different_providers() {
let d = tmp("disjoint");
fs::create_dir_all(d.join("bin")).unwrap();
fs::write(d.join("bin/cli"), dyn_object(&["libshared.so.1"], None)).unwrap();
fs::write(d.join("bin/gui"), dyn_object(&["libshared.so.1"], None)).unwrap();
let providers = vec![
provider("alpha", 1, &[("libshared.so.1", 1)]),
provider("beta", 2, &[("libshared.so.1", 2)]),
];
let entrypoints = vec![
entrypoint("cli", Some("bin/cli"), names(&["alpha"])),
entrypoint("gui", Some("bin/gui"), names(&["beta"])),
];
audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &providers, &no_host)
.unwrap();
let _ = fs::remove_dir_all(&d);
}
#[test]
fn private_plugin_basenames_are_not_collisions() {
let d = tmp("plugins");
let alpha = d.join("set-alpha");
let beta = d.join("set-beta");
for (set, soname) in [(&alpha, "liba.so.1"), (&beta, "libb.so.1")] {
fs::create_dir_all(set.join("lib/plugins")).unwrap();
fs::write(
set.join("lib/libcarried.so.1"),
dyn_object(&[], Some(soname)),
)
.unwrap();
fs::write(set.join("lib/plugins/helper.so"), dyn_object(&[], None)).unwrap();
}
let alpha_exports = tree_exports(&alpha);
let beta_exports = tree_exports(&beta);
assert_eq!(
alpha_exports
.iter()
.map(|e| e.soname.as_str())
.collect::<Vec<_>>(),
["liba.so.1"]
);
assert_eq!(
beta_exports
.iter()
.map(|e| e.soname.as_str())
.collect::<Vec<_>>(),
["libb.so.1"]
);
fs::create_dir_all(d.join("bin")).unwrap();
fs::write(
d.join("bin/app"),
dyn_object(&["liba.so.1", "libb.so.1"], None),
)
.unwrap();
let providers = vec![
sysroot::SetProvider {
set: "alpha".to_string(),
pin: format!("{:064x}", 1u64),
provides: alpha_exports.iter().map(|e| e.soname.clone()).collect(),
exports: alpha_exports,
},
sysroot::SetProvider {
set: "beta".to_string(),
pin: format!("{:064x}", 2u64),
provides: beta_exports.iter().map(|e| e.soname.clone()).collect(),
exports: beta_exports,
},
];
let entrypoints = vec![entrypoint(
"app",
Some("bin/app"),
recipe::SetSelection::All,
)];
audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &providers, &no_host)
.unwrap();
let _ = fs::remove_dir_all(&d);
}
#[test]
fn an_app_owned_provider_satisfies_the_requirement() {
let d = tmp("app-owned");
fs::create_dir_all(d.join("bin")).unwrap();
fs::create_dir_all(d.join("lib")).unwrap();
fs::write(d.join("bin/app"), dyn_object(&["libshared.so.1"], None)).unwrap();
fs::write(
d.join("lib/libshared.so.1"),
dyn_object(&[], Some("libshared.so.1")),
)
.unwrap();
let providers = vec![provider("alpha", 1, &[("libshared.so.1", 1)])];
let entrypoints = vec![entrypoint(
"app",
Some("bin/app"),
recipe::SetSelection::All,
)];
audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &providers, &no_host)
.unwrap();
let providers = vec![provider("alpha", 1, &[])];
fs::remove_file(d.join("lib/libshared.so.1")).unwrap();
let err =
audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &providers, &no_host)
.unwrap_err();
assert!(err.to_string().contains("libshared.so.1"), "{err}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn an_insufficient_provider_version_fails_naming_what_was_found() {
let d = tmp("version");
fs::create_dir_all(d.join("bin")).unwrap();
fs::write(d.join("bin/cli"), dyn_object(&["libshared.so.2"], None)).unwrap();
let providers = vec![
provider("cli", 1, &[("libshared.so.1", 1)]),
provider("gui", 2, &[("libshared.so.2", 2)]),
];
let entrypoints = vec![entrypoint("cli", Some("bin/cli"), names(&["cli"]))];
let err =
audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &providers, &no_host)
.unwrap_err();
let text = err.to_string();
assert!(text.contains("entrypoint 'cli'"), "{text}");
assert!(text.contains("libshared.so.2"), "{text}");
assert!(text.contains("which gui exports"), "{text}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn an_absent_provider_names_the_need_and_the_object() {
let d = tmp("absent");
fs::create_dir_all(d.join("bin")).unwrap();
fs::write(d.join("bin/app"), dyn_object(&["libgone.so.1"], None)).unwrap();
let entrypoints = vec![entrypoint(
"app",
Some("bin/app"),
recipe::SetSelection::All,
)];
let err = audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &[], &no_host)
.unwrap_err();
let text = err.to_string();
assert!(text.contains("entrypoint 'app'"), "{text}");
assert!(text.contains("bin/app needs libgone.so.1"), "{text}");
assert!(text.contains("nothing in the application"), "{text}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn the_requirements_walk_follows_the_entrypoints_own_closure() {
let d = tmp("closure");
fs::create_dir_all(d.join("bin")).unwrap();
fs::create_dir_all(d.join("lib")).unwrap();
fs::write(d.join("bin/app"), dyn_object(&["libcarried.so.1"], None)).unwrap();
fs::write(
d.join("lib/libcarried.so.1"),
dyn_object(&["libgone.so.1"], Some("libcarried.so.1")),
)
.unwrap();
fs::write(d.join("bin/other"), dyn_object(&["libgui.so.1"], None)).unwrap();
let providers = vec![provider("gui", 1, &[("libgui.so.1", 1)])];
let entrypoints = vec![entrypoint(
"app",
Some("bin/app"),
recipe::SetSelection::All,
)];
let err =
audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &providers, &no_host)
.unwrap_err();
let text = err.to_string();
assert!(
text.contains("lib/libcarried.so.1 needs libgone.so.1"),
"{text}"
);
assert!(!text.contains("libgui.so.1"), "{text}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_selection_only_entrypoint_needs_no_tree() {
let providers = vec![
provider("alpha", 1, &[("liba.so.1", 1)]),
provider("beta", 2, &[("libb.so.1", 2)]),
];
let entrypoints = vec![entrypoint("app", None, recipe::SetSelection::All)];
audit_entrypoint_providers(
Path::new("/onelf-audit-no-such-tree"),
Path::new("lib"),
&entrypoints,
&providers,
&no_host,
)
.unwrap();
let colliding = vec![
provider("alpha", 1, &[("liba.so.1", 1)]),
provider("beta", 2, &[("liba.so.1", 2)]),
];
let err = audit_entrypoint_providers(
Path::new("/onelf-audit-no-such-tree"),
Path::new("lib"),
&entrypoints,
&colliding,
&no_host,
)
.unwrap_err();
assert!(err.to_string().contains("liba.so.1"), "{err}");
}
#[test]
fn an_unknown_selection_name_is_refused() {
let d = tmp("ghost");
fs::create_dir_all(d.join("bin")).unwrap();
fs::write(d.join("bin/app"), dyn_object(&[], None)).unwrap();
let entrypoints = vec![entrypoint("app", Some("bin/app"), names(&["ghost"]))];
let err = audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &[], &no_host)
.unwrap_err();
assert!(err.to_string().contains("set ghost"), "{err}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_missing_entrypoint_executable_is_a_finding() {
let d = tmp("missing");
let entrypoints = vec![entrypoint(
"app",
Some("bin/app"),
recipe::SetSelection::All,
)];
let err = audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &[], &no_host)
.unwrap_err();
assert!(
err.to_string().contains("bin/app is not in the tree"),
"{err}"
);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_failing_audit_writes_nothing() {
let d = tmp("readonly");
fs::create_dir_all(d.join("bin")).unwrap();
fs::create_dir_all(d.join("lib")).unwrap();
fs::write(d.join("bin/app"), dyn_object(&["libgone.so.1"], None)).unwrap();
fs::write(
d.join("lib/libcarried.so.1"),
dyn_object(&[], Some("libcarried.so.1")),
)
.unwrap();
let before = snapshot(&d);
let entrypoints = vec![entrypoint(
"app",
Some("bin/app"),
recipe::SetSelection::All,
)];
assert!(
audit_entrypoint_providers(&d, Path::new("lib"), &entrypoints, &[], &no_host).is_err()
);
assert_eq!(before, snapshot(&d), "the audit must not write");
let _ = fs::remove_dir_all(&d);
}
fn snapshot(root: &Path) -> Vec<(PathBuf, Vec<u8>)> {
let mut files: Vec<(PathBuf, Vec<u8>)> = jwalk::WalkDir::new(root)
.skip_hidden(false)
.sort(true)
.into_iter()
.flatten()
.filter(|e| e.file_type().is_file())
.map(|e| {
let rel = e.path().strip_prefix(root).unwrap().to_path_buf();
(rel, fs::read(e.path()).unwrap())
})
.collect();
files.sort_by(|a, b| a.0.cmp(&b.0));
files
}
}
#[cfg(test)]
mod set_preparation_tests {
use super::*;
use onelf_format::rules::RelativePath;
use onelf_format::{Block, Entry, EntryKind, Flags, Footer, Manifest, ManifestHeader};
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("onelf-prep-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
fn stored_package(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut string_table: Vec<u8> = vec![0];
let mut entries = Vec::new();
let mut payload = Vec::new();
for (name, content) in files {
let name_offset = string_table.len() as u32;
string_table.extend_from_slice(name.as_bytes());
string_table.push(0);
let hash = *blake3::hash(content).as_bytes();
entries.push(Entry {
kind: EntryKind::File,
parent: u32::MAX,
name: name_offset,
mode: 0o644,
mtime_secs: 0,
mtime_nsec: 0,
content_hash: hash,
blocks: vec![Block {
payload_offset: payload.len() as u64,
compressed_size: content.len() as u64,
original_size: content.len() as u64,
content_hash: hash,
}],
symlink_target: 0,
});
payload.extend_from_slice(content);
}
let manifest = Manifest {
header: ManifestHeader {
version: 3,
entry_count: entries.len() as u32,
string_table_size: string_table.len() as u32,
entrypoint_count: 0,
default_entrypoint: 0,
lib_dir_count: 0,
name_offset: 0,
package_id: [0; 32],
prefix_hash: *blake3::hash(&[]).as_bytes(),
footer_hash: [0; 32],
},
entrypoints: Vec::new(),
entries,
lib_dir_offsets: Vec::new(),
string_table,
};
let raw = manifest.serialize().unwrap();
let compressed = zstd::bulk::compress(&raw, 1).unwrap();
let footer = Footer {
format_version: onelf_format::footer::FORMAT_VERSION,
flags: Flags::STORED,
manifest_offset: 0,
manifest_compressed: compressed.len() as u64,
manifest_original: raw.len() as u64,
payload_offset: compressed.len() as u64,
payload_size: payload.len() as u64,
dict_offset: 0,
dict_size: 0,
manifest_checksum: onelf_format::manifest::manifest_checksum(&raw).unwrap(),
};
let mut bytes = Vec::new();
bytes.extend_from_slice(&compressed);
bytes.extend_from_slice(&payload);
footer.write_to(&mut bytes).unwrap();
bytes
}
fn write_build(tag: &str, files: &[(&str, &[u8])]) -> (PathBuf, sysroot::SharedSet) {
let dir = tmp(tag);
let bytes = stored_package(files);
let path = dir.join("set.onelf");
fs::write(&path, &bytes).unwrap();
let set = sysroot::SharedSet {
name: "audio".to_string(),
packages: vec!["alsa-lib".to_string()],
url: format!("file://{}", path.display()),
blake3: blake3::hash(&bytes).to_hex().to_string(),
};
(dir, set)
}
fn at(base: SourceBase, path: &str) -> SourceCandidate {
SourceCandidate {
base,
path: RelativePath::new(path).unwrap(),
}
}
fn data_query<'a>(candidates: &'a [SourceCandidate]) -> SupplyQuery<'a> {
SupplyQuery {
rule: "alsa",
group: "configuration",
kind: ResourceKind::Directory,
destination: "share/alsa",
candidates,
executable: false,
hooks: &[BuildHookId::AlsaConfPatch],
runtime: builtin_runtime("alsa"),
}
}
fn builtin_runtime(id: &str) -> &'static [RuntimePathGroup] {
&onelf_format::catalog::builtins()
.iter()
.find(|d| d.id.as_str() == id)
.unwrap()
.runtime
.paths
}
fn helper_query<'a>(candidates: &'a [SourceCandidate]) -> SupplyQuery<'a> {
SupplyQuery {
rule: "gstreamer",
group: "scanner",
kind: ResourceKind::File,
destination: "libexec/gstreamer-1.0/gst-plugin-scanner",
candidates,
executable: true,
hooks: &[],
runtime: builtin_runtime("gstreamer"),
}
}
fn prepare_and_walk(
appdir: &Path,
sets: &[sysroot::SharedSet],
query: &SupplyQuery,
) -> io::Result<Vec<PreparedPin>> {
let mut plan = DestinationPlan::load(appdir)?;
let mut preparation = SetPreparation::new(sets, &[], Path::new("lib"), false);
assert!(
preparation.supply_group(&mut plan, query)?,
"the declared set was expected to hold the resource"
);
assert!(!plan.has_findings());
let mut actions = Vec::new();
let prepared = preparation.queue_actions(&mut actions);
execute_walk(
appdir,
false,
false,
&appdir.join("lib"),
&actions,
&plan,
&mut WalkOutcome::default(),
)?;
Ok(prepared)
}
fn relative_files(root: &Path) -> Vec<String> {
let mut got: Vec<String> = jwalk::WalkDir::new(root)
.skip_hidden(false)
.sort(true)
.into_iter()
.flatten()
.filter(|e| e.file_type().is_file())
.map(|e| {
e.path()
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect();
got.sort();
got
}
fn mode_of(path: &Path) -> u32 {
fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[test]
fn only_a_resource_a_runtime_mapping_reaches_is_prepared() {
let appdir = tmp("unmapped-subtree");
let (builddir, set) = write_build(
"unmapped-set",
&[("share/alsa/alsa.conf", b"# conf\n".as_slice())],
);
let candidates = [at(SourceBase::InstallationPrefix, "share/alsa")];
let unmapped = SupplyQuery {
runtime: &[],
..data_query(&candidates)
};
let mut plan = DestinationPlan::load(&appdir).unwrap();
let mut preparation =
SetPreparation::new(std::slice::from_ref(&set), &[], Path::new("lib"), false);
assert!(!preparation.supply_group(&mut plan, &unmapped).unwrap());
assert!(preparation.queue_actions(&mut Vec::new()).is_empty());
let prepared = prepare_and_walk(
&appdir,
std::slice::from_ref(&set),
&data_query(&candidates),
)
.unwrap();
assert_eq!(prepared[0].rules, ["alsa"]);
assert!(runtime_maps(
builtin_runtime("gstreamer"),
"lib/gstreamer-1.0/gst-plugin-scanner",
Path::new("lib")
));
let _ = fs::remove_dir_all(&appdir);
let _ = fs::remove_dir_all(&builddir);
}
#[test]
fn a_set_supplied_configuration_prepares_into_its_pins_subtree() {
let appdir = tmp("alsa-subtree");
let (builddir, set) = write_build(
"alsa-set",
&[
(
"share/alsa/alsa.conf",
b"@hooks [\n\t{\n\t\tfunc load\n\t\tfiles [\n\t\t\t\"/etc/alsa/conf.d\"\n\t\t]\n\t}\n]\n",
),
(
"share/alsa/alsa.conf.d/99-fixture.conf",
b"pcm.fixer type plug\n",
),
],
);
let candidates = [at(SourceBase::InstallationPrefix, "share/alsa")];
let prepared = prepare_and_walk(
&appdir,
std::slice::from_ref(&set),
&data_query(&candidates),
)
.expect("the supply");
assert_eq!(prepared.len(), 1);
let pin = &prepared[0];
assert_eq!(pin.set, "audio");
assert_eq!(pin.pin, set.blake3);
assert_eq!(
pin.root,
Path::new(".onelf-prepared/audio").join(set.blake3.as_str())
);
let root = appdir.join(&pin.root);
assert_eq!(
relative_files(&root),
[
"share/alsa/alsa.conf",
"share/alsa/alsa.conf.d/99-fixture.conf"
],
"the subtree holds exactly the declared files"
);
let conf = fs::read_to_string(root.join("share/alsa/alsa.conf")).unwrap();
assert!(
conf.contains("{ @func datadir } \"/alsa.conf.d\""),
"the registered hook anchored the subtree's own snippets: {conf}"
);
assert_eq!(
fs::read_to_string(root.join("share/alsa/alsa.conf.d/99-fixture.conf")).unwrap(),
"pcm.fixer type plug\n"
);
assert_eq!(mode_of(&root.join("share/alsa/alsa.conf")), 0o644);
assert_eq!(pin.files.len(), 2);
let _ = fs::remove_dir_all(&appdir);
let _ = fs::remove_dir_all(&builddir);
}
#[test]
fn helpers_prepare_only_without_a_foreign_interpreter() {
let appdir = tmp("helper-safety");
let candidates = [
at(SourceBase::LibraryDir, "gstreamer-1.0/gst-plugin-scanner"),
at(
SourceBase::InstallationPrefix,
"libexec/gstreamer-1.0/gst-plugin-scanner",
),
];
let (envdir, env_set) = write_build(
"helper-env",
&[(
"lib/gstreamer-1.0/gst-plugin-scanner",
b"#!/usr/bin/env python3\nimport sys\n",
)],
);
let mut plan = DestinationPlan::load(&appdir).unwrap();
let mut preparation =
SetPreparation::new(std::slice::from_ref(&env_set), &[], Path::new("lib"), false);
let err = preparation
.supply_group(&mut plan, &helper_query(&candidates))
.unwrap_err();
let text = err.to_string();
assert!(text.contains("/usr/bin/env"), "{text}");
assert!(text.contains("/bin/sh"), "{text}");
assert!(
text.contains("libexec/gstreamer-1.0/gst-plugin-scanner"),
"{text}"
);
drop(preparation);
drop(plan);
assert!(
!appdir.join(PREPARED_DIR).exists(),
"a refused helper prepared nothing"
);
let (shdir, sh_set) = write_build(
"helper-sh",
&[(
"lib/gstreamer-1.0/gst-plugin-scanner",
b"#!/bin/sh\nexec \"$@\"\n",
)],
);
let prepared = prepare_and_walk(
&appdir,
std::slice::from_ref(&sh_set),
&helper_query(&candidates),
)
.expect("the /bin/sh helper");
let dest = appdir
.join(&prepared[0].root)
.join("libexec/gstreamer-1.0/gst-plugin-scanner");
assert_eq!(
fs::read(&dest).unwrap(),
b"#!/bin/sh\nexec \"$@\"\n",
"the helper's bytes are the pinned build's bytes"
);
assert_eq!(mode_of(&dest), 0o755);
let (elfdir, elf_set) = write_build(
"helper-elf",
&[(
"lib/gstreamer-1.0/gst-plugin-scanner",
b"\x7fELFfixture-scanner",
)],
);
let prepared = prepare_and_walk(
&appdir,
std::slice::from_ref(&elf_set),
&helper_query(&candidates),
)
.expect("the ELF helper");
let dest = appdir
.join(&prepared[0].root)
.join("libexec/gstreamer-1.0/gst-plugin-scanner");
assert_eq!(fs::read(&dest).unwrap(), b"\x7fELFfixture-scanner");
assert_eq!(mode_of(&dest), 0o755);
for dir in [&envdir, &shdir, &elfdir] {
let _ = fs::remove_dir_all(dir);
}
let _ = fs::remove_dir_all(&appdir);
}
#[test]
fn tampered_set_bytes_are_refused_at_the_pin_or_the_read() {
let appdir = tmp("tamper");
let candidates = [at(SourceBase::InstallationPrefix, "share/alsa")];
let (builddir, set) = write_build("tamper-set", &[("share/alsa/alsa.conf", b"conf\n")]);
let build = builddir.join("set.onelf");
let original = fs::read(&build).unwrap();
let mut flipped = original.clone();
let at = flipped.len() - onelf_format::FOOTER_SIZE - 1;
flipped[at] ^= 0xff;
fs::write(&build, &flipped).unwrap();
let err = sysroot::VerifiedSet::open(&set)
.err()
.expect("a build that is not the pin must be refused");
assert!(err.contains("not the pinned build"), "{err}");
let mut plan = DestinationPlan::load(&appdir).unwrap();
let mut preparation =
SetPreparation::new(std::slice::from_ref(&set), &[], Path::new("lib"), false);
assert!(
!preparation
.supply_group(&mut plan, &data_query(&candidates))
.unwrap(),
"an unpinned build supplies nothing"
);
drop(preparation);
drop(plan);
assert!(!appdir.join(PREPARED_DIR).exists());
let corrupt = sysroot::SharedSet {
blake3: blake3::hash(&flipped).to_hex().to_string(),
..set
};
let mut plan = DestinationPlan::load(&appdir).unwrap();
let mut preparation =
SetPreparation::new(std::slice::from_ref(&corrupt), &[], Path::new("lib"), false);
let err = preparation
.supply_group(&mut plan, &data_query(&candidates))
.unwrap_err();
let text = err.to_string();
assert!(text.contains("tampered or corrupt package"), "{text}");
assert!(text.contains("audio"), "{text}");
assert!(text.contains("alsa.conf"), "{text}");
drop(preparation);
drop(plan);
assert!(!appdir.join(PREPARED_DIR).exists());
let _ = fs::remove_dir_all(&appdir);
let _ = fs::remove_dir_all(&builddir);
}
#[test]
fn two_versions_of_one_set_prepare_side_by_side() {
let appdir = tmp("versions");
let candidates = [at(SourceBase::InstallationPrefix, "share/alsa")];
let (dir1, set1) = write_build("v1", &[("share/alsa/alsa.conf", b"conf v1\n")]);
let (dir2, set2) = write_build("v2", &[("share/alsa/alsa.conf", b"conf v2\n")]);
assert_ne!(set1.blake3, set2.blake3);
let first = prepare_and_walk(&appdir, &[set1], &data_query(&candidates))
.expect("the first version");
sysroot::record_generated(
&appdir,
first.iter().flat_map(|p| p.files.iter().cloned()).collect(),
)
.unwrap();
let second = prepare_and_walk(&appdir, &[set2], &data_query(&candidates))
.expect("the second version");
sysroot::record_generated(
&appdir,
second
.iter()
.flat_map(|p| p.files.iter().cloned())
.collect(),
)
.unwrap();
assert_ne!(first[0].root, second[0].root);
assert_eq!(
fs::read_to_string(appdir.join(&first[0].root).join("share/alsa/alsa.conf")).unwrap(),
"conf v1\n",
"the first pin's subtree keeps its version"
);
assert_eq!(
fs::read_to_string(appdir.join(&second[0].root).join("share/alsa/alsa.conf")).unwrap(),
"conf v2\n"
);
let ledger: Vec<String> = sysroot::read_generated(&appdir)
.unwrap()
.into_iter()
.map(|e| e.path)
.collect();
for file in first[0].files.iter().chain(second[0].files.iter()) {
assert!(ledger.contains(file), "{file} missing from {ledger:?}");
}
let _ = fs::remove_dir_all(&appdir);
let _ = fs::remove_dir_all(&dir1);
let _ = fs::remove_dir_all(&dir2);
}
#[test]
fn the_prepared_subtree_is_keyed_by_set_and_pin() {
let pin = "AB12CD34".to_string() + &"0".repeat(56);
let root = prepared_subtree("audio", &pin).unwrap();
assert_eq!(
root,
Path::new(".onelf-prepared/audio").join(pin.to_ascii_lowercase())
);
assert!(prepared_subtree("bad name", &pin).is_err());
assert!(prepared_subtree("audio", "not-a-digest").is_err());
}
#[test]
fn a_dry_run_and_an_unreachable_build_prepare_nothing() {
let appdir = tmp("no-fetch");
let candidates = [at(SourceBase::InstallationPrefix, "share/alsa")];
let (builddir, set) = write_build("dry", &[("share/alsa/alsa.conf", b"conf\n")]);
let mut plan = DestinationPlan::load(&appdir).unwrap();
let mut preparation =
SetPreparation::new(std::slice::from_ref(&set), &[], Path::new("lib"), true);
assert!(!preparation.active());
assert!(
!preparation
.supply_group(&mut plan, &data_query(&candidates))
.unwrap()
);
drop(preparation);
drop(plan);
assert!(!appdir.join(PREPARED_DIR).exists());
let ghost = sysroot::SharedSet {
url: format!("file://{}", builddir.join("absent.onelf").display()),
..set
};
let mut plan = DestinationPlan::load(&appdir).unwrap();
let mut preparation =
SetPreparation::new(std::slice::from_ref(&ghost), &[], Path::new("lib"), false);
assert!(
!preparation
.supply_group(&mut plan, &data_query(&candidates))
.unwrap()
);
drop(preparation);
drop(plan);
assert!(!appdir.join(PREPARED_DIR).exists());
let _ = fs::remove_dir_all(&appdir);
let _ = fs::remove_dir_all(&builddir);
}
#[test]
fn a_sysroot_declared_set_supplies_through_the_same_preparation() {
let appdir = tmp("sysroot-supply");
let candidates = [at(
SourceBase::LibraryDir,
"gstreamer-1.0/gst-plugin-scanner",
)];
let (builddir, set) = write_build(
"sysroot-set",
&[(
"lib/gstreamer-1.0/gst-plugin-scanner",
b"\x7fELFfixture-scanner",
)],
);
let mut plan = DestinationPlan::load(&appdir).unwrap();
let mut preparation =
SetPreparation::new(&[], std::slice::from_ref(&set), Path::new("lib"), true);
assert!(!preparation.active());
assert!(
!preparation
.supply_group(&mut plan, &helper_query(&candidates))
.unwrap()
);
drop(preparation);
drop(plan);
assert!(!appdir.join(PREPARED_DIR).exists());
let mut plan = DestinationPlan::load(&appdir).unwrap();
let mut preparation =
SetPreparation::new(&[], std::slice::from_ref(&set), Path::new("lib"), false);
assert!(
preparation
.supply_group(&mut plan, &helper_query(&candidates))
.unwrap(),
"the sysroot's declared set holds the helper"
);
assert!(!plan.has_findings());
let mut actions = Vec::new();
let prepared = preparation.queue_actions(&mut actions);
execute_walk(
&appdir,
false,
false,
&appdir.join("lib"),
&actions,
&plan,
&mut WalkOutcome::default(),
)
.unwrap();
assert_eq!(prepared.len(), 1);
assert_eq!(prepared[0].set, "audio");
assert_eq!(prepared[0].pin, set.blake3);
let dest = appdir
.join(&prepared[0].root)
.join("libexec/gstreamer-1.0/gst-plugin-scanner");
assert_eq!(fs::read(&dest).unwrap(), b"\x7fELFfixture-scanner");
assert_eq!(mode_of(&dest), 0o755);
let _ = fs::remove_dir_all(&appdir);
let _ = fs::remove_dir_all(&builddir);
}
}